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,188 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user