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>
189 lines
6.7 KiB
TypeScript
189 lines
6.7 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import http from "node:http";
|
|
import { once } from "node:events";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { DownloadManager } from "../src/main/download-manager";
|
|
import { defaultSettings } from "../src/main/constants";
|
|
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
|
|
import { shutdownItemLogs } from "../src/main/item-log";
|
|
import { shutdownPackageLogs } from "../src/main/package-log";
|
|
|
|
// Regression for the reported symptom: after an app update while downloading,
|
|
// packages that were in flight do not continue after the restart.
|
|
//
|
|
// Root cause: installUpdate() called manager.stop(), whose abort continuation
|
|
// marks the in-flight item "cancelled"/"Gestoppt". autoResumeOnStart only
|
|
// resumes "queued"/"reconnect_wait" items, so after the silent-install relaunch
|
|
// the download silently stays parked instead of continuing.
|
|
|
|
const tempDirs: string[] = [];
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(async () => {
|
|
globalThis.fetch = originalFetch;
|
|
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));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
|
|
const started = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - started > timeoutMs) {
|
|
throw new Error("waitFor timeout");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
|
|
/** Starts an HTTP server that trickles bytes forever so a download stays
|
|
* actively "downloading" until it is aborted. Returns the direct URL plus a
|
|
* stop() that tears down all open responses and the server. */
|
|
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
|
|
const openTimers = new Set<NodeJS.Timeout>();
|
|
const openResponses = new Set<http.ServerResponse>();
|
|
const server = http.createServer((req, res) => {
|
|
if ((req.url || "") !== "/direct") {
|
|
res.statusCode = 404;
|
|
res.end("not-found");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Accept-Ranges", "bytes");
|
|
res.setHeader("Content-Length", String(64 * 1024 * 1024));
|
|
openResponses.add(res);
|
|
res.write(Buffer.alloc(64 * 1024, 7));
|
|
const timer = setInterval(() => {
|
|
try {
|
|
res.write(Buffer.alloc(16 * 1024, 9));
|
|
} catch {
|
|
// socket gone
|
|
}
|
|
}, 100);
|
|
openTimers.add(timer);
|
|
res.on("close", () => {
|
|
clearInterval(timer);
|
|
openTimers.delete(timer);
|
|
openResponses.delete(res);
|
|
});
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("server address unavailable");
|
|
}
|
|
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
|
const stop = async (): Promise<void> => {
|
|
for (const timer of openTimers) {
|
|
clearInterval(timer);
|
|
}
|
|
openTimers.clear();
|
|
for (const res of openResponses) {
|
|
try {
|
|
res.destroy();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
openResponses.clear();
|
|
server.close();
|
|
await once(server, "close");
|
|
};
|
|
return { directUrl, stop };
|
|
}
|
|
|
|
function mockUnrestrict(directUrl: string): void {
|
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/unrestrict/link")) {
|
|
return new Response(
|
|
JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
}
|
|
|
|
async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType<typeof createStoragePaths>; serverStop: () => Promise<void> }> {
|
|
const { directUrl, stop: serverStop } = await startTricklingServer();
|
|
mockUnrestrict(directUrl);
|
|
const paths = createStoragePaths(path.join(root, "state"));
|
|
const manager = new DownloadManager(
|
|
{
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
outputDir: path.join(root, "downloads"),
|
|
extractDir: path.join(root, "extract"),
|
|
autoExtract: false,
|
|
autoReconnect: false,
|
|
retryLimit: 0
|
|
},
|
|
emptySession(),
|
|
paths
|
|
);
|
|
manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]);
|
|
await manager.start();
|
|
await waitFor(() => {
|
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
|
return item?.status === "downloading" && (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size > 0;
|
|
});
|
|
return { manager, paths, serverStop };
|
|
}
|
|
|
|
describe("update restart resume", () => {
|
|
it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
|
tempDirs.push(root);
|
|
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
|
try {
|
|
manager.stop();
|
|
manager.persistNowSync();
|
|
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
|
manager.prepareForShutdown();
|
|
|
|
const reloaded = loadSession(paths);
|
|
const item = Object.values(reloaded.items)[0];
|
|
expect(item).toBeTruthy();
|
|
// Documents the loss of resumability: cancelled items are not auto-resumed.
|
|
expect(item.status).toBe("cancelled");
|
|
} finally {
|
|
await serverStop();
|
|
}
|
|
});
|
|
|
|
it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
|
tempDirs.push(root);
|
|
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
|
try {
|
|
// Mirrors AppController.installUpdate(): park downloads, then sync-persist.
|
|
manager.stop({ parkForRestart: true });
|
|
manager.persistNowSync();
|
|
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
|
manager.prepareForShutdown();
|
|
|
|
const reloaded = loadSession(paths);
|
|
const item = Object.values(reloaded.items)[0];
|
|
expect(item).toBeTruthy();
|
|
// The package/item must survive AND be resumable so auto-resume continues it.
|
|
expect(Object.keys(reloaded.packages).length).toBe(1);
|
|
expect(item.status).toBe("queued");
|
|
} finally {
|
|
await serverStop();
|
|
}
|
|
});
|
|
});
|