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) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-26 19:47:58 +01:00
parent b41b7c9de6
commit e8c6761bf0
2 changed files with 31 additions and 12 deletions

View File

@ -1545,10 +1545,16 @@ class MegaDebridClient {
}); });
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
return null; return null;
} }
const payload = parseJsonSafe(text); const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") { if (!payload || payload.response_code !== "ok") {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
this.clearTokenCache();
}
return null; return null;
} }
const token = String(payload.token || "").trim(); const token = String(payload.token || "").trim();
@ -2467,7 +2473,7 @@ class DebridLinkClient {
throw new Error("aborted"); throw new Error("aborted");
} }
if (poll > 0) { if (poll > 0) {
await new Promise((resolve) => setTimeout(resolve, 2000)); await sleepWithSignal(2000, signal);
} }
const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal); const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal);
if (refreshed) { if (refreshed) {
@ -2738,7 +2744,7 @@ class LinkSnappyClient {
throw error; throw error;
} }
if (attempt < REQUEST_RETRIES) { if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt), signal); await sleepWithSignal(retryDelay(attempt), signal);
} }
} }
} }
@ -2808,7 +2814,7 @@ class OneFichierClient {
throw error; throw error;
} }
if (attempt < REQUEST_RETRIES) { if (attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt), signal); await sleepWithSignal(retryDelay(attempt), signal);
} }
} }
} }

View File

@ -501,6 +501,14 @@ function ensureBaseDir(baseDir: string): void {
fs.mkdirSync(baseDir, { recursive: true }); 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<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return null; return null;
@ -608,11 +616,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
}; };
} }
let orphanedItemCount = 0;
for (const [itemId, item] of Object.entries(itemsById)) { for (const [itemId, item] of Object.entries(itemsById)) {
if (!packagesById[item.packageId]) { if (!packagesById[item.packageId]) {
orphanedItemCount += 1;
delete itemsById[itemId]; delete itemsById[itemId];
} }
} }
if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
for (const pkg of Object.values(packagesById)) { for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => { pkg.itemIds = pkg.itemIds.filter((itemId) => {
@ -671,7 +684,7 @@ export function loadSettings(paths: StoragePaths): AppSettings {
if (backupLoaded) { if (backupLoaded) {
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
try { try {
const payload = JSON.stringify(backupLoaded, null, 2); const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2);
const tempPath = `${paths.configFile}.tmp`; const tempPath = `${paths.configFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
@ -762,7 +775,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
} }
} }
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, null, 2); const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
const tempPath = `${paths.configFile}.tmp`; const tempPath = `${paths.configFile}.tmp`;
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); 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<void> { export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, null, 2); const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
if (asyncSettingsSaveRunning) { if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, settings }; asyncSettingsSaveQueued = { paths, settings };
return; return;
@ -853,7 +866,7 @@ export function loadSession(paths: StoragePaths): SessionState {
if (backupPkgCount > 0) { if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
@ -871,7 +884,7 @@ export function loadSession(paths: StoragePaths): SessionState {
if (backup) { if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet"); logger.warn("Session defekt, Backup-Datei wird verwendet");
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
@ -889,7 +902,7 @@ export function loadSession(paths: StoragePaths): SessionState {
if (tmpSession && Object.keys(tmpSession.packages).length > 0) { if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`); logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
try { try {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }); const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8"); fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch { } catch {
// ignore restore write failure // ignore restore write failure
@ -913,7 +926,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
// Best-effort backup; proceed even if it fails // 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"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
@ -983,7 +996,7 @@ export function cancelPendingAsyncSaves(): void {
} }
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> { export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload); await saveSessionPayloadAsync(paths, payload);
} }
@ -1036,7 +1049,7 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); 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`; const tempPath = `${paths.historyFile}.tmp`;
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");