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
+161 -156
View File
@@ -466,7 +466,7 @@ type DownloadManagerOptions = {
protectEmptyClobber?: boolean;
};
type PendingRunResult = {
type RunLifecycleContext = {
id: string;
startedAt: number;
packageGenerations: Map<string, number>;
@@ -1936,13 +1936,11 @@ export class DownloadManager extends EventEmitter {
private historyRecordedPackages = new Set<string>();
private packageResultGenerations = new Map<string, number>();
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>();
@@ -3122,10 +3120,9 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.historyRecordedPackages.clear();
this.packageResultGenerations.clear();
this.finalizedPackageResults.clear();
this.runPackageGenerations.clear();
this.pendingRunResult = null;
this.runContexts.clear();
this.standalonePackageResults.clear();
this.successDigestResults.clear();
if (this.successDigestTimer) {
clearTimeout(this.successDigestTimer);
@@ -3194,6 +3191,7 @@ export class DownloadManager extends EventEmitter {
cleanedTotalBytes: 0,
cleanedUrls: [],
cleanedProviders: [],
resultGeneration: 1,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: nowMs(),
@@ -3240,7 +3238,7 @@ export class DownloadManager extends EventEmitter {
if (this.session.running) {
this.runItemIds.add(itemId);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.beginPackageResultGeneration(packageId));
this.beginPackageResultGeneration(packageId);
}
if (looksLikeOpaqueFilename(fileName)) {
const existing = unresolvedByLink.get(link) ?? [];
@@ -3971,7 +3969,7 @@ export class DownloadManager extends EventEmitter {
});
}
private async directoryHasAnyFiles(rootDir: string): Promise<boolean> {
private async directoryHasAnyFiles(rootDir: string): Promise<boolean> {
if (!rootDir) {
return false;
}
@@ -4005,10 +4003,39 @@ export class DownloadManager extends EventEmitter {
}
}
}
return false;
}
private async removeEmptyDirectoryTree(rootDir: string): Promise<number> {
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> {
if (!rootDir) {
return 0;
}
@@ -5288,32 +5315,6 @@ export class DownloadManager extends EventEmitter {
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[] {
const folderCandidates: string[] = [];
let currentDir = path.dirname(sourcePath);
@@ -5784,7 +5785,6 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.add(itemId);
}
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
await Promise.allSettled(postProcessTasks);
@@ -5867,7 +5867,6 @@ export class DownloadManager extends EventEmitter {
}
if (this.session.running) {
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") {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId));
this.beginPackageResultGeneration(item.packageId);
}
}
this.persistSoon();
@@ -6011,11 +6010,8 @@ export class DownloadManager extends EventEmitter {
this.emitState(true);
return;
}
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.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6098,7 +6094,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "queued" || item.status === "reconnect_wait") {
this.runItemIds.add(item.id);
this.runPackageIds.add(item.packageId);
this.runPackageGenerations.set(item.packageId, this.beginPackageResultGeneration(item.packageId));
this.beginPackageResultGeneration(item.packageId);
}
}
this.persistSoon();
@@ -6120,11 +6116,8 @@ export class DownloadManager extends EventEmitter {
this.emitState(true);
return;
}
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.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6216,11 +6209,8 @@ export class DownloadManager extends EventEmitter {
});
if (runItems.length === 0) {
if (this.packagePostProcessTasks.size > 0) {
this.runItemIds.clear();
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.session.running = true;
@@ -6237,11 +6227,8 @@ export class DownloadManager extends EventEmitter {
});
return;
}
this.runItemIds.clear();
this.runItemIds.clear();
this.runPackageIds.clear();
this.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6270,11 +6257,8 @@ export class DownloadManager extends EventEmitter {
this.emitState(true);
return;
}
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.addTrailingPostProcessPackageIds(this.runPackageIds);
this.runPackageGenerations.clear();
this.ensureRunPackageGenerations(this.runPackageIds);
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
@@ -6371,16 +6355,11 @@ export class DownloadManager extends EventEmitter {
pkg.status = "queued";
pkg.updatedAt = nowMs();
}
}
}
if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) {
const outcomes = Array.from(this.runOutcomes.values());
const success = outcomes.filter((s) => s === "completed").length;
const failed = outcomes.filter((s) => s === "failed").length;
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));
const packageResults = [...this.runPackageIds]
.flatMap((packageId) => {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, this.getPackageResultGeneration(packageId)));
return result ? [result] : [];
});
this.flushPackageSuccessDigest();
@@ -6390,13 +6369,13 @@ export class DownloadManager extends EventEmitter {
startedAt: this.session.runStartedAt,
completedAt: nowMs(),
packages: packageResults,
totalPackages: this.runPackageIds.size,
successfulFiles: success,
failedFiles: failed,
cancelledFiles: cancelled
totalPackages: this.runPackageIds.size
})));
this.pendingRunResult = null;
}
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.persistSoon();
this.emitState(true);
}
@@ -8307,10 +8286,9 @@ export class DownloadManager extends EventEmitter {
this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", {
completedItems: completedItems.length,
targetedItems: targetItems.length
});
});
const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, generation);
this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
@@ -8338,10 +8316,9 @@ export class DownloadManager extends EventEmitter {
this.logPackageForPackage(pkg, "INFO", "Jetzt entpacken ausgelöst", {
completedItems: completedItems.length,
targetedItems: targetItems.length
});
});
const generation = this.beginPackageResultGeneration(packageId, false, true);
this.runPackageIds.add(packageId);
this.runPackageGenerations.set(packageId, generation);
this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
this.persistSoon();
this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
@@ -8353,9 +8330,15 @@ export class DownloadManager extends EventEmitter {
}
}
private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void {
this.notePackageDownloadStarted(pkg, completedAt);
pkg.downloadCompletedAt = Math.max(pkg.downloadCompletedAt || 0, completedAt);
private notePackageDownloadCompleted(pkg: PackageEntry, completedAt = nowMs()): void {
this.notePackageDownloadStarted(pkg, 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 {
@@ -11609,8 +11592,8 @@ export class DownloadManager extends EventEmitter {
continue;
}
const generation = this.beginPackageResultGeneration(packageId, false, true);
if (this.runPackageIds.has(packageId)) {
this.runPackageGenerations.set(packageId, generation);
if (!this.runPackageIds.has(packageId)) {
this.standalonePackageResults.add(this.packageResultKey(packageId, generation));
}
this.refreshPackageStatus(pkg);
}
@@ -11681,21 +11664,26 @@ export class DownloadManager extends EventEmitter {
}
private getPackageResultGeneration(packageId: string): number {
const existing = this.packageResultGenerations.get(packageId);
if (existing && existing > 0) {
return existing;
const pkg = this.session.packages[packageId];
if (!pkg) {
return 1;
}
this.packageResultGenerations.set(packageId, 1);
return 1;
const generation = Number.isFinite(pkg.resultGeneration)
? Math.max(1, Math.floor(pkg.resultGeneration || 1))
: 1;
pkg.resultGeneration = generation;
return generation;
}
private beginPackageResultGeneration(packageId: string, resetDownloadTelemetry = false, forceReset = false): number {
const current = this.getPackageResultGeneration(packageId);
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 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 (resetDownloadTelemetry) {
pkg.downloadStartedAt = 0;
@@ -11714,14 +11702,27 @@ export class DownloadManager extends EventEmitter {
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) {
if (this.runPackageGenerations.has(packageId)) {
continue;
}
const generation = this.getPackageResultGeneration(packageId);
this.runPackageGenerations.set(packageId, generation);
packageGenerations.set(packageId, this.getPackageResultGeneration(packageId));
}
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 {
@@ -11784,14 +11785,17 @@ export class DownloadManager extends EventEmitter {
private tryFinalizePackageResult(packageId: string): PackageResult | null {
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;
}
const items = pkg.itemIds.map((itemId) => this.session.items[itemId]).filter(Boolean) as DownloadItem[];
if (items.some((item) => !isFinishedStatus(item.status)) || this.hasPackageLifecycleWork(packageId)) {
return null;
}
const generation = this.runPackageGenerations.get(packageId) || this.getPackageResultGeneration(packageId);
const key = this.packageResultKey(packageId, generation);
const existing = this.finalizedPackageResults.get(key);
if (existing) {
@@ -11839,45 +11843,45 @@ export class DownloadManager extends EventEmitter {
}
this.persistSoon();
this.emitState();
this.tryFinalizeRunResult();
this.standalonePackageResults.delete(key);
this.tryFinalizeRunResults();
return result;
}
private tryFinalizeRunResult(): void {
const pending = this.pendingRunResult;
if (!pending) {
return;
}
const packageResults: PackageResult[] = [];
for (const [packageId, generation] of pending.packageGenerations) {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
if (!result) {
return;
private tryFinalizeRunResults(): void {
for (const context of [...this.runContexts.values()]) {
const packageResults: PackageResult[] = [];
let complete = true;
for (const [packageId, generation] of context.packageGenerations) {
const result = this.finalizedPackageResults.get(this.packageResultKey(packageId, generation));
if (!result) {
complete = false;
break;
}
packageResults.push(result);
}
packageResults.push(result);
}
const result = buildRunResult({
id: pending.id,
stopped: false,
startedAt: pending.startedAt,
completedAt: nowMs(),
packages: packageResults,
totalPackages: pending.packageGenerations.size
});
this.flushPackageSuccessDigest(result.completedAt);
if (this.settings.notifyOnRunFinished) {
this.queueNotificationEvent(buildRunNotificationEvent(result));
}
this.pendingRunResult = null;
for (const [packageId, generation] of pending.packageGenerations) {
if (!this.session.packages[packageId]) {
this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation));
this.packageResultGenerations.delete(packageId);
if (!complete) {
continue;
}
const result = buildRunResult({
id: context.id,
stopped: false,
startedAt: context.startedAt,
completedAt: nowMs(),
packages: packageResults,
totalPackages: context.packageGenerations.size
});
this.flushPackageSuccessDigest(result.completedAt);
if (this.settings.notifyOnRunFinished) {
this.queueNotificationEvent(buildRunNotificationEvent(result));
}
this.runContexts.delete(context.id);
for (const [packageId, generation] of context.packageGenerations) {
if (!this.session.packages[packageId] && !this.isPackageResultTracked(packageId, generation)) {
this.finalizedPackageResults.delete(this.packageResultKey(packageId, generation));
}
}
}
this.runPackageIds.clear();
this.runPackageGenerations.clear();
this.runCompletedPackages.clear();
}
private refreshPackageStatus(pkg: PackageEntry): void {
@@ -11917,7 +11921,9 @@ export class DownloadManager extends EventEmitter {
return;
}
pkg.downloadEndedAt = Math.max(pkg.downloadEndedAt || 0, nowMs());
if (!(pkg.downloadEndedAt || 0)) {
pkg.downloadEndedAt = nowMs();
}
if (failed > 0 || extractFailed > 0) {
pkg.status = "failed";
} else if (cancelled > 0) {
@@ -12570,9 +12576,10 @@ export class DownloadManager extends EventEmitter {
this.emitState();
}
}
});
logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`);
});
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}`);
this.logPackageForPackage(pkg, "INFO", "Hybrid-Extract abgeschlossen", {
extracted: result.extracted,
failed: result.failed
@@ -13125,8 +13132,9 @@ export class DownloadManager extends EventEmitter {
}
emitExtractStatus(overallLabel);
}
});
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
});
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 || ""}`);
this.logPackageForPackage(pkg, "INFO", "Post-Processing Entpacken Ende", {
extracted: result.extracted,
failed: result.failed,
@@ -13355,8 +13363,9 @@ export class DownloadManager extends EventEmitter {
);
}
});
throwIfAborted();
extractedCount += nestedResult.extracted;
throwIfAborted();
pkg.outputCount = Math.max(pkg.outputCount || 0, await this.countPackageOutputFiles(pkg.extractDir));
extractedCount += nestedResult.extracted;
logger.info(`Deferred Nested-Extraction Ende: extracted=${nestedResult.extracted}, failed=${nestedResult.failed}`);
this.logPackageForPackage(pkg, "INFO", "Deferred Nested-Extraction Ende", {
extracted: nestedResult.extracted,
@@ -13629,15 +13638,11 @@ export class DownloadManager extends EventEmitter {
extracted,
durationSeconds: duration,
averageSpeedBps: avgSpeed
};
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
this.ensureRunPackageGenerations(this.runPackageIds);
this.pendingRunResult = total > 0 ? {
id: uuidv4(),
startedAt: runStartedAt,
packageGenerations: new Map(this.runPackageGenerations)
} : null;
};
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
const runContext = total > 0 ? this.captureRunContext(this.runPackageIds, runStartedAt) : null;
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
@@ -13658,10 +13663,10 @@ export class DownloadManager extends EventEmitter {
this.lastSettingsPersistAt = 0;
this.persistNow();
this.emitState();
for (const packageId of this.runPackageIds) {
for (const packageId of runContext?.packageGenerations.keys() || []) {
this.tryFinalizePackageResult(packageId);
}
this.tryFinalizeRunResult();
this.tryFinalizeRunResults();
}
public getSessionStats(): import("../shared/types").SessionStats {
+1
View File
@@ -998,6 +998,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
cleanupErrorCategory: asText(pkg.cleanupErrorCategory),
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, 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[];
outputCount?: number;
cleanupErrorCategory?: string;
resultGeneration?: number;
createdAt: number;
updatedAt: number;
}