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.
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
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, " |