From c634b70396033e009c27a4ca56bdba296fb687fe Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 12:22:21 +0200 Subject: [PATCH] fix: preserve postprocessing run ownership across stops Bind main, deferred, and hybrid postprocessing controllers to the run context that owns their package generation. Abort only the stopped run and unowned work so a later run cannot cancel deferred completion for an earlier run. Treat stop and shutdown aborts as lifecycle cancellation instead of cleanup failure and cover the real deferred handoff through package, run, history, and cleanup results. --- src/main/download-manager.ts | 69 +++++++++++++++++++++++++++--------- tests/notify-hooks.test.ts | 1 + 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 4576772..65bab73 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1918,14 +1918,20 @@ export class DownloadManager extends EventEmitter { private packagePostProcessTasks = new Map>(); - private packagePostProcessAbortControllers = new Map(); + private packagePostProcessAbortControllers = new Map(); + + private packagePostProcessRunOwnerByController = new WeakMap(); private packageDeferredPostProcessAbortControllers = new Map(); + private packageDeferredRunOwnerByController = new WeakMap(); + private packageDeferredPostProcessTasks = new Map>>(); private packageHybridPostProcessControllers = new Map>(); + private packageHybridRunOwnerByController = new WeakMap(); + private packageHybridPostProcessTasks = new Map>>(); private packagePostProcessVersions = new Map(); @@ -6805,7 +6811,7 @@ export class DownloadManager extends EventEmitter { this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; - this.abortPostProcessing("stop"); + this.abortPostProcessing("stop", stoppedRunContext?.id); for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); } this.packagePostProcessWaiters = []; this.packagePostProcessActive = 0; @@ -8381,9 +8387,13 @@ export class DownloadManager extends EventEmitter { return claimed; } - private abortPostProcessing(reason: string): void { - for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) { - if (!controller.signal.aborted) { + private abortPostProcessing(reason: string, runContextId?: string): void { + for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) { + const owner = this.packagePostProcessRunOwnerByController.get(controller); + if (runContextId !== undefined && owner !== undefined && owner !== null && owner !== runContextId) { + continue; + } + if (!controller.signal.aborted) { controller.abort(reason); } @@ -8410,16 +8420,24 @@ export class DownloadManager extends EventEmitter { } } } - } - - for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { - if (!controller.signal.aborted) { + } + + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + const owner = this.packageDeferredRunOwnerByController.get(controller); + if (runContextId !== undefined && owner !== undefined && owner !== null && owner !== runContextId) { + continue; + } + if (!controller.signal.aborted) { controller.abort(reason); } } - for (const hybridSet of this.packageHybridPostProcessControllers.values()) { - for (const controller of hybridSet) { - if (!controller.signal.aborted) { + for (const hybridSet of this.packageHybridPostProcessControllers.values()) { + for (const controller of hybridSet) { + const owner = this.packageHybridRunOwnerByController.get(controller); + if (runContextId !== undefined && owner !== undefined && owner !== null && owner !== runContextId) { + continue; + } + if (!controller.signal.aborted) { controller.abort(reason); } } @@ -8473,6 +8491,7 @@ export class DownloadManager extends EventEmitter { const abortController = new AbortController(); this.packagePostProcessAbortControllers.set(packageId, abortController); + this.packagePostProcessRunOwnerByController.set(abortController, this.getPackageResultRunOwner(packageId)); const queuedPackage = this.session.packages[packageId]; if (queuedPackage) { queuedPackage.postProcessQueuedAt = queuedPackage.postProcessQueuedAt || nowMs(); @@ -12367,6 +12386,16 @@ export class DownloadManager extends EventEmitter { } } + private getPackageResultRunOwner(packageId: string): string | null { + const generation = this.getPackageResultGeneration(packageId); + for (const context of this.runContexts.values()) { + if (context.packageGenerations.get(packageId) === generation) { + return context.id; + } + } + return null; + } + private queueNotificationEvent(notification: NotificationEvent): void { if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) { return; @@ -13389,6 +13418,7 @@ export class DownloadManager extends EventEmitter { if (result.extracted > 0) { this.trackPackagePostProcessResult(packageId); const hybridController = new AbortController(); + this.packageHybridRunOwnerByController.set(hybridController, this.getPackageResultRunOwner(packageId)); let hybridSet = this.packageHybridPostProcessControllers.get(packageId); if (!hybridSet) { hybridSet = new Set(); @@ -14093,8 +14123,9 @@ export class DownloadManager extends EventEmitter { if (replacedController && !replacedController.signal.aborted) { replacedController.abort("deferred_replaced"); } - const deferredController = new AbortController(); - this.packageDeferredPostProcessAbortControllers.set(packageId, deferredController); + const deferredController = new AbortController(); + this.packageDeferredPostProcessAbortControllers.set(packageId, deferredController); + this.packageDeferredRunOwnerByController.set(deferredController, this.getPackageResultRunOwner(packageId)); const deferredVersion = this.getPackagePostProcessVersion(packageId); const shouldAbort = (): boolean => !this.isDeferredPostProcessStillCurrent(packageId, pkg, deferredVersion, deferredController.signal); const throwIfAborted = (): void => this.throwIfDeferredPostProcessAborted(packageId, pkg, deferredVersion, deferredController.signal); @@ -14252,9 +14283,13 @@ export class DownloadManager extends EventEmitter { || reason.includes("package_removed") || reason === "reset" || reason === "cancel" - || reason === "overwrite" - || reason === "skip" - || reason === "package_toggle") { + || reason === "overwrite" + || reason === "skip" + || reason === "package_toggle" + || reason === "stop" + || reason === "shutdown" + || reason === "Error: stop" + || reason === "Error: shutdown") { logger.info(`Deferred Post-Extraction abgebrochen: pkg=${pkg.name}, reason=${reason}`); } else { pkg.cleanupErrorCategory = reason.slice(0, 256) || "cleanup"; diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index 1bf3433..002c8e7 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -755,6 +755,7 @@ describe("authoritative run completion", () => { await Promise.allSettled(deferredTasks); await flushNotifications(); + expect(packageA.cleanupErrorCategory || "").toBe(""); expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1); expect(history.map((entry) => entry.name)).toEqual([packageA.name]);