Beim Update parkte installUpdate() aktive Downloads via stop() -> deren Abbruch-
Continuation markierte die Items "cancelled"/"Gestoppt". autoResumeOnStart nimmt
nach dem Neustart aber nur "queued"/"reconnect_wait" auf, also liefen die gerade
ladenden Downloads nach dem Update nicht weiter (timing-abhaengig: "manchmal").
Jetzt: stop({parkForRestart:true}) bricht aktive Tasks mit Grund "shutdown" ab,
sodass sie als "queued" re-queued werden (wie bei normalem App-Shutdown). Das
schliesst zugleich den einzigen plausiblen Loesch-Pfad (all-cancelled-Pakete sind
ueber applyRetroactiveCleanupPolicy entfernbar). Stop-Button-Verhalten unveraendert.
Zusaetzliche Robustheit in storage.ts (enge Blast-Radien, nicht die Hauptursache):
- async-Save-Clobber: eine gequeuete, veraltete Payload konnte einen neueren
Sync-Save (persistNowSync/prepareForShutdown) ueberschreiben; Generation wird
jetzt zum Snapshot-Zeitpunkt erfasst und durch die Queue getragen.
- loadSession gab leer zurueck (und ignorierte ein gefuelltes .bak), wenn die
Primaerdatei fehlte; faellt jetzt auf die Backup/Temp-Recovery zurueck.
Regressionstests: tests/update-restart-resume.test.ts (echter Live-Download ->
Park -> Reload = queued, plus Charakterisierung plain stop() -> cancelled) und
tests/session-restart-loss.test.ts (Clobber + Backup-Fallback). Volle Suite gruen.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
|
|
import {
|
|
cancelPendingAsyncSaves,
|
|
createStoragePaths,
|
|
emptySession,
|
|
loadSession,
|
|
saveSession,
|
|
saveSessionAsync
|
|
} from "../src/main/storage";
|
|
|
|
// Regression tests for queue loss across an app-update restart.
|
|
// Both scenarios were observed empirically to drop packages before the fix:
|
|
// - a queued stale async save clobbering a newer synchronous save
|
|
// (persistNowSync / prepareForShutdown), and
|
|
// - loadSession ignoring a good .bak when the primary file is momentarily absent.
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
/** Build a session whose package set is exactly `ids`. */
|
|
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));
|
|
|
|
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"]));
|
|
|
|
// An async save goes in-flight, a second async save (stale snapshot) gets
|
|
// queued, then a synchronous save persists the live state with package C.
|
|
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"]);
|
|
});
|
|
});
|