Update-Neustart: laufende Downloads als queued parken statt als "Gestoppt" haengenzubleiben
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
53cc6b11eb
commit
8d03ca124f
@@ -0,0 +1,141 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user