From e8c6761bf0e54bb20c0e5c4dd60d1522ed765e7d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 26 Mar 2026 19:47:58 +0100 Subject: [PATCH] Harden state persistence and fix provider abort handling - Add safeJsonReplacer to all JSON.stringify calls in storage.ts to prevent NaN/Infinity values from corrupting state files and causing queue loss - Fix LinkSnappy and 1Fichier retry loops: use sleepWithSignal() instead of sleep() so abort signals are respected during retry delays - Fix Debrid-Link polling: replace raw setTimeout with sleepWithSignal() so URL generation polling can be cancelled - Fix Mega-Debrid doConnectApi: clear token cache on 401/403 responses instead of caching invalid credentials for 20 minutes - Add logging when normalizeLoadedSession removes orphaned items so data loss during startup is visible in logs Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/debrid.ts | 12 +++++++++--- src/main/storage.ts | 31 ++++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 785e600..7e40a73 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1545,10 +1545,16 @@ class MegaDebridClient { }); const text = await response.text(); if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.clearTokenCache(); + } return null; } const payload = parseJsonSafe(text); if (!payload || payload.response_code !== "ok") { + if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { + this.clearTokenCache(); + } return null; } const token = String(payload.token || "").trim(); @@ -2467,7 +2473,7 @@ class DebridLinkClient { throw new Error("aborted"); } if (poll > 0) { - await new Promise((resolve) => setTimeout(resolve, 2000)); + await sleepWithSignal(2000, signal); } const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); if (refreshed) { @@ -2738,7 +2744,7 @@ class LinkSnappyClient { throw error; } if (attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt), signal); + await sleepWithSignal(retryDelay(attempt), signal); } } } @@ -2808,7 +2814,7 @@ class OneFichierClient { throw error; } if (attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt), signal); + await sleepWithSignal(retryDelay(attempt), signal); } } } diff --git a/src/main/storage.ts b/src/main/storage.ts index a185857..f0a067e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -501,6 +501,14 @@ function ensureBaseDir(baseDir: string): void { fs.mkdirSync(baseDir, { recursive: true }); } +/** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */ +function safeJsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "number" && !Number.isFinite(value)) { + return null; + } + return value; +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -608,11 +616,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState { }; } + let orphanedItemCount = 0; for (const [itemId, item] of Object.entries(itemsById)) { if (!packagesById[item.packageId]) { + orphanedItemCount += 1; delete itemsById[itemId]; } } + if (orphanedItemCount > 0) { + logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); + } for (const pkg of Object.values(packagesById)) { pkg.itemIds = pkg.itemIds.filter((itemId) => { @@ -671,7 +684,7 @@ export function loadSettings(paths: StoragePaths): AppSettings { if (backupLoaded) { logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); try { - const payload = JSON.stringify(backupLoaded, null, 2); + const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2); const tempPath = `${paths.configFile}.tmp`; fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(tempPath, paths.configFile); @@ -762,7 +775,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void { } } const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); - const payload = JSON.stringify(persisted, null, 2); + const payload = JSON.stringify(persisted, safeJsonReplacer, 2); const tempPath = `${paths.configFile}.tmp`; try { fs.writeFileSync(tempPath, payload, "utf8"); @@ -796,7 +809,7 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise { const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); - const payload = JSON.stringify(persisted, null, 2); + const payload = JSON.stringify(persisted, safeJsonReplacer, 2); if (asyncSettingsSaveRunning) { asyncSettingsSaveQueued = { paths, settings }; return; @@ -853,7 +866,7 @@ export function loadSession(paths: StoragePaths): SessionState { if (backupPkgCount > 0) { logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); try { - const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }); + const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(tempPath, paths.sessionFile); @@ -871,7 +884,7 @@ export function loadSession(paths: StoragePaths): SessionState { if (backup) { logger.warn("Session defekt, Backup-Datei wird verwendet"); try { - const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }); + const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(tempPath, paths.sessionFile); @@ -889,7 +902,7 @@ export function loadSession(paths: StoragePaths): SessionState { if (tmpSession && Object.keys(tmpSession.packages).length > 0) { logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`); try { - const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }); + const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer); fs.writeFileSync(paths.sessionFile, payload, "utf8"); } catch { // ignore restore write failure @@ -913,7 +926,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void { // Best-effort backup; proceed even if it fails } } - const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const tempPath = sessionTempPath(paths.sessionFile, "sync"); try { fs.writeFileSync(tempPath, payload, "utf8"); @@ -983,7 +996,7 @@ export function cancelPendingAsyncSaves(): void { } export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { - const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); await saveSessionPayloadAsync(paths, payload); } @@ -1036,7 +1049,7 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] { export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { ensureBaseDir(paths.baseDir); const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); - const payload = JSON.stringify(trimmed, null, 2); + const payload = JSON.stringify(trimmed, safeJsonReplacer, 2); const tempPath = `${paths.historyFile}.tmp`; try { fs.writeFileSync(tempPath, payload, "utf8");