fix(notifications): isolate package result ownership

Prevent active and completed run contexts from being duplicated into standalone tracking during later starts. Scope start-triggered recovery reactivation to enabled, non-excluded packages so foreign stopped generations remain suppressed while explicit retries keep working.
This commit is contained in:
Sucukdeluxe
2026-08-22 06:22:44 +02:00
parent 26ebd931b7
commit f4c4fb2fac
2 changed files with 96 additions and 8 deletions
+20 -5
View File
@@ -6195,11 +6195,15 @@ export class DownloadManager extends EventEmitter {
this.schedulerGeneration += 1;
this.session.running = true;
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
const pkg = this.session.packages[packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId));
}));
for (const packageId of this.packagePostProcessTasks.keys()) {
this.trackStandalonePackageResult(packageId);
}
const recoveredItems = await this.recoverRetryableItems("start");
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
await sleep(0);
@@ -11551,7 +11555,10 @@ export class DownloadManager extends EventEmitter {
throw new Error(lastError || "Download fehlgeschlagen");
}
private async recoverRetryableItems(trigger: "startup" | "start"): Promise<number> {
private async recoverRetryableItems(
trigger: "startup" | "start",
reactivationPackageIds?: ReadonlySet<string>
): Promise<number> {
let recovered = 0;
let finalized = 0;
const touchedPackages = new Set<string>();
@@ -11632,7 +11639,11 @@ export class DownloadManager extends EventEmitter {
}
this.beginPackageResultGeneration(packageId, false, true);
if (!this.runPackageIds.has(packageId)) {
if (trigger === "start" && reactivationPackageIds?.has(packageId)) {
this.reactivateStandalonePackageResult(packageId);
} else if (trigger === "startup") {
this.trackStandalonePackageResult(packageId);
}
}
this.refreshPackageStatus(pkg);
}
@@ -11828,18 +11839,22 @@ export class DownloadManager extends EventEmitter {
}
private trackStandalonePackageResult(packageId: string): void {
const key = this.packageResultKey(packageId, this.getPackageResultGeneration(packageId));
if (this.suppressedPackageResults.has(key)) {
const generation = this.getPackageResultGeneration(packageId);
const key = this.packageResultKey(packageId, generation);
if (this.suppressedPackageResults.has(key) || this.isPackageResultTracked(packageId, generation)) {
return;
}
this.standalonePackageResults.add(key);
}
private reactivateStandalonePackageResult(packageId: string): void {
const key = this.packageResultKey(packageId, this.getPackageResultGeneration(packageId));
const generation = this.getPackageResultGeneration(packageId);
const key = this.packageResultKey(packageId, generation);
this.suppressedPackageResults.delete(key);
if (!this.isPackageResultTracked(packageId, generation)) {
this.standalonePackageResults.add(key);
}
}
private suppressStandalonePackageResults(): void {
for (const key of this.standalonePackageResults) {
+73
View File
@@ -612,6 +612,79 @@ describe("authoritative run completion", () => {
expect(history).toHaveLength(0);
});
it("keeps an earlier run-owned postprocess result alive when a later run is stopped", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
const packageA = addPackage(session, ["queued"], "earlier-run-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
const packageAItem = session.items[packageA.itemIds[0]];
packageAItem.status = "completed";
packageAItem.downloadedBytes = 1_000;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 100;
packageAItem.fullStatus = "Fertig";
packageA.status = "completed";
state.runOutcomes.set(packageAItem.id, "completed");
let releasePostProcess = (): void => {};
const postProcessGate = new Promise<void>((resolve) => {
releasePostProcess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postProcessGate);
const packageAPostProcess = state.runPackagePostProcessing(packageA.id);
await Promise.resolve();
state.finishRun();
const packageB = addPackage(session, ["queued"], "later-run-package");
await manager.start();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
manager.stop();
releasePostProcess();
await packageAPostProcess;
await flushNotifications();
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]);
});
it("does not reactivate a suppressed foreign package when another start recovers it from disk", async () => {
const { manager, session, events, history } = setup({ autoExtractWhenStopped: true });
const packageA = addPackage(session, ["queued"], "suppressed-recovery-package");
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
manager.stop();
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-recovery-"));
tempDirs.push(recoveryDir);
const recoveredPath = path.join(recoveryDir, "recovered-package.rar");
fs.writeFileSync(recoveredPath, Buffer.alloc(1_000, 7));
const packageAItem = session.items[packageA.itemIds[0]];
packageA.enabled = false;
packageA.status = "failed";
packageAItem.status = "failed";
packageAItem.targetPath = recoveredPath;
packageAItem.downloadedBytes = 0;
packageAItem.totalBytes = 1_000;
packageAItem.progressPercent = 0;
packageAItem.fullStatus = "Resume-Link erneuern";
packageAItem.lastError = "download_underflow";
const packageB = addPackage(session, ["queued"], "recovery-run-package");
await manager.start();
await Promise.allSettled([...state.packagePostProcessTasks.values()]);
await flushNotifications();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(0);
expect(history).toHaveLength(0);
manager.stop();
});
it("suppresses a stopped postprocess-only generation and allows an explicit package retry", async () => {
const { manager, session, events, history } = setup({ autoExtract: true, autoExtractWhenStopped: true });
const pkg = addPackage(session, ["completed"], "postprocess-only-package");