diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 8c343f9..8c60101 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -470,6 +470,7 @@ type RunLifecycleContext = { id: string; startedAt: number; packageGenerations: Map; + downloadsFinished: boolean; }; function generateHistoryId(): string { @@ -1940,6 +1941,8 @@ export class DownloadManager extends EventEmitter { private runContexts = new Map(); + private activeRunContextId: string | null = null; + private standalonePackageResults = new Set(); private successDigestResults = new Map(); @@ -3003,9 +3006,10 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Paket gestoppt"; item.updatedAt = nowMs(); } - } - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); + } + this.runPackageIds.delete(packageId); + this.untrackActiveRunPackage(packageId); + this.runCompletedPackages.delete(packageId); } else { if (pkg.status === "paused") { pkg.status = "queued"; @@ -3027,8 +3031,9 @@ export class DownloadManager extends EventEmitter { } } if (this.session.running) { - if (hasReactivatedRunItems) { - this.runPackageIds.add(packageId); + if (hasReactivatedRunItems) { + this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); } @@ -3122,6 +3127,7 @@ export class DownloadManager extends EventEmitter { this.historyRecordedPackages.clear(); this.finalizedPackageResults.clear(); this.runContexts.clear(); + this.activeRunContextId = null; this.standalonePackageResults.clear(); this.successDigestResults.clear(); if (this.successDigestTimer) { @@ -3239,6 +3245,7 @@ export class DownloadManager extends EventEmitter { this.runItemIds.add(itemId); this.runPackageIds.add(packageId); this.beginPackageResultGeneration(packageId); + this.trackActiveRunPackage(packageId); } if (looksLikeOpaqueFilename(fileName)) { const existing = unresolvedByLink.get(link) ?? []; @@ -3372,8 +3379,9 @@ export class DownloadManager extends EventEmitter { this.abortPackagePostProcessing(packageId, "skip"); - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); + this.runPackageIds.delete(packageId); + this.untrackActiveRunPackage(packageId); + this.runCompletedPackages.delete(packageId); const items = pkg.itemIds .map((itemId) => this.session.items[itemId]) @@ -3449,8 +3457,9 @@ export class DownloadManager extends EventEmitter { } } this.runCompletedPackages.delete(packageId); - if (this.session.running) { - this.runPackageIds.add(packageId); + if (this.session.running) { + this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } pkg.status = "queued"; pkg.updatedAt = nowMs(); @@ -5785,6 +5794,7 @@ export class DownloadManager extends EventEmitter { this.runItemIds.add(itemId); } this.runPackageIds.add(packageId); + this.trackActiveRunPackage(packageId); } await Promise.allSettled(postProcessTasks); @@ -5867,6 +5877,7 @@ export class DownloadManager extends EventEmitter { } if (this.session.running) { this.runPackageIds.add(pkgId); + this.trackActiveRunPackage(pkgId); } } @@ -5961,6 +5972,11 @@ export class DownloadManager extends EventEmitter { public async startPackages(packageIds: string[]): Promise { this.ensureUsableDownloadAccount(); const targetSet = new Set(packageIds); + for (const packageId of this.packagePostProcessTasks.keys()) { + if (targetSet.has(packageId)) { + this.trackStandalonePackageResult(packageId); + } + } for (const pkgId of targetSet) { const pkg = this.session.packages[pkgId]; @@ -5990,6 +6006,7 @@ export class DownloadManager extends EventEmitter { this.runItemIds.add(item.id); this.runPackageIds.add(item.packageId); this.beginPackageResultGeneration(item.packageId); + this.trackActiveRunPackage(item.packageId); } } this.persistSoon(); @@ -6021,9 +6038,10 @@ export class DownloadManager extends EventEmitter { this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -6057,11 +6075,16 @@ export class DownloadManager extends EventEmitter { this.ensureUsableDownloadAccount(); const targetSet = new Set(itemIds); - const affectedPackageIds = new Set(); - for (const itemId of targetSet) { - const item = this.session.items[itemId]; - if (item) affectedPackageIds.add(item.packageId); - } + const affectedPackageIds = new Set(); + for (const itemId of targetSet) { + const item = this.session.items[itemId]; + if (item) affectedPackageIds.add(item.packageId); + } + for (const packageId of this.packagePostProcessTasks.keys()) { + if (affectedPackageIds.has(packageId)) { + this.trackStandalonePackageResult(packageId); + } + } for (const pkgId of affectedPackageIds) { const pkg = this.session.packages[pkgId]; @@ -6095,6 +6118,7 @@ export class DownloadManager extends EventEmitter { this.runItemIds.add(item.id); this.runPackageIds.add(item.packageId); this.beginPackageResultGeneration(item.packageId); + this.trackActiveRunPackage(item.packageId); } } this.persistSoon(); @@ -6127,9 +6151,10 @@ export class DownloadManager extends EventEmitter { this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -6166,9 +6191,12 @@ export class DownloadManager extends EventEmitter { this.ensureUsableDownloadAccount(); this.schedulerGeneration += 1; - this.session.running = true; - - const recoveredItems = await this.recoverRetryableItems("start"); + this.session.running = true; + for (const packageId of this.packagePostProcessTasks.keys()) { + this.trackStandalonePackageResult(packageId); + } + + const recoveredItems = await this.recoverRetryableItems("start"); await sleep(0); @@ -6275,9 +6303,10 @@ export class DownloadManager extends EventEmitter { } } - this.session.running = true; - this.session.paused = false; - this.session.runStartedAt = nowMs(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -11702,16 +11731,54 @@ export class DownloadManager extends EventEmitter { return next; } - private captureRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + private createRunContext(packageIds: Iterable, startedAt: number, downloadsFinished: boolean): RunLifecycleContext { const packageGenerations = new Map(); for (const packageId of packageIds) { packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); } - const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations }; + const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations, downloadsFinished }; this.runContexts.set(context.id, context); return context; } + private beginActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const context = this.createRunContext(packageIds, startedAt, false); + this.activeRunContextId = context.id; + return context; + } + + private trackActiveRunPackage(packageId: string): void { + if (!this.activeRunContextId) { + return; + } + const context = this.runContexts.get(this.activeRunContextId); + if (context && !context.packageGenerations.has(packageId)) { + context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + } + + private untrackActiveRunPackage(packageId: string): void { + if (!this.activeRunContextId) { + return; + } + this.runContexts.get(this.activeRunContextId)?.packageGenerations.delete(packageId); + } + + private finishActiveRunContext(packageIds: Iterable, startedAt: number): RunLifecycleContext { + const active = this.activeRunContextId ? this.runContexts.get(this.activeRunContextId) : undefined; + const context = active || this.createRunContext(packageIds, startedAt, false); + for (const packageId of packageIds) { + if (!context.packageGenerations.has(packageId)) { + context.packageGenerations.set(packageId, this.getPackageResultGeneration(packageId)); + } + } + context.downloadsFinished = true; + if (this.activeRunContextId === context.id) { + this.activeRunContextId = null; + } + return context; + } + private isPackageResultTracked(packageId: string, generation: number): boolean { const key = this.packageResultKey(packageId, generation); if (this.standalonePackageResults.has(key)) { @@ -11725,6 +11792,10 @@ export class DownloadManager extends EventEmitter { return false; } + private trackStandalonePackageResult(packageId: string): void { + this.standalonePackageResults.add(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId))); + } + private queueNotificationEvent(notification: NotificationEvent): void { if (!this.enqueueNotificationCallback || !String(this.settings.notifyUrl || "").trim()) { return; @@ -11850,6 +11921,9 @@ export class DownloadManager extends EventEmitter { private tryFinalizeRunResults(): void { for (const context of [...this.runContexts.values()]) { + if (!context.downloadsFinished) { + continue; + } const packageResults: PackageResult[] = []; let complete = true; for (const [packageId, generation] of context.packageGenerations) { @@ -13640,7 +13714,7 @@ export class DownloadManager extends EventEmitter { averageSpeedBps: avgSpeed }; this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; - const runContext = total > 0 ? this.captureRunContext(this.runPackageIds, runStartedAt) : null; + const runContext = total > 0 ? this.finishActiveRunContext(this.runPackageIds, runStartedAt) : null; this.runItemIds.clear(); this.runPackageIds.clear(); this.runOutcomes.clear(); diff --git a/tests/notify-hooks.test.ts b/tests/notify-hooks.test.ts index dbd8b39..2b99c66 100644 --- a/tests/notify-hooks.test.ts +++ b/tests/notify-hooks.test.ts @@ -424,6 +424,70 @@ describe("authoritative run completion", () => { expect(events[0].payload.title).toContain("Paket-Digest"); }); + it("keeps the active run generation after package_done cleanup removes a generation-seven package", async () => { + const { manager, session, events, history } = setup({ completedCleanupPolicy: "package_done" }); + const pkg = addPackage(session); + const state = internal(manager); + pkg.resultGeneration = 7; + session.running = true; + session.runStartedAt = Date.now() - 20_000; + state.runItemIds = new Set(pkg.itemIds); + state.runPackageIds = new Set([pkg.id]); + state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); + state.beginActiveRunContext?.(state.runPackageIds, session.runStartedAt); + + state.tryFinalizePackageResult(pkg.id); + const currentResult = state.finalizedPackageResults.get(`${pkg.id}:7`); + expect(currentResult).toBeDefined(); + state.finalizedPackageResults.set(`${pkg.id}:1`, { + ...currentResult, + status: "failed", + successfulFiles: 0, + failedFiles: 9, + failurePhase: "download", + errorCategory: "stale" + }); + state.applyPackageDoneCleanup(pkg.id); + expect(session.packages[pkg.id]).toBeUndefined(); + + state.finishRun(); + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(events.filter((event) => event.type === "run_completed")).toHaveLength(1); + const completedRun = events.find((event) => event.type === "run_completed"); + expect(completedRun?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true); + expect(history).toHaveLength(1); + expect(history[0].id).toBe(`hist-${pkg.id}-7`); + }); + + it("registers a postprocess-only start before a package task without download items completes", async () => { + const { manager, session, events, history } = setup(); + const pkg = addPackage(session); + const state = internal(manager); + for (const itemId of pkg.itemIds) { + delete session.items[itemId]; + } + pkg.itemIds = []; + pkg.status = "completed"; + let releasePostProcess = (): void => {}; + const postProcessGate = new Promise((resolve) => { + releasePostProcess = resolve; + }); + state.handlePackagePostProcessing = vi.fn(async () => postProcessGate); + + const postProcess = state.runPackagePostProcessing(pkg.id); + await Promise.resolve(); + await manager.start(); + releasePostProcess(); + await postProcess; + await flushNotifications(); + + expect(events.filter((event) => event.type === "package_completed")).toHaveLength(1); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ name: pkg.name, status: "completed", fileCount: 0 }); + }); + it("finalizes overlapping runs independently when the earlier run finishes deferred work last", async () => { const { manager, session, events, history } = setup(); const packageA = addPackage(session, ["completed"], "package-a");