fix(notifications): preserve run and package result identity

This commit is contained in:
Sucukdeluxe
2026-08-22 05:27:04 +02:00
parent 03da04b629
commit de97dd4bea
6 changed files with 299 additions and 177 deletions
+136 -131
View File
@@ -466,7 +466,7 @@ type DownloadManagerOptions = {
protectEmptyClobber?: boolean; protectEmptyClobber?: boolean;
}; };
type PendingRunResult = { type RunLifecycleContext = {
id: string; id: string;
startedAt: number; startedAt: number;
packageGenerations: Map<string, number>; packageGenerations: Map<string, number>;
@@ -1936,13 +1936,11 @@ export class DownloadManager extends EventEmitter {
private historyRecordedPackages = new Set<string>(); private historyRecordedPackages = new Set<string>();
private packageResultGenerations = new Map<string, number>();
private finalizedPackageResults = new Map<string, PackageResult>(); private finalizedPackageResults = new Map<string, PackageResult>();
private runPackageGenerations = new Map<string, number>(); private runContexts = new Map<string, RunLifecycleContext>();
private pendingRunResult: PendingRunResult | null = null; private standalonePackageResults = new Set<string>();
private successDigestResults = new Map<string, PackageResultEnvelope>(); private successDigestResults = new Map<string, PackageResultEnvelope>();
@@ -3122,10 +3120,9 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.historyRecordedPackages.clear(); this.historyRecordedPackages.clear();
this.packageResultGenerations.clear();
this.finalizedPackageResults.clear(); this.finalizedPackageResults.clear();
this.runPackageGenerations.clear(); this.runContexts.clear();
this.pendingRunResult = null; this.standalonePackageResults.clear();
this.successDigestResults.clear(); this.successDigestResults.clear();
if (this.successDigestTimer) { if (this.successDigestTimer) {
clearTimeout(this.successDigestTimer); clearTimeout(this.successDigestTimer);
@@ -3194,6 +3191,7 @@ export class DownloadManager extends EventEmitter {
cleanedTotalBytes: 0, cleanedTotalBytes: 0,
cleanedUrls: [], cleanedUrls: [],
cleanedProviders: [], cleanedProviders: [],
resultGeneration: 1,
downloadStartedAt: 0, downloadStartedAt: 0,
downloadCompletedAt: 0, downloadCompletedAt: 0,
createdAt: nowMs(), createdAt: nowMs(),
@@ -3240,7 +3238,7 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) { if (this.session.running) {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.beginPackageResultGeneration(packageId)); this.beginPackageResultGeneration(packageId);
} }
if (looksLikeOpaqueFilename(fileName)) { if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? []; const existing = unresolvedByLink.get(link) ?? [];
@@ -4008,6 +4006,35 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
private async countPackageOutputFiles(rootDir: string): Promise<number> {
if (!rootDir) {
return 0;
}
const stack = [rootDir];
let count = 0;
while (stack.length > 0) {
const current = stack.pop() as string;
let entries: fs.Dirent[] = [];
try {
entries = await fs.promises.readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile() && !isArchiveLikePath(fullPath) && !isIgnorableEmptyDirFileName(entry.name)) {
count += 1;
}
}
}
return count;
}
private async removeEmptyDirectoryTree(rootDir: string): Promise<number> { private async removeEmptyDirectoryTree(rootDir: string): Promise<number> {
if (!rootDir) { if (!rootDir) {
return 0; return 0;
@@ -5288,32 +5315,6 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
// Packages whose post-processing (task, deferred pass or hybrid round) is still
// alive must keep their run-membership when the run set is replaced/cleared —
// otherwise their terminal notification is dropped as soon as session.running
// flips false (the notify guard checks running || runPackageIds).
private addTrailingPostProcessPackageIds(target: Set<string>): void {
for (const id of this.packagePostProcessTasks.keys()) {
target.add(id);
}
for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) {
if (!controller.signal.aborted) {
target.add(id);
}
}
for (const [id, tasks] of this.packageHybridPostProcessTasks) {
if (tasks.size > 0) target.add(id);
}
for (const [id, hybridSet] of this.packageHybridPostProcessControllers) {
for (const c of hybridSet) {
if (!c.signal.aborted) {
target.add(id);
break;
}
}
}
}
private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] { private buildCollectFolderCandidates(sourcePath: string, sourceRoot: string, pkg: PackageEntry): string[] {
const folderCandidates: string[] = []; const folderCandidates: string[] = [];
let currentDir = path.dirname(sourcePath); let currentDir = path.dirname(sourcePath);
@@ -5784,7 +5785,6 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
} }
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
} }
await Promise.allSettled(postProcessTasks); await Promise.allSettled(postProcessTasks);
@@ -5867,7 +5867,6 @@ export class DownloadManager extends EventEmitter {
} }
if (this.session.running) { if (this.session.running) {
this.runPackageIds.add(pkgId); this.runPackageIds.add(pkgId);
this.runPackageGenerations.set(pkgId, this.getPackageResultGeneration(pkgId));
} }
} }
@@ -5990,7 +5989,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "queued" || item.status === "reconnect_wait") { if (item.status === "queued" || item.status === "reconnect_wait") {
this.runItemIds.add(item.id); this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId); this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); this.beginPackageResultGeneration(item.packageId);
} }
} }
this.persistSoon(); this.persistSoon();
@@ -6013,9 +6012,6 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@@ -6098,7 +6094,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "queued" || item.status === "reconnect_wait") { if (item.status === "queued" || item.status === "reconnect_wait") {
this.runItemIds.add(item.id); this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId); this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId)); this.beginPackageResultGeneration(item.packageId);
} }
} }
this.persistSoon(); this.persistSoon();
@@ -6122,9 +6118,6 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@@ -6218,9 +6211,6 @@ export class DownloadManager extends EventEmitter {
if (this.packagePostProcessTasks.size > 0) { if (this.packagePostProcessTasks.size > 0) {
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.session.running = true; this.session.running = true;
@@ -6239,9 +6229,6 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@@ -6272,9 +6259,6 @@ export class DownloadManager extends EventEmitter {
} }
this.runItemIds = new Set(runItems.map((item) => item.id)); this.runItemIds = new Set(runItems.map((item) => item.id));
this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
@@ -6373,14 +6357,9 @@ export class DownloadManager extends EventEmitter {
} }
} }
if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) {
const outcomes = Array.from(this.runOutcomes.values()); const packageResults = [...this.runPackageIds]
const success = outcomes.filter((s) => s === "completed").length; .flatMap((packageId) => {
const failed = outcomes.filter((s) => s === "failed").length; const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)));
const cancelled = outcomes.filter((s) => s === "cancelled").length;
this.ensureRunPackageGenerations(this.runPackageIds);
const packageResults = [...this.runPackageGenerations]
.flatMap(([packageId, generation]) => {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
return result ? [result] : []; return result ? [result] : [];
}); });
this.flushPackageSuccessDigest(); this.flushPackageSuccessDigest();
@@ -6390,13 +6369,13 @@ export class DownloadManager extends EventEmitter {
startedAt: this.session.runStartedAt, startedAt: this.session.runStartedAt,
completedAt: nowMs(), completedAt: nowMs(),
packages: packageResults, packages: packageResults,
totalPackages: this.runPackageIds.size, totalPackages: this.runPackageIds.size
successfulFiles: success,
failedFiles: failed,
cancelledFiles: cancelled
}))); })));
this.pendingRunResult = null;
} }
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
} }
@@ -8309,8 +8288,7 @@ export class DownloadManager extends EventEmitter {
targetedItems: targetItems.length targetedItems: targetItems.length
}); });
const generation = this.beginPackageResultGeneration(packageId, false, true); const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId); this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
this.runPackageGenerations.set(packageId, generation);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
@@ -8340,8 +8318,7 @@ export class DownloadManager extends EventEmitter {
targetedItems: targetItems.length targetedItems: targetItems.length
}); });
const generation = this.beginPackageResultGeneration(packageId, false, true); const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId); this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
this.runPackageGenerations.set(packageId, generation);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
@@ -8356,6 +8333,12 @@ export class DownloadManager extends EventEmitter {
private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void { private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void {
this.notePackageDownloadStarted(pkg, completedAt); this.notePackageDownloadStarted(pkg, completedAt);
pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt); pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt);
if (pkg.itemIds.every((itemId) => {
const item = this.session.items[itemId];
return !item || isFinishedStatus(item.status);
})) {
pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, completedAt);
}
} }
private getPackageHistoryDurationSeconds(pkg: PackageEntry): number { private getPackageHistoryDurationSeconds(pkg: PackageEntry): number {
@@ -11609,8 +11592,8 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const generation = this.beginPackageResultGeneration(packageId, false, true); const generation = this.beginPackageResultGeneration(packageId, false, true);
if (this.runPackageIds.has(packageId)) { if (!this.runPackageIds.has(packageId)) {
this.runPackageGenerations.set(packageId, generation); this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
} }
this.refreshPackageStatus(pkg); this.refreshPackageStatus(pkg);
} }
@@ -11681,21 +11664,26 @@ export class DownloadManager extends EventEmitter {
} }
private getPackageResultGeneration(packageId: string): number { private getPackageResultGeneration(packageId: string): number {
const existing = this.packageResultGenerations.get(packageId); const pkg = this.session.packages[packageId];
if (existing && existing > 0) { if (!pkg) {
return existing; return 1;
} }
this.packageResultGenerations.set(packageId, 1); const generation = Number.isFinite(pkg.resultGeneration)
return 1; ? Math.max(1, Math.floor(pkg.resultGeneration || 1))
: 1;
pkg.resultGeneration = generation;
return generation;
} }
private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number { private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number {
const current = this.getPackageResultGeneration(packageId); const current = this.getPackageResultGeneration(packageId);
const currentKey = this.packageResultKey(packageId, current); const currentKey = this.packageResultKey(packageId, current);
const wasFinalized = this.finalizedPackageResults.has(currentKey);
const next = wasFinalized ? current + 1 : current;
this.packageResultGenerations.set(packageId, next);
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
const wasFinalized = this.finalizedPackageResults.has(currentKey) || (pkg?.terminalAt || 0) > 0;
const next = wasFinalized ? current + 1 : current;
if (pkg) {
pkg.resultGeneration = next;
}
if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) { if (pkg && (wasFinalized || resetDownloadTelemetry || forceReset)) {
if (resetDownloadTelemetry) { if (resetDownloadTelemetry) {
pkg.downloadStartedAt = 0; pkg.downloadStartedAt = 0;
@@ -11714,14 +11702,27 @@ export class DownloadManager extends EventEmitter {
return next; return next;
} }
private ensureRunPackageGenerations(packageIds: Iterable<string>): void { private captureRunContext(packageIds: Iterable<string>, startedAt: number): RunLifecycleContext {
const packageGenerations = new Map<string, number>();
for (const packageId of packageIds) { for (const packageId of packageIds) {
if (this.runPackageGenerations.has(packageId)) { packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
continue;
}
const generation = this.getPackageResultGeneration(packageId);
this.runPackageGenerations.set(packageId, generation);
} }
const context: RunLifecycleContext = { id: uuidv4(), startedAt, packageGenerations };
this.runContexts.set(context.id, context);
return context;
}
private isPackageResultTracked(packageId: string, generation: number): boolean {
const key = this.packageResultKey(packageId, generation);
if (this.standalonePackageResults.has(key)) {
return true;
}
for (const context of this.runContexts.values()) {
if (context.packageGenerations.get(packageId) === generation) {
return true;
}
}
return false;
} }
private queueNotificationEvent(notification: NotificationEvent): void { private queueNotificationEvent(notification: NotificationEvent): void {
@@ -11784,14 +11785,17 @@ export class DownloadManager extends EventEmitter {
private tryFinalizePackageResult(packageId: string): PackageResult | null { private tryFinalizePackageResult(packageId: string): PackageResult | null {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || (!this.runPackageIds.has(packageId) && !this.runPackageGenerations.has(packageId))) { if (!pkg) {
return null;
}
const generation = this.getPackageResultGeneration(packageId);
if (!this.runPackageIds.has(packageId) && !this.isPackageResultTracked(packageId, generation)) {
return null; return null;
} }
const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[]; const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) { if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) {
return null; return null;
} }
const generation = this.runPackageGenerations.get(packageId) || this.getPackageResultGeneration(packageId);
const key = this.packageResultKey(packageId, generation); const key = this.packageResultKey(packageId, generation);
const existing = this.finalizedPackageResults.get(key); const existing = this.finalizedPackageResults.get(key);
if (existing) { if (existing) {
@@ -11839,45 +11843,45 @@ export class DownloadManager extends EventEmitter {
} }
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
this.tryFinalizeRunResult(); this.standalonePackageResults.delete(key);
this.tryFinalizeRunResults();
return result; return result;
} }
private tryFinalizeRunResult(): void { private tryFinalizeRunResults(): void {
const pending = this.pendingRunResult; for (const context of [...this.runContexts.values()]) {
if (!pending) { const packageResults: PackageResult[] = [];
return; let complete = true;
} for (const [packageId, generation] of context.packageGenerations) {
const packageResults: PackageResult[] = []; const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
for (const [packageId, generation] of pending.packageGenerations) { if (!result) {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation)); complete = false;
if (!result) { break;
return; }
packageResults.push(result);
} }
packageResults.push(result); if (!complete) {
} continue;
const result = buildRunResult({ }
id: pending.id, const result = buildRunResult({
stopped: false, id: context.id,
startedAt: pending.startedAt, stopped: false,
completedAt: nowMs(), startedAt: context.startedAt,
packages: packageResults, completedAt: nowMs(),
totalPackages: pending.packageGenerations.size packages: packageResults,
}); totalPackages: context.packageGenerations.size
this.flushPackageSuccessDigest(result.completedAt); });
if (this.settings.notifyOnRunFinished) { this.flushPackageSuccessDigest(result.completedAt);
this.queueNotificationEvent(buildRunNotificationEvent(result)); if (this.settings.notifyOnRunFinished) {
} this.queueNotificationEvent(buildRunNotificationEvent(result));
this.pendingRunResult = null; }
for (const [packageId, generation] of pending.packageGenerations) { this.runContexts.delete(context.id);
if (!this.session.packages[packageId]) { for (const [packageId, generation] of context.packageGenerations) {
this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation)); if (!this.session.packages[packageId] && !this.isPackageResultTracked(packageId, generation)) {
this.packageResultGenerations.delete(packageId); this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation));
}
} }
} }
this.runPackageIds.clear();
this.runPackageGenerations.clear();
this.runCompletedPackages.clear();
} }
private refreshPackageStatus(pkg: PackageEntry): void { private refreshPackageStatus(pkg: PackageEntry): void {
@@ -11917,7 +11921,9 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, nowMs()); if (!(pkg.downloadEndedAt || 0)) {
pkg.downloadEndedAt = nowMs();
}
if (failed > 0 || extractFailed > 0) { if (failed > 0 || extractFailed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0) { } else if (cancelled > 0) {
@@ -12571,6 +12577,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
}); });
pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir));
logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`);
this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", { this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", {
@@ -13126,6 +13133,7 @@ export class DownloadManager extends EventEmitter {
emitExtractStatus(overallLabel); emitExtractStatus(overallLabel);
} }
}); });
pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir));
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", { this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", {
extracted: result.extracted, extracted: result.extracted,
@@ -13356,6 +13364,7 @@ export class DownloadManager extends EventEmitter {
} }
}); });
throwIfAborted(); throwIfAborted();
pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir));
extractedCount += nestedResult.extracted; extractedCount += nestedResult.extracted;
logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`); logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`);
this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", { this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", {
@@ -13631,13 +13640,9 @@ export class DownloadManager extends EventEmitter {
averageSpeedBps: avgSpeed averageSpeedBps: avgSpeed
}; };
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
this.ensureRunPackageGenerations(this.runPackageIds); const runContext = total > 0 ? this.captureRunContext(this.runPackageIds, runStartedAt) : null;
this.pendingRunResult = total > 0 ? {
id: uuidv4(),
startedAt: runStartedAt,
packageGenerations: new Map(this.runPackageGenerations)
} : null;
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear(); this.providerStartReservations.clear();
@@ -13658,10 +13663,10 @@ export class DownloadManager extends EventEmitter {
this.lastSettingsPersistAt = 0; this.lastSettingsPersistAt = 0;
this.persistNow(); this.persistNow();
this.emitState(); this.emitState();
for (const packageId of this.runPackageIds) { for (const packageId of runContext?.packageGenerations.keys() || []) {
this.tryFinalizePackageResult(packageId); this.tryFinalizePackageResult(packageId);
} }
this.tryFinalizeRunResult(); this.tryFinalizeRunResults();
} }
public getSessionStats(): import("../shared/types").SessionStats { public getSessionStats(): import("../shared/types").SessionStats {
+1
View File
@@ -998,6 +998,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations), remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000), outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
cleanupErrorCategory: asText(pkg.cleanupErrorCategory), cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
}; };
+1
View File
@@ -572,6 +572,7 @@ export interface PackageEntry {
remuxOperations?: RemuxOperationMetric[]; remuxOperations?: RemuxOperationMetric[];
outputCount?: number; outputCount?: number;
cleanupErrorCategory?: string; cleanupErrorCategory?: string;
resultGeneration?: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
+17 -2
View File
@@ -4457,6 +4457,7 @@ describe("download manager", () => {
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined; let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
try { try {
const history: HistoryEntry[] = [];
const manager = new DownloadManager( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
@@ -4467,13 +4468,14 @@ describe("download manager", () => {
maxParallel: 1 maxParallel: 1
}, },
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state")),
{ onHistoryEntry: (entry) => history.push(entry) }
); );
const blocker = new Promise<void>((resolve) => { const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve; releaseBlockedPostProcess = resolve;
}); });
(manager as unknown as { packagePostProcessQueue: Promise<void> }).packagePostProcessQueue = blocker; (manager as any).handlePackagePostProcessing = vi.fn(async () => blocker);
manager.addPackages([ manager.addPackages([
{ name: "first", links: ["https://dummy/first"] }, { name: "first", links: ["https://dummy/first"] },
@@ -4494,14 +4496,26 @@ describe("download manager", () => {
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed"; return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
}, 6000); }, 6000);
const pendingPackage = manager.getSnapshot().session.packages[firstPackage];
expect(pendingPackage?.downloadStartedAt).toBeGreaterThan(0);
expect(pendingPackage?.downloadEndedAt).toBeGreaterThanOrEqual(pendingPackage?.downloadStartedAt || 0);
expect(pendingPackage?.downloadEndedAt).toBe(pendingPackage?.downloadCompletedAt);
const downloadEndedAt = pendingPackage?.downloadEndedAt || 0;
await new Promise((resolve) => setTimeout(resolve, 1_200));
expect(manager.getSnapshot().session.packages[firstPackage]?.downloadEndedAt).toBe(downloadEndedAt);
if (releaseBlockedPostProcess) { if (releaseBlockedPostProcess) {
releaseBlockedPostProcess(); releaseBlockedPostProcess();
} }
await waitFor(() => !manager.getSnapshot().session.running, 25000); await waitFor(() => !manager.getSnapshot().session.running, 25000);
await waitFor(() => history.some((entry) => entry.name === "first"), 6000);
const done = manager.getSnapshot(); const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.status).toBe("completed"); expect(done.session.items[firstItem]?.status).toBe("completed");
expect(done.session.items[secondItem]?.status).toBe("completed"); expect(done.session.items[secondItem]?.status).toBe("completed");
const firstHistory = history.find((entry) => entry.name === "first");
expect(firstHistory?.downloadEndedAt).toBe(downloadEndedAt);
expect(firstHistory?.totalDurationSeconds).toBeGreaterThan(firstHistory?.downloadDurationSeconds || 0);
} finally { } finally {
if (releaseBlockedPostProcess) { if (releaseBlockedPostProcess) {
releaseBlockedPostProcess(); releaseBlockedPostProcess();
@@ -11055,6 +11069,7 @@ describe("download manager", () => {
errorCategory: "" errorCategory: ""
}); });
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0); expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
expect(snapshot.session.packages[packageId]?.outputCount).toBe(1);
}, 30000); }, 30000);
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
+100 -2
View File
@@ -243,6 +243,51 @@ describe("authoritative package completion", () => {
expect(history).toHaveLength(2); expect(history).toHaveLength(2);
}); });
it("continues the persisted result generation after an extraction retry following restart", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
pkg.resultGeneration = 7;
pkg.terminalAt = Date.now() - 1_000;
pkg.archiveOperations = [{
id: "archive-restart-failed",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 10_000,
completedAt: 12_000,
durationMs: 2_000,
status: "failed",
errorCategory: "crc_error"
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
manager.retryExtraction(pkg.id);
expect(pkg.resultGeneration).toBe(8);
pkg.archiveOperations = [{
id: "archive-restart-completed",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 20_000,
completedAt: 23_000,
durationMs: 3_000,
status: "completed",
errorCategory: ""
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
pkg.status = "completed";
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events).toHaveLength(1);
expect(events[0].id).toContain(":8:");
expect(history).toHaveLength(1);
expect(history[0].id).toBe(`hist-${pkg.id}-8`);
});
it("moves a pending success digest into the outbox before shutdown", async () => { it("moves a pending success digest into the outbox before shutdown", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session); const pkg = addPackage(session);
@@ -279,6 +324,29 @@ describe("authoritative run completion", () => {
expect(events.some((event) => event.type === "run_completed")).toBe(false); expect(events.some((event) => event.type === "run_completed")).toBe(false);
}); });
it("builds run_stopped file counters only from finalized package results", async () => {
const { manager, session, events } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds = new Set([pkg.id]);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
events.length = 0;
session.running = true;
session.runStartedAt = Date.now() - 10_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "failed"]]);
manager.stop();
await flushNotifications();
const stopped = events.find((event) => event.type === "run_stopped");
expect(stopped).toBeDefined();
expect(stopped?.payload.fields.some((field) => field.name === "Dateien" && field.value === "1 erfolgreich · 0 fehlgeschlagen · 0 abgebrochen")).toBe(true);
});
it("waits for failed extraction package results before emitting the final run summary", async () => { it("waits for failed extraction package results before emitting the final run summary", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" }); const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session); const pkg = addPackage(session);
@@ -344,8 +412,6 @@ describe("authoritative run completion", () => {
session.runStartedAt = Date.now() - 20_000; session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds); state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]); state.runPackageIds = new Set([pkg.id]);
state.packageResultGenerations = new Map([[pkg.id, 1]]);
state.runPackageGenerations = new Map([[pkg.id, 1]]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]); state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.tryFinalizePackageResult(pkg.id); state.tryFinalizePackageResult(pkg.id);
@@ -357,4 +423,36 @@ describe("authoritative run completion", () => {
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]); expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest"); expect(events[0].payload.title).toContain("Paket-Digest");
}); });
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");
const packageB = addPackage(session, ["completed"], "package-b");
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(packageA.itemIds);
state.runPackageIds = new Set([packageA.id]);
state.runOutcomes = new Map([[packageA.itemIds[0], "completed"]]);
state.packageDeferredPostProcessTasks.set(packageA.id, new Set([Promise.resolve()]));
state.finishRun();
session.running = true;
session.runStartedAt = Date.now() - 5_000;
state.runItemIds = new Set(packageB.itemIds);
state.runPackageIds = new Set([packageB.id]);
state.runOutcomes = new Map([[packageB.itemIds[0], "completed"]]);
state.finishRun();
state.packageDeferredPostProcessTasks.delete(packageA.id);
state.tryFinalizePackageResult(packageA.id);
await flushNotifications();
expect(events.filter((event) => event.type === "package_completed")).toHaveLength(2);
expect(events.filter((event) => event.type === "run_completed")).toHaveLength(2);
expect(new Set(events.filter((event) => event.type === "package_completed").map((event) => event.id)).size).toBe(2);
expect(new Set(events.filter((event) => event.type === "run_completed").map((event) => event.id)).size).toBe(2);
expect(history.map((entry) => entry.name).sort()).toEqual([packageA.name, packageB.name].sort());
});
}); });
+2
View File
@@ -1137,6 +1137,7 @@ describe("settings storage", () => {
itemIds: [], itemIds: [],
cancelled: false, cancelled: false,
enabled: true, enabled: true,
resultGeneration: 7,
downloadStartedAt: 1_000, downloadStartedAt: 1_000,
downloadCompletedAt: 10_000, downloadCompletedAt: 10_000,
downloadEndedAt: 12_000, downloadEndedAt: 12_000,
@@ -1174,6 +1175,7 @@ describe("settings storage", () => {
}); });
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({ expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
resultGeneration: 7,
downloadEndedAt: 12_000, downloadEndedAt: 12_000,
postProcessQueuedAt: 13_000, postProcessQueuedAt: 13_000,
postProcessStartedAt: 14_000, postProcessStartedAt: 14_000,