From 6cde08dac3e7e1a9585a19c84fda7cc54424dd90 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 19 Jun 2026 22:42:05 +0200 Subject: [PATCH] Fix: Komplett leere Liste nach unsauberem Neustart (Datenverlust bei Stromausfall/Crash waehrend Download) Ursache: Session-Writes (writeFile + atomic rename) liefen ohne fsync. Waehrend eines Downloads feuert persistSoon alle 700ms-3s, die Session-Datei bleibt damit dauerhaft dirty im OS-Cache. Bei hartem Stromausfall auf NTFS ist die rename-Metadatentransaktion journaled (durable), aber die Datenbloecke der temp-Datei sind nicht geflusht -> primary zeigt nach Reboot auf Null/Garbage. Die .bak-Kopie stammt per copyFileSync aus einer ebenfalls ungeflushten primary -> ebenfalls korrupt. loadSession faellt durch primary -> bak -> temp auf emptySession() durch, und der naechste persistSoon speichert diese leere Session ueber die Platte -> dauerhaft leer. Tritt nur bei UNSAUBEREM Neustart auf (sauberes Beenden flusht ohnehin). Fix in drei Schichten: - Durable atomic write: temp wird vor dem rename gefsynct. Reihenfolge zwingend write -> fsync -> close -> rename (NTFS kann eine Datei mit offenem Handle nicht renamen). Sync-Pfad via openSync/writeSync/fsyncSync/closeSync, Async-Pfad via FileHandle.sync() (laeuft auf dem libuv-Threadpool, blockiert den Hot-Path nicht). Kein Throttle: ein throttle-skip wuerde eine ungeflushte temp ueber die durable primary renamen und das Korruptionsfenster wieder oeffnen. - Read-Retry: readSessionFile wiederholt bei transienten Sperren (EBUSY/EPERM/EAGAIN, z.B. Virenscanner/Disk-not-ready beim Boot) 5x mit Backoff. EACCES und JSON-Parse-Fehler werden nicht wiederholt. - Empty-Clobber-Guard: loadSessionWithStatus meldet, ob alle Tiers unlesbar waren (Status empty-unreadable). In dem Fall blockiert der DownloadManager das Speichern einer leeren Session ueber vorhandene Daten, bis wieder echte Daten vorliegen; die erste nicht-leere Speicherung hebt den Schutz auf. Tests: tests/session-restart-loss.test.ts um Status-Klassifizierung, fsync-Nachweis, async-Roundtrip (close-before-rename), EBUSY-Retry und Guard-Clear-Pfad erweitert. Suite 929 gruen, tsc unveraendert bei 6 Baseline-Fehlern. --- src/main/app-controller.ts | 6 +- src/main/download-manager.ts | 38 ++++++++- src/main/storage.ts | 119 ++++++++++++++++++++++------- tests/session-restart-loss.test.ts | Bin 4701 -> 12682 bytes 4 files changed, 129 insertions(+), 34 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 10e7450..021f501 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -39,7 +39,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { runInstallWithResume } from "./update-install-flow"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; @@ -112,7 +112,8 @@ export class AppController { initTraceLog(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - const session = loadSession(this.storagePaths); + const loadResult = loadSessionWithStatus(this.storagePaths); + const session = loadResult.session; this.megaWebFallback = new MegaWebFallback(() => ({ login: this.settings.megaLogin, password: this.settings.megaPassword @@ -126,6 +127,7 @@ export class AppController { realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + protectEmptyClobber: loadResult.status === "empty-unreadable", onHistoryEntry: (entry: HistoryEntry) => { addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index ac7c496..f38a8a9 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -363,6 +363,7 @@ type DownloadManagerOptions = { bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; invalidateMegaSession?: () => void; onHistoryEntry?: HistoryEntryCallback; + protectEmptyClobber?: boolean; }; function generateHistoryId(): string { @@ -1688,6 +1689,10 @@ export class DownloadManager extends EventEmitter { public blockAllPersistence = false; + private protectAgainstEmptyClobber = false; + + private emptyClobberProtectionLogged = false; + private debridService: DebridService; private invalidateMegaSessionFn?: () => void; @@ -1831,6 +1836,10 @@ export class DownloadManager extends EventEmitter { this.session = session; this.itemCount = Object.keys(this.session.items).length; this.storagePaths = storagePaths; + this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber); + if (this.protectAgainstEmptyClobber) { + logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen"); + } this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict, allDebridWebUnrestrict: options.allDebridWebUnrestrict, @@ -5821,7 +5830,9 @@ export class DownloadManager extends EventEmitter { const itemCount = Object.keys(this.session.items).length; logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); this.foldRuntimeIntoSettings(nowMs()); - saveSession(this.storagePaths, this.session); + if (!this.guardBlocksSessionSave()) { + saveSession(this.storagePaths, this.session); + } saveSettings(this.storagePaths, this.settings); } else { logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`); @@ -6124,10 +6135,29 @@ export class DownloadManager extends EventEmitter { }, delay); } + private guardBlocksSessionSave(): boolean { + if (!this.protectAgainstEmptyClobber) { + return false; + } + const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0; + if (isEmpty) { + if (!this.emptyClobberProtectionLogged) { + logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet"); + this.emptyClobberProtectionLogged = true; + } + return true; + } + this.protectAgainstEmptyClobber = false; + logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert"); + return false; + } + private persistNow(): void { const now = nowMs(); this.lastPersistAt = now; - void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); + if (!this.guardBlocksSessionSave()) { + void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); + } if (now - this.lastSettingsPersistAt >= 30000) { this.foldRuntimeIntoSettings(now); this.lastSettingsPersistAt = now; @@ -6141,7 +6171,9 @@ export class DownloadManager extends EventEmitter { const itemCount = Object.keys(this.session.items).length; logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`); this.foldRuntimeIntoSettings(nowMs()); - saveSession(this.storagePaths, this.session); + if (!this.guardBlocksSessionSave()) { + saveSession(this.storagePaths, this.session); + } saveSettings(this.storagePaths, this.settings); } diff --git a/src/main/storage.ts b/src/main/storage.ts index 07ed598..21b7b53 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -890,21 +890,50 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se return session; } +const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); + +function sleepSyncMs(ms: number): void { + if (ms <= 0) { + return; + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + function readSessionFile(filePath: string): SessionState | null { + let raw: string | null = null; + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + raw = fs.readFileSync(filePath, "utf8"); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) { + const backoffMs = 100 * 2 ** (attempt - 1); + logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`); + sleepSyncMs(backoffMs); + continue; + } + if (code === "EACCES" || code === "EPERM") { + logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } else { + logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`); + } + return null; + } + } + if (raw === null) { + return null; + } try { - const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + const parsed = JSON.parse(raw) as unknown; const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); const pkgCount = Object.keys(session.packages).length; const itemCount = Object.keys(session.items).length; logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); return session; } catch (error) { - const code = (error as NodeJS.ErrnoException)?.code || ""; - if (code === "EACCES" || code === "EPERM") { - logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); - } else { - logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`); - } + logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`); return null; } } @@ -1004,17 +1033,31 @@ export function emptySession(): SessionState { }; } -export function loadSession(paths: StoragePaths): SessionState { +export type SessionLoadStatus = + | "ok" + | "recovered-backup" + | "recovered-temp" + | "empty-fresh" + | "empty-unreadable"; + +export interface SessionLoadResult { + session: SessionState; + status: SessionLoadStatus; +} + +export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult { ensureBaseDir(paths.baseDir); const backupFile = sessionBackupPath(paths.sessionFile); + const syncTempFile = sessionTempPath(paths.sessionFile, "sync"); + const asyncTempFile = sessionTempPath(paths.sessionFile, "async"); const primaryExists = fs.existsSync(paths.sessionFile); + const backupExists = fs.existsSync(backupFile); + const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile); + if (!primaryExists) { - const hasRecoverable = fs.existsSync(backupFile) - || fs.existsSync(sessionTempPath(paths.sessionFile, "sync")) - || fs.existsSync(sessionTempPath(paths.sessionFile, "async")); - if (!hasRecoverable) { + if (!backupExists && !anyTempExists) { logger.info("Keine Session-Datei vorhanden, starte mit leerer Session"); - return emptySession(); + return { session: emptySession(), status: "empty-fresh" }; } logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht"); } @@ -1023,7 +1066,7 @@ export function loadSession(paths: StoragePaths): SessionState { if (primary) { const primaryPkgCount = Object.keys(primary.packages).length; - if (primaryPkgCount === 0 && fs.existsSync(backupFile)) { + if (primaryPkgCount === 0 && backupExists) { const backup = readSessionFile(backupFile); if (backup) { const backupPkgCount = Object.keys(backup.packages).length; @@ -1031,29 +1074,27 @@ export function loadSession(paths: StoragePaths): SessionState { logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); try { const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); - const tempPath = sessionTempPath(paths.sessionFile, "sync"); - fs.writeFileSync(tempPath, payload, "utf8"); - syncRenameWithExdevFallback(tempPath, paths.sessionFile); + fs.writeFileSync(syncTempFile, payload, "utf8"); + syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); } catch { } - return backup; + return { session: backup, status: "recovered-backup" }; } } } - return primary; + return { session: primary, status: "ok" }; } - const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null; + const backup = backupExists ? readSessionFile(backupFile) : null; if (backup) { logger.warn("Session defekt, Backup-Datei wird verwendet"); try { const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); - const tempPath = sessionTempPath(paths.sessionFile, "sync"); - fs.writeFileSync(tempPath, payload, "utf8"); - syncRenameWithExdevFallback(tempPath, paths.sessionFile); + fs.writeFileSync(syncTempFile, payload, "utf8"); + syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); } catch { } - return backup; + return { session: backup, status: "recovered-backup" }; } for (const kind of ["sync", "async"] as const) { @@ -1067,13 +1108,21 @@ export function loadSession(paths: StoragePaths): SessionState { fs.writeFileSync(paths.sessionFile, payload, "utf8"); } catch { } - return tmpSession; + return { session: tmpSession, status: "recovered-temp" }; } } } - logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)"); - return emptySession(); + if (primaryExists || backupExists || anyTempExists) { + logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv"); + return { session: emptySession(), status: "empty-unreadable" }; + } + + return { session: emptySession(), status: "empty-fresh" }; +} + +export function loadSession(paths: StoragePaths): SessionState { + return loadSessionWithStatus(paths).session; } export function saveSession(paths: StoragePaths, session: SessionState): void { @@ -1088,7 +1137,13 @@ export function saveSession(paths: StoragePaths, session: SessionState): void { const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const tempPath = sessionTempPath(paths.sessionFile, "sync"); try { - fs.writeFileSync(tempPath, payload, "utf8"); + const fd = fs.openSync(tempPath, "w"); + try { + fs.writeSync(fd, payload); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } syncRenameWithExdevFallback(tempPath, paths.sessionFile); } catch (error) { try { fs.rmSync(tempPath, { force: true }); } catch { } @@ -1104,7 +1159,13 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat await fs.promises.mkdir(paths.baseDir, { recursive: true }); await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); const tempPath = sessionTempPath(paths.sessionFile, "async"); - await fsp.writeFile(tempPath, payload, "utf8"); + const handle = await fsp.open(tempPath, "w"); + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } if (generation < syncSaveGeneration) { await fsp.rm(tempPath, { force: true }).catch(() => {}); return; diff --git a/tests/session-restart-loss.test.ts b/tests/session-restart-loss.test.ts index 73f4544612dbe0ed1c13a13bad5453706d0215b7..39bb814601160d26298c32d9183ee94429479450 100644 GIT binary patch literal 12682 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 ji2H|uvJWx=fj@%sT?pZ7}EmzO>~_-l6^lUvsD= delta 1235 zcmb7C&ui0Q7^ZVuy0~?%O}pB5+h^R|x>dJ<9@grx&LLiOqIeLdd?sJpV4BqAYsVO6 zcR}3ts(*&Vo1h@O3Jypr-_B7g7H-02KL;^GrUQ3IFuu_?? z++?<`nT8YVCRGoFoNGqGcG5PjvX*p9n6p<5rCHM6Qjgst(Yp_R`}*L$uLpL0vpx}a zI)=ldNb{Uo3sfnNkEh6mIkF0wBNLe2I&!WD_t(T)Dl3B8nG0iNF%WZ3+p=%^~3eHeawT6VG)DOG5lm z;=B^h!gnDFqCXC!{u6M;KM2f!2A=xU&Udci&lApe#oY7XLr-jqDR?8^g`a(wAw*(u zBRGlZlr#+2r6|6(hhp%81YsxO??BUhlaesqI|-*Faj4>dBmJ&7FecV%EmMsX&B*Io zp~$l>SlvEZ*p5gzcr7>vF9%}qHW&n>Ki;1v^1Qr+#>7({dp_^G({|K)G74XkN!SX^ zz}GGnm(fh1_sjauUZOGdyz@MPB-;oGiYwK7~reOHkIJ9j2&&JU(2O2vMAH$)4 i%2