Deferred-Post-Processing Lifecycle härten (H1/H2/M1) + 0-Byte-Fix (H3) + Dead Code (N1)
Aus der Bug-Analyse (3 Subagents): die Deferred-Post-Processing-Pipeline war
nur halb ins Abbruch-/Lifecycle-Management integriert — gleiche Ecke wie der
v1.7.156-Datenverlust.
H1: abortPostProcessing (globaler Stop/Shutdown/clearAll/external) bricht jetzt
auch packageDeferredPostProcessAbortControllers + die neue Hybrid-Map ab.
Vorher rasten MKV-Move/Cleanup/Rename gegen den synchronen Shutdown-Save.
H2: Hybrid-Post-Extract (Rename+MKV-Collect) lief als komplett ungetracktes
detached Promise. Jetzt in packageHybridPostProcessControllers (Set/Package)
registriert — SYNCHRON vor dem Promise, mit shouldAbort an beide Aufrufe.
Bewusst SEPARAT von der Deferred-Map, sonst würde runDeferredPostExtraction's
replace-Logik die laufende Hybrid-Arbeit selbst killen (Advisor-Fund).
Cancel/Reset/Stop stoppt jetzt laufende Hybrid-Verschiebungen.
M1: hasAnyDeferredPostProcessPending() — Scheduler-Abschluss + finishRun-Clear
gaten darauf. Run endet/Summary feuert nicht mehr während im Hintergrund
noch Dateien verschoben werden; Run-State wird nicht mehr mittendrin geleert.
H3: validateDownloadedFileCompletion akzeptierte 0-Byte bei source=stream-end
(kein Content-Length, keine Provider-Größe) als "fertig". Jetzt ok:false
-> bestehender download_underflow-Retry-Pfad. Verhindert leere Datei = komplett.
N1: toter (unerreichbarer) Disk-Fallback-Block in findReadyArchiveSets +
verwaiste pendingItemStatus-Map entfernt (verhaltensneutral).
Bewusst übersprungen: M2 (blockAllPersistence — vorgeschlagener Reset wäre
unsicher, In-Memory-Session ist nach Import stale) und M3 (cancelPendingAsyncSaves
— Generation-Guard schützt Korrektheit bereits). Siehe tasks/todo.md.
8 neue Tests (tests/download-completion.test.ts) inkl. H3-Regression. 621 Tests grün.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d1274d23dc
commit
7d52d5a495
@ -127,6 +127,19 @@ export function validateDownloadedFileCompletion(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.plan.source === "stream-end") {
|
if (args.plan.source === "stream-end") {
|
||||||
|
// H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der
|
||||||
|
// Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener
|
||||||
|
// Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett
|
||||||
|
// und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits
|
||||||
|
// behandelte Fall actualBytes<=0 mit bekannter Größe (oben).
|
||||||
|
if (actualBytes <= 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
totalBytes: 0,
|
||||||
|
acceptedMetadataMismatch: false,
|
||||||
|
error: "download_underflow:0/0"
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
totalBytes: actualBytes,
|
totalBytes: actualBytes,
|
||||||
|
|||||||
@ -1672,6 +1672,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private packageDeferredPostProcessAbortControllers = new Map<string, AbortController>();
|
private packageDeferredPostProcessAbortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
// Hybrid post-extract (Rename + MKV-Collect) läuft als detached Promise sobald
|
||||||
|
// ein Archiv-Set fertig ist. Separat von der Deferred-Pipe getrackt, damit
|
||||||
|
// runDeferredPostExtraction's replace-Logik die laufende Hybrid-Arbeit nicht
|
||||||
|
// killt — und damit globaler Abort (Stop/Shutdown) + Run-Abschluss sie sehen.
|
||||||
|
// Set pro Package, da mehrere Archive-Sets dicht hintereinander fertig werden
|
||||||
|
// können. (H1/H2/M1)
|
||||||
|
private packageHybridPostProcessControllers = new Map<string, Set<AbortController>>();
|
||||||
|
|
||||||
private packagePostProcessVersions = new Map<string, number>();
|
private packagePostProcessVersions = new Map<string, number>();
|
||||||
|
|
||||||
private hybridExtractRequeue = new Set<string>();
|
private hybridExtractRequeue = new Set<string>();
|
||||||
@ -2478,6 +2486,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.packageDeferredPostProcessAbortControllers.delete(packageId);
|
this.packageDeferredPostProcessAbortControllers.delete(packageId);
|
||||||
|
|
||||||
|
// Auch laufende Hybrid-Post-Extract-Promises (Rename/MKV-Collect) abbrechen. (H2)
|
||||||
|
const hybridSet = this.packageHybridPostProcessControllers.get(packageId);
|
||||||
|
if (hybridSet) {
|
||||||
|
for (const controller of hybridSet) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.packageHybridPostProcessControllers.delete(packageId);
|
||||||
|
}
|
||||||
|
|
||||||
this.hybridExtractRequeue.delete(packageId);
|
this.hybridExtractRequeue.delete(packageId);
|
||||||
this.clearHybridArchiveState(packageId);
|
this.clearHybridArchiveState(packageId);
|
||||||
}
|
}
|
||||||
@ -2781,6 +2800,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.speedBytesPerPackage.clear();
|
this.speedBytesPerPackage.clear();
|
||||||
this.packagePostProcessTasks.clear();
|
this.packagePostProcessTasks.clear();
|
||||||
this.packagePostProcessAbortControllers.clear();
|
this.packagePostProcessAbortControllers.clear();
|
||||||
|
this.packageDeferredPostProcessAbortControllers.clear();
|
||||||
|
this.packageHybridPostProcessControllers.clear();
|
||||||
this.hybridExtractRequeue.clear();
|
this.hybridExtractRequeue.clear();
|
||||||
this.hybridExtractedPaths.clear();
|
this.hybridExtractedPaths.clear();
|
||||||
this.hybridFailedArchives.clear();
|
this.hybridFailedArchives.clear();
|
||||||
@ -4558,7 +4579,37 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private hasDeferredPostProcessPending(packageId: string): boolean {
|
private hasDeferredPostProcessPending(packageId: string): boolean {
|
||||||
const controller = this.packageDeferredPostProcessAbortControllers.get(packageId);
|
const controller = this.packageDeferredPostProcessAbortControllers.get(packageId);
|
||||||
return Boolean(controller && !controller.signal.aborted);
|
if (controller && !controller.signal.aborted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const hybridSet = this.packageHybridPostProcessControllers.get(packageId);
|
||||||
|
if (hybridSet) {
|
||||||
|
for (const c of hybridSet) {
|
||||||
|
if (!c.signal.aborted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M1: True wenn IRGENDWO noch Deferred- oder Hybrid-Post-Processing läuft.
|
||||||
|
* Verhindert dass der Scheduler/finishRun den Run als beendet meldet und
|
||||||
|
* State zurücksetzt, während im Hintergrund noch Dateien verschoben werden. */
|
||||||
|
private hasAnyDeferredPostProcessPending(): boolean {
|
||||||
|
for (const controller of this.packageDeferredPostProcessAbortControllers.values()) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const hybridSet of this.packageHybridPostProcessControllers.values()) {
|
||||||
|
for (const c of hybridSet) {
|
||||||
|
if (!c.signal.aborted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>): Promise<string> {
|
private async buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>): Promise<string> {
|
||||||
@ -7083,6 +7134,24 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// H1: Globaler Stop/Shutdown/clearAll/external muss AUCH das Deferred-Post-
|
||||||
|
// Processing (MKV-Move, Cleanup, Rename) und die Hybrid-Promises abbrechen,
|
||||||
|
// sonst rasen FS-Operationen gegen den Shutdown-Save und schreiben halbe
|
||||||
|
// Verschiebungen / löschen halbe Archive. Per-Package wird das von
|
||||||
|
// abortPackagePostProcessing erledigt — hier der globale Sweep.
|
||||||
|
for (const controller of this.packageDeferredPostProcessAbortControllers.values()) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const hybridSet of this.packageHybridPostProcessControllers.values()) {
|
||||||
|
for (const controller of hybridSet) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acquirePostProcessSlot(packageId: string): Promise<void> {
|
private async acquirePostProcessSlot(packageId: string): Promise<void> {
|
||||||
@ -8151,7 +8220,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Single-pass queue presence check (saves one full O(n) iteration per tick)
|
// Single-pass queue presence check (saves one full O(n) iteration per tick)
|
||||||
const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
|
const queuePresence = this.activeTasks.size === 0 ? this.getQueuePresence(now) : { hasImmediate: true, hasDelayed: false };
|
||||||
const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed;
|
const downloadsComplete = this.activeTasks.size === 0 && !queuePresence.hasImmediate && !queuePresence.hasDelayed;
|
||||||
const postProcessComplete = this.packagePostProcessTasks.size === 0;
|
const postProcessComplete = this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending();
|
||||||
if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) {
|
if (downloadsComplete && (postProcessComplete || this.settings.autoExtractWhenStopped)) {
|
||||||
this.finishRun();
|
this.finishRun();
|
||||||
break;
|
break;
|
||||||
@ -10790,24 +10859,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build lookup: pathKey → item status for pending items.
|
|
||||||
// Also map by filename (resolved against outputDir) so items without
|
|
||||||
// targetPath (never started) are still found by the disk-fallback check.
|
|
||||||
const packageItems = pkg.itemIds
|
const packageItems = pkg.itemIds
|
||||||
.map((itemId) => this.session.items[itemId])
|
.map((itemId) => this.session.items[itemId])
|
||||||
.filter(Boolean) as DownloadItem[];
|
.filter(Boolean) as DownloadItem[];
|
||||||
const pendingItemStatus = new Map<string, string>();
|
|
||||||
for (const item of packageItems) {
|
|
||||||
if (item.status === "completed") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (item.targetPath) {
|
|
||||||
pendingItemStatus.set(pathKey(item.targetPath), item.status);
|
|
||||||
}
|
|
||||||
if (item.fileName && pkg.outputDir) {
|
|
||||||
pendingItemStatus.set(pathKey(path.join(pkg.outputDir, item.fileName)), item.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
||||||
@ -10848,54 +10902,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${nonCompletedCount} Part(s) laut Session ohne completed-Status)`);
|
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${nonCompletedCount} Part(s) laut Session ohne completed-Status)`);
|
||||||
ready.add(pathKey(candidate));
|
ready.add(pathKey(candidate));
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Disk-fallback: if all parts exist on disk at their full expected size but some
|
|
||||||
// items lack "completed" status, allow extraction. This handles items that finished
|
|
||||||
// downloading but whose status was not updated (crash between write and persist).
|
|
||||||
const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part)));
|
|
||||||
let allMissingFullOnDisk = true;
|
|
||||||
for (const part of missingParts) {
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.stat(part);
|
|
||||||
// Find the item that owns this file to get its expected totalBytes
|
|
||||||
const ownerItem = this.findItemByDiskPath(pkg, part);
|
|
||||||
const minBytes = expectedMinBytes(ownerItem?.totalBytes ?? 0, isLargeBinaryLikePath(part));
|
|
||||||
if (stat.size < minBytes) {
|
|
||||||
allMissingFullOnDisk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
allMissingFullOnDisk = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!allMissingFullOnDisk) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Any non-completed item blocks extraction — failed/cancelled/stopped items may
|
|
||||||
// have partial files on disk that would corrupt the extraction.
|
|
||||||
const anyNonCompletedItem = missingParts.some((part) => {
|
|
||||||
const status = pendingItemStatus.get(pathKey(part));
|
|
||||||
return status !== undefined;
|
|
||||||
});
|
|
||||||
if (anyNonCompletedItem) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Safety: if any pending item in the package has neither targetPath nor fileName,
|
|
||||||
// we cannot map it to a file on disk. It could correspond to any missingPart
|
|
||||||
// (e.g. after a reset before re-unrestrict), so skip disk-fallback for this archive.
|
|
||||||
const hasUntrackedPendingItem = pkg.itemIds.some((itemId) => {
|
|
||||||
const pendingItem = this.session.items[itemId];
|
|
||||||
return pendingItem
|
|
||||||
&& !isFinishedStatus(pendingItem.status)
|
|
||||||
&& !pendingItem.targetPath
|
|
||||||
&& !pendingItem.fileName;
|
|
||||||
});
|
|
||||||
if (hasUntrackedPendingItem) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`);
|
|
||||||
ready.add(pathKey(candidate));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ready;
|
return ready;
|
||||||
@ -11331,16 +11337,37 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// race with the deferred-post-process pipe's rename / mkvMove for
|
// race with the deferred-post-process pipe's rename / mkvMove for
|
||||||
// the same package — without that, hybrid mkvMove could move a
|
// the same package — without that, hybrid mkvMove could move a
|
||||||
// file while deferred rename was still scanning it (ENOENT).
|
// file while deferred rename was still scanning it (ENOENT).
|
||||||
|
//
|
||||||
|
// Der Controller wird SYNCHRON (vor dem void-Promise) registriert, damit
|
||||||
|
// es kein Zeitfenster gibt in dem packagePostProcessTasks leer UND die
|
||||||
|
// Hybrid-Arbeit ungetrackt ist. shouldAbort stoppt Rename + MKV-Collect
|
||||||
|
// bei Stop/Shutdown/Cancel/Reset oder wenn das Package ersetzt wurde. (H2)
|
||||||
|
const hybridController = new AbortController();
|
||||||
|
let hybridSet = this.packageHybridPostProcessControllers.get(packageId);
|
||||||
|
if (!hybridSet) {
|
||||||
|
hybridSet = new Set<AbortController>();
|
||||||
|
this.packageHybridPostProcessControllers.set(packageId, hybridSet);
|
||||||
|
}
|
||||||
|
hybridSet.add(hybridController);
|
||||||
|
const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg);
|
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, hybridShouldAbort);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
|
logger.warn(`Hybrid Auto-Rename Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg));
|
await this.chainPackageFileOp(pkg.id, () => this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`Hybrid MKV-Collection Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
|
logger.warn(`Hybrid MKV-Collection Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`);
|
||||||
|
} finally {
|
||||||
|
const set = this.packageHybridPostProcessControllers.get(packageId);
|
||||||
|
if (set) {
|
||||||
|
set.delete(hybridController);
|
||||||
|
if (set.size === 0) {
|
||||||
|
this.packageHybridPostProcessControllers.delete(packageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@ -12307,7 +12334,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Keep runPackageIds and runCompletedPackages alive when post-processing tasks
|
// Keep runPackageIds and runCompletedPackages alive when post-processing tasks
|
||||||
// are still running (autoExtractWhenStopped) so handlePackagePostProcessing()
|
// are still running (autoExtractWhenStopped) so handlePackagePostProcessing()
|
||||||
// can still update runCompletedPackages. They are cleared by the next start().
|
// can still update runCompletedPackages. They are cleared by the next start().
|
||||||
if (this.packagePostProcessTasks.size === 0) {
|
// M1: auch laufende Deferred/Hybrid-Arbeit berücksichtigen, sonst werden die
|
||||||
|
// Run-Maps geleert während noch MKVs verschoben werden.
|
||||||
|
if (this.packagePostProcessTasks.size === 0 && !this.hasAnyDeferredPostProcessPending()) {
|
||||||
this.runPackageIds.clear();
|
this.runPackageIds.clear();
|
||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,5 +54,17 @@ App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte L
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## REVIEW / ERGEBNISSE
|
## REVIEW / ERGEBNISSE (2026-05-23)
|
||||||
(wird nach Umsetzung gefüllt)
|
|
||||||
|
**Umgesetzt (v1.7.158):**
|
||||||
|
- ✅ **H1** — `abortPostProcessing` aborted jetzt auch alle Deferred- + Hybrid-Controller (globaler Stop/Shutdown/clearAll/external). Keine FS-Race gegen Shutdown-Save mehr.
|
||||||
|
- ✅ **H2** — Hybrid-Post-Extract läuft über neue `packageHybridPostProcessControllers`-Map (Set pro Package), Controller SYNCHRON vor dem detached Promise registriert, `shouldAbort` an Rename + MKV-Collect durchgereicht. `abortPackagePostProcessing` + `clearAll` räumen die Map. Cancel/Reset stoppt jetzt laufende Hybrid-Arbeit.
|
||||||
|
- ✅ **M1** — neuer `hasAnyDeferredPostProcessPending()`; Scheduler-Abschluss + `finishRun`-Clear gaten darauf. `hasDeferredPostProcessPending` (per-Package, für package_done-Cleanup) prüft jetzt auch Hybrid. Run endet erst wenn Background-FS-Arbeit fertig.
|
||||||
|
- ✅ **H3** — `validateDownloadedFileCompletion`: 0-Byte bei `stream-end` → `ok:false` (download_underflow), routet in den bestehenden Retry-Pfad. Regressionstest in `tests/download-completion.test.ts` (8 Tests).
|
||||||
|
- ✅ **N1** — toter Disk-Fallback-Block in `findReadyArchiveSets` + verwaiste `pendingItemStatus`-Map entfernt (verhaltensneutral).
|
||||||
|
|
||||||
|
**Bewusst NICHT umgesetzt (mit Begründung):**
|
||||||
|
- ⏭️ **M2** (`blockAllPersistence` nie zurückgesetzt) — der vom Report vorgeschlagene Reset wäre **unsicher**: nach Backup-Import ist die In-Memory-Session stale (Import schreibt nur auf Disk, lädt den Manager nicht neu). Ein Reset würde beim nächsten persist die restored Daten mit Stale-State überschreiben. `blockAllPersistence` ist absichtlich bis-Neustart. Sauberer Fix = In-Memory-Reload nach Import (größerer Umbau, separat).
|
||||||
|
- ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff.
|
||||||
|
|
||||||
|
**Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst).
|
||||||
|
|||||||
64
tests/download-completion.test.ts
Normal file
64
tests/download-completion.test.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
|
|
||||||
|
describe("download-completion", () => {
|
||||||
|
describe("planDownloadCompletion", () => {
|
||||||
|
it("uses content-length when present", () => {
|
||||||
|
const plan = planDownloadCompletion({
|
||||||
|
existingBytes: 0, responseStatus: 200, contentLength: 1000,
|
||||||
|
totalFromRange: null, knownTotal: null, correctedTotal: null
|
||||||
|
});
|
||||||
|
expect(plan.source).toBe("content-length");
|
||||||
|
expect(plan.expectedTotal).toBe(1000);
|
||||||
|
});
|
||||||
|
it("falls back to stream-end when no size info is available", () => {
|
||||||
|
const plan = planDownloadCompletion({
|
||||||
|
existingBytes: 0, responseStatus: 200, contentLength: 0,
|
||||||
|
totalFromRange: null, knownTotal: null, correctedTotal: null
|
||||||
|
});
|
||||||
|
expect(plan.source).toBe("stream-end");
|
||||||
|
expect(plan.expectedTotal).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateDownloadedFileCompletion", () => {
|
||||||
|
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
|
||||||
|
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
||||||
|
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
||||||
|
|
||||||
|
// H3 regression: a stream-end download (no Content-Length, no provider size)
|
||||||
|
// that yielded 0 bytes is a FAILED download, not a valid completion.
|
||||||
|
it("rejects a 0-byte stream-end download (H3)", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain("download_underflow");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a non-empty stream-end download", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.totalBytes).toBe(5_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an underflowing content-length download", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a complete content-length download", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 0-byte download even with known provider size", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts provider-metadata download and flags size mismatch", () => {
|
||||||
|
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
||||||
|
// provider-metadata: shorter than expected -> underflow rejected
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user