real-debrid-downloader/tests/session-restart-loss.test.ts
Sucukdeluxe 6cde08dac3 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.
2026-06-19 22:42:05 +02:00

366 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
import {
cancelPendingAsyncSaves,
createStoragePaths,
emptySession,
loadSession,
loadSessionWithStatus,
loadSettings,
saveSession,
saveSessionAsync,
saveSettings,
saveSettingsAsync
} from "../src/main/storage";
import { defaultSettings } from "../src/main/constants";
import { DownloadManager } from "../src/main/download-manager";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
const tempDirs: string[] = [];
afterEach(async () => {
shutdownItemLogs();
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 80));
}
}
}
});
function makePackage(id: string, itemId: string): PackageEntry {
return {
id,
name: `Package ${id}`,
outputDir: "C:/tmp/out",
extractDir: "C:/tmp/extract",
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: 1,
updatedAt: 1
};
}
function makeItem(id: string, packageId: string): DownloadItem {
return {
id,
packageId,
url: `https://example.com/${id}`,
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `${id}.rar`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
};
}
function sessionWith(ids: string[]): SessionState {
const s = emptySession();
for (const id of ids) {
const itemId = `${id}-item`;
s.packageOrder.push(id);
s.packages[id] = makePackage(id, itemId);
s.items[itemId] = makeItem(itemId, id);
}
return s;
}
const settle = (ms = 250): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
function primaryPackageKeys(sessionFile: string): string[] {
if (!fs.existsSync(sessionFile)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(sessionFile, "utf8")) as { packages?: Record<string, unknown> };
return Object.keys(parsed.packages || {});
} catch {
return ["<unparseable>"];
}
}
describe("session restart loss", () => {
it("does not let a queued stale async save clobber a newer synchronous save", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
saveSession(paths, sessionWith(["A", "B"]));
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
saveSession(paths, sessionWith(["A", "B", "C"]));
await inflight;
await queued;
await settle();
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B", "C"]);
});
it("recovers packages from the backup when the primary session file is absent", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
expect(fs.existsSync(paths.sessionFile)).toBe(false);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("still treats a truly fresh install (no primary, no backup, no temp) as empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual([]);
expect(Object.keys(loaded.items)).toEqual([]);
});
it("recovers from the backup when the primary exists but is empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("does not let an in-flight/queued async settings save clobber a newer synchronous saveSettings", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-race-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
const withName = (name: string) => ({ ...defaultSettings(), packageName: name });
saveSettings(paths, withName("OLD"));
const inflight = saveSettingsAsync(paths, withName("OLD"));
const queued = saveSettingsAsync(paths, withName("OLD"));
saveSettings(paths, withName("NEW"));
await inflight;
await queued;
await settle();
expect(loadSettings(paths).packageName).toBe("NEW");
});
});
describe("session load status classification", () => {
it("a readable populated primary reports status ok", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, sessionWith(["A", "B"]));
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("ok");
expect(Object.keys(result.session.packages).sort()).toEqual(["A", "B"]);
});
it("a truly fresh install reports status empty-fresh (no protection needed)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
expect(loadSessionWithStatus(paths).status).toBe("empty-fresh");
});
it("a corrupt primary with a good backup reports recovered-backup, not empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, "{ this is not valid json", "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("recovered-backup");
expect(Object.keys(result.session.packages).sort()).toEqual(["A", "B"]);
});
it("THE BUG SIGNATURE: corrupt primary AND corrupt backup (no temp) reports empty-unreadable", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, "", "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, "garbage not json", "utf8");
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("empty-unreadable");
expect(Object.keys(result.session.packages)).toEqual([]);
});
it("an empty-but-valid primary is status ok (a legitimate empty queue), NOT empty-unreadable", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, emptySession());
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("ok");
});
});
describe("durable session writes", () => {
it("fsyncs the temp file before renaming on a synchronous save", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fsync-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const fsyncSpy = vi.spyOn(fs, "fsyncSync");
try {
saveSession(paths, sessionWith(["A"]));
expect(fsyncSpy).toHaveBeenCalled();
} finally {
fsyncSpy.mockRestore();
}
expect(Object.keys(loadSession(paths).packages)).toEqual(["A"]);
});
it("an async save round-trips correctly (close-before-rename works on this platform)", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fsync-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
await saveSessionAsync(paths, sessionWith(["A", "B", "C"]));
await settle();
expect(Object.keys(loadSession(paths).packages).sort()).toEqual(["A", "B", "C"]);
});
it("retries a transiently locked session file and recovers (EBUSY)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, sessionWith(["A"]));
const realRead = fs.readFileSync;
let lockedReads = 0;
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((p: fs.PathOrFileDescriptor, options?: unknown) => {
if (String(p) === paths.sessionFile && lockedReads < 2) {
lockedReads += 1;
const err = new Error("locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return (realRead as (a: unknown, b: unknown) => unknown)(p, options);
}) as typeof fs.readFileSync);
try {
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["A"]);
expect(lockedReads).toBe(2);
} finally {
readSpy.mockRestore();
}
});
});
describe("empty-clobber guard", () => {
const managerSettings = (root: string) => ({
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
autoResumeOnStart: false,
retryLimit: 0
});
it("after an unreadable load, a sync save does NOT overwrite the on-disk data with empty; a later real change persists, and a genuine clear still works", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-guard-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
saveSession(paths, sessionWith(["GOOD"]));
expect(Object.keys(loadSession(paths).packages)).toEqual(["GOOD"]);
const manager = new DownloadManager(managerSettings(root), emptySession(), paths, { protectEmptyClobber: true });
try {
manager.persistNowSync();
expect(Object.keys(loadSession(paths).packages)).toEqual(["GOOD"]);
const real = sessionWith(["NEW"]);
const m = manager as unknown as { session: SessionState; protectAgainstEmptyClobber: boolean };
m.session.packageOrder = real.packageOrder;
m.session.packages = real.packages;
m.session.items = real.items;
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual(["NEW"]);
expect(m.protectAgainstEmptyClobber).toBe(false);
m.session.packageOrder = [];
m.session.packages = {};
m.session.items = {};
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual([]);
} finally {
manager.blockAllPersistence = true;
manager.clearPersistTimer();
}
});
it("a normal start (status ok) does NOT protect, so removing the last package persists empty immediately", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-guard-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
saveSession(paths, sessionWith(["GOOD"]));
const manager = new DownloadManager(managerSettings(root), sessionWith(["GOOD"]), paths, { protectEmptyClobber: false });
try {
const m = manager as unknown as { session: SessionState };
m.session.packageOrder = [];
m.session.packages = {};
m.session.items = {};
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual([]);
} finally {
manager.blockAllPersistence = true;
manager.clearPersistTimer();
}
});
});