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;
}
+34 -19
View File
@@ -4455,9 +4455,10 @@ describe("download manager", () => {
return originalFetch(input, init);
};
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
try {
const manager = new DownloadManager(
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
try {
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -4466,14 +4467,15 @@ describe("download manager", () => {
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
emptySession(),
createStoragePaths(path.join(root, "state")),
{ onHistoryEntry: (entry) => history.push(entry) }
);
const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve;
});
(manager as unknown as { packagePostProcessQueue: Promise<void> }).packagePostProcessQueue = blocker;
const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve;
});
(manager as any).handlePackagePostProcessing = vi.fn(async () => blocker);
manager.addPackages([
{ name: "first", links: ["https://dummy/first"] },
@@ -4489,19 +4491,31 @@ describe("download manager", () => {
manager.start();
await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000);
await waitFor(() => {
const state = manager.getSnapshot().session.items[secondItem]?.status;
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
}, 6000);
await waitFor(() => {
const state = manager.getSnapshot().session.items[secondItem]?.status;
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
}, 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) {
releaseBlockedPostProcess();
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.status).toBe("completed");
expect(done.session.items[secondItem]?.status).toBe("completed");
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
await waitFor(() => history.some((entry) => entry.name === "first"), 6000);
const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.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 {
if (releaseBlockedPostProcess) {
releaseBlockedPostProcess();
@@ -11055,6 +11069,7 @@ describe("download manager", () => {
errorCategory: ""
});
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
expect(snapshot.session.packages[packageId]?.outputCount).toBe(1);
}, 30000);
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);
});
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 () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
@@ -279,6 +324,29 @@ describe("authoritative run completion", () => {
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 () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
@@ -344,8 +412,6 @@ describe("authoritative run completion", () => {
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
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.tryFinalizePackageResult(pkg.id);
@@ -357,4 +423,36 @@ describe("authoritative run completion", () => {
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
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: [],
cancelled: false,
enabled: true,
resultGeneration: 7,
downloadStartedAt: 1_000,
downloadCompletedAt: 10_000,
downloadEndedAt: 12_000,
@@ -1174,6 +1175,7 @@ describe("settings storage", () => {
});
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
resultGeneration: 7,
downloadEndedAt: 12_000,
postProcessQueuedAt: 13_000,
postProcessStartedAt: 14_000,