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:
Sucukdeluxe
2026-06-02 05:19:54 +02:00
co-authored by Claude Opus 4.8
parent 53cc6b11eb
commit 8d03ca124f
5 changed files with 379 additions and 16 deletions
+8 -1
View File
@@ -440,8 +440,15 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
// Stop active downloads before installing. Extractions may continue briefly
// until prepareForShutdown() is called during app quit.
// parkForRestart MUST stay true here: it keeps in-flight items as "queued"
// (not "cancelled") so the updated app auto-resumes them after the silent
// install relaunch. A plain stop() marks them "cancelled"/"Gestoppt", which
// autoResumeOnStart does NOT pick up — the downloads then silently fail to
// continue after the update (the reported "packages gone after update" bug).
// Regression coverage: tests/update-restart-resume.test.ts asserts this exact
// stop({parkForRestart:true}) + persistNowSync() sequence reloads as "queued".
if (this.manager.isSessionRunning()) {
this.manager.stop();
this.manager.stop({ parkForRestart: true });
}
// Flush any pending async saves BEFORE the update process starts.
// This ensures the queue is fully persisted to disk so it survives the restart.
+11 -3
View File
@@ -5689,7 +5689,15 @@ export class DownloadManager extends EventEmitter {
});
}
public stop(): void {
public stop(options?: { parkForRestart?: boolean }): void {
// parkForRestart: used before an app-update install. Active downloads are
// aborted with the "shutdown" reason so their continuation re-queues them
// (status "queued") instead of marking them "cancelled"/"Gestoppt". A
// cancelled item is NOT picked up by autoResumeOnStart after the update
// relaunch, so the download would silently fail to resume — the user sees
// packages that were downloading "disappear" from the active list.
const parkForRestart = options?.parkForRestart === true;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped;
this.schedulerGeneration += 1;
this.session.running = false;
@@ -5713,8 +5721,8 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessActive = 0;
}
for (const active of this.activeTasks.values()) {
active.abortReason = "stop";
active.abortController.abort("stop");
active.abortReason = abortReason;
active.abortController.abort(abortReason);
}
// Reset all non-finished items to clean "Wartet" / "Paket gestoppt" state
for (const item of Object.values(this.session.items)) {
+31 -12
View File
@@ -954,13 +954,25 @@ export function emptySession(): SessionState {
export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.sessionFile)) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
const backupFile = sessionBackupPath(paths.sessionFile);
const primaryExists = fs.existsSync(paths.sessionFile);
// A missing primary file is only a genuine "fresh start" when there is also
// nothing to recover from. If a backup or an interrupted-write temp file
// exists, fall through to the recovery chain below instead of returning an
// empty session — otherwise a momentarily-absent primary during an update
// restart would discard a perfectly good backup and wipe the whole queue.
if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
}
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
}
const primary = readSessionFile(paths.sessionFile);
const backupFile = sessionBackupPath(paths.sessionFile);
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
// If primary loaded but is empty, check if backup has packages (safety net)
if (primary) {
@@ -1044,7 +1056,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
}
let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
let syncSaveGeneration = 0;
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
@@ -1074,15 +1086,19 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
}
}
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> {
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
if (asyncSaveRunning) {
asyncSaveQueued = { paths, payload };
// Keep the freshest payload, but preserve the generation captured when THIS
// payload was snapshotted. Re-reading syncSaveGeneration at re-invoke time
// would let a stale queued write slip past the guard and clobber a newer
// synchronous save (persistNowSync/prepareForShutdown) — which could drop
// packages that the sync save had just persisted.
asyncSaveQueued = { paths, payload, generation };
return;
}
asyncSaveRunning = true;
const gen = syncSaveGeneration;
try {
await writeSessionPayload(paths, payload, gen);
await writeSessionPayload(paths, payload, generation);
} catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally {
@@ -1090,7 +1106,7 @@ async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Pr
if (asyncSaveQueued) {
const queued = asyncSaveQueued;
asyncSaveQueued = null;
void saveSessionPayloadAsync(queued.paths, queued.payload);
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
}
}
}
@@ -1102,8 +1118,11 @@ export function cancelPendingAsyncSaves(): void {
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
// Capture the generation at snapshot time so the guard in writeSessionPayload
// can reliably discard this write if a synchronous save lands afterwards.
const generation = syncSaveGeneration;
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload);
await saveSessionPayloadAsync(paths, payload, generation);
}
const MAX_HISTORY_ENTRIES = 500;