Release v1.0.2 – Stromausfall-Schutz

Atomare Schreibvorgänge (schützt vor Dateikorruption durch Stromausfall):
- _atomic_write(): schreibt erst .tmp, sync auf Disk, dann os.replace() (POSIX-atomar)
- save_cfg() / save_state() verwenden _atomic_write statt write_text()
- Update-Install schreibt app.py.tmp, fsync, dann atomares Umbenennen

Korruptionsschutz beim Laden:
- load_cfg() / load_state(): bei JSON-Fehler Warnung loggen, .corrupt-Backup anlegen,
  sicher mit Standardwerten weiterlaufen statt zu crashen

Schutz vor unvollständigen Kopien:
- Jede Datei wird als .picopy_tmp kopiert, erst nach Abschluss atomar umbenannt
- Duplikat-Skip prüft Dateigröße: stimmt sie nicht überein, war die Datei abgeschnitten
  und wird automatisch neu kopiert
- .picopy_incomplete Marker-Datei im Zielordner während des Kopiervorgangs
- Veraltete .picopy_tmp-Dateien werden beim Kopierstart bereinigt
- subprocess.run(['sync']) vor dem Unmounten der Laufwerke

Startup-Bereinigung:
- cleanup_stale_mounts() beim Start: hängende /mnt/picopy-Mounts aus vorherigen
  Abstürzen werden sauber per umount -l entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 02:11:18 +02:00
parent b54fe0cd60
commit e96ce8a7d3
2 changed files with 88 additions and 22 deletions

94
app.py
View File

@@ -18,7 +18,7 @@ from flask import Flask, jsonify, request
app = Flask(__name__) app = Flask(__name__)
VERSION = '1.0.1' VERSION = '1.0.2'
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main' RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
BASE_DIR = Path('/opt/picopy') BASE_DIR = Path('/opt/picopy')
@@ -69,19 +69,22 @@ def load_state():
global copy_state global copy_state
try: try:
if STATE_FILE.exists(): if STATE_FILE.exists():
saved = json.loads(STATE_FILE.read_text()) saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
# Nur nicht-laufende Daten wiederherstellen
saved['running'] = False saved['running'] = False
saved['current'] = '' saved['current'] = ''
copy_state.update(saved) copy_state.update(saved)
except Exception: except (json.JSONDecodeError, ValueError) as e:
pass log.warning(f'state.json korrupt ({e}), starte mit leerem Zustand')
try: STATE_FILE.rename(STATE_FILE.with_suffix('.corrupt'))
except Exception: pass
except Exception as e:
log.warning(f'state.json nicht lesbar: {e}')
def save_state(): def save_state():
try: try:
with copy_lock: with copy_lock:
data = dict(copy_state) data = dict(copy_state)
STATE_FILE.write_text(json.dumps(data)) _atomic_write(STATE_FILE, json.dumps(data))
except Exception: except Exception:
pass pass
@@ -100,13 +103,17 @@ def load_cfg():
cfg = DEFAULT_CONFIG.copy() cfg = DEFAULT_CONFIG.copy()
try: try:
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
cfg.update(json.loads(CONFIG_FILE.read_text())) cfg.update(json.loads(CONFIG_FILE.read_text(encoding='utf-8')))
except Exception: except (json.JSONDecodeError, ValueError) as e:
pass log.error(f'config.json korrupt ({e}), verwende Standardwerte')
try: CONFIG_FILE.rename(CONFIG_FILE.with_suffix('.corrupt'))
except Exception: pass
except Exception as e:
log.warning(f'config.json nicht lesbar: {e}')
return cfg return cfg
def save_cfg(cfg): def save_cfg(cfg):
CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) _atomic_write(CONFIG_FILE, json.dumps(cfg, indent=2))
# ── WiFi Hilfsfunktionen ───────────────────────────────────────────────────── # ── WiFi Hilfsfunktionen ─────────────────────────────────────────────────────
@@ -379,6 +386,32 @@ def _file_md5(p: Path) -> str:
return h.hexdigest() return h.hexdigest()
def _atomic_write(path: Path, content: str) -> None:
"""Schreibt atomar: erst .tmp, dann os.replace() sicher bei Stromausfall."""
tmp = path.with_suffix(path.suffix + '.tmp')
try:
tmp.write_text(content, encoding='utf-8')
with open(tmp, 'rb') as fh:
os.fsync(fh.fileno()) # Daten wirklich auf Datenträger schreiben
os.replace(str(tmp), str(path)) # Atomares Umbenennen (POSIX-Garantie)
except Exception:
try: tmp.unlink(missing_ok=True)
except Exception: pass
raise
def cleanup_stale_mounts() -> None:
"""Bereinigt beim Start hängen gebliebene PiCopy-Mounts (z.B. nach Stromausfall)."""
try:
with open('/proc/mounts') as fh:
mps = [line.split()[1] for line in fh if '/mnt/picopy' in line]
for mp in mps:
log.info(f'Bereinige veralteten Mount: {mp}')
subprocess.run(['umount', '-l', mp], capture_output=True)
except Exception as e:
log.warning(f'Stale-Mount-Bereinigung fehlgeschlagen: {e}')
def _fmt_bytes(b): def _fmt_bytes(b):
if b < 1024: return f'{b} B' if b < 1024: return f'{b} B'
if b < 1024**2: return f'{b/1024:.1f} KB' if b < 1024**2: return f'{b/1024:.1f} KB'
@@ -421,6 +454,18 @@ def do_copy(src_dev, dst_dev, cfg):
dst_dir.mkdir(parents=True, exist_ok=True) dst_dir.mkdir(parents=True, exist_ok=True)
add_log(f'Zielordner: {dst_dir}') add_log(f'Zielordner: {dst_dir}')
# Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen
for stale in dst_dir.rglob('*.picopy_tmp'):
log.info(f'Bereinige Temp-Datei: {stale}')
stale.unlink(missing_ok=True)
# Incomplete-Marker: existiert dieser nach Neustart, war die letzte Kopie unterbrochen
incomplete_marker = dst_dir / '.picopy_incomplete'
incomplete_marker.write_text(json.dumps({
'started': datetime.now().isoformat(),
'source': src_dev.get('label', ''),
}))
# ── Dateien sammeln & filtern ────────────────────────────────────── # ── Dateien sammeln & filtern ──────────────────────────────────────
src_path = Path(src_mp) src_path = Path(src_mp)
all_files = [f for f in src_path.rglob('*') if f.is_file()] all_files = [f for f in src_path.rglob('*') if f.is_file()]
@@ -453,18 +498,30 @@ def do_copy(src_dev, dst_dev, cfg):
if dst_f.exists(): if dst_f.exists():
if dup_mode == 'skip': if dup_mode == 'skip':
# Größenvergleich: stimmt die Größe nicht überein, war die Datei
# beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten
if dst_f.stat().st_size == f.stat().st_size:
skipped += 1 skipped += 1
with copy_lock: with copy_lock:
copy_state.update(done=i+1, copy_state.update(done=i+1,
progress=int((i+1)/total*100) if total else 100, progress=int((i+1)/total*100) if total else 100,
current=str(f.name)) current=str(f.name))
continue continue
else:
add_log(f'Unvollständige Datei gefunden, wird neu kopiert: {f.name}')
elif dup_mode == 'rename': elif dup_mode == 'rename':
dst_f = _unique_path(dst_f) dst_f = _unique_path(dst_f)
# overwrite: einfach weitermachen # overwrite: einfach weitermachen
fsize = f.stat().st_size fsize = f.stat().st_size
shutil.copy2(f, dst_f) tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
try:
shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren
os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen
except Exception:
try: tmp_f.unlink(missing_ok=True)
except Exception: pass
raise
copied_pairs.append((f, dst_f)) copied_pairs.append((f, dst_f))
with copy_lock: with copy_lock:
@@ -538,6 +595,11 @@ def do_copy(src_dev, dst_dev, cfg):
else: else:
add_log('Quelle geleert ✓') add_log('Quelle geleert ✓')
# Alle Daten auf den Datenträger schreiben bevor wir abmelden
subprocess.run(['sync'], capture_output=True)
try: incomplete_marker.unlink(missing_ok=True)
except Exception: pass
with copy_lock: with copy_lock:
copy_state['last_copy'] = datetime.now().isoformat() copy_state['last_copy'] = datetime.now().isoformat()
add_log('Fertig! ' + ', '.join(msg_parts)) add_log('Fertig! ' + ', '.join(msg_parts))
@@ -551,6 +613,7 @@ def do_copy(src_dev, dst_dev, cfg):
add_log(f'Fehler: {e}') add_log(f'Fehler: {e}')
finally: finally:
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
if src_owned and src_mp: if src_owned and src_mp:
subprocess.run(['umount', src_mp], capture_output=True) subprocess.run(['umount', src_mp], capture_output=True)
if dst_owned and dst_mp: if dst_owned and dst_mp:
@@ -1032,9 +1095,11 @@ def r_update_install():
# Syntax-Check bevor wir irgendetwas überschreiben # Syntax-Check bevor wir irgendetwas überschreiben
compile(new_code, 'app.py', 'exec') compile(new_code, 'app.py', 'exec')
tmp = Path('/tmp/picopy_update.py') tmp = Path('/opt/picopy/app.py.tmp')
tmp.write_text(new_code) tmp.write_text(new_code, encoding='utf-8')
shutil.copy(str(tmp), '/opt/picopy/app.py') with open(tmp, 'rb') as fh:
os.fsync(fh.fileno()) # Sicherstellen dass Daten auf der Platte sind
os.replace(str(tmp), '/opt/picopy/app.py') # Atomares Umbenennen
log.info('Update installiert starte Dienst neu…') log.info('Update installiert starte Dienst neu…')
# Systemd startet den Dienst automatisch neu # Systemd startet den Dienst automatisch neu
@@ -1992,6 +2057,7 @@ function flash(id,cls,msg){
if __name__ == '__main__': if __name__ == '__main__':
cleanup_stale_mounts()
load_state() load_state()
threading.Thread(target=usb_monitor, daemon=True).start() threading.Thread(target=usb_monitor, daemon=True).start()
threading.Thread(target=wifi_monitor, daemon=True).start() threading.Thread(target=wifi_monitor, daemon=True).start()

View File

@@ -1 +1 @@
1.0.1 1.0.2