diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 569a7f1..98528e5 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -9633,6 +9633,7 @@ export class DownloadManager extends EventEmitter { await fs.promises.truncate(effectiveTargetPath, resumeStart); existingBytes = resumeStart; item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes); + resumeRewindBytesNextAttempt = 0; logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", { attempt, previousBytes, @@ -9646,8 +9647,6 @@ export class DownloadManager extends EventEmitter { rewindBytes, error: compactErrorText(rewindError) }); - } finally { - resumeRewindBytesNextAttempt = 0; } } else if (resumeRewindBytesNextAttempt > 0) { resumeRewindBytesNextAttempt = 0; @@ -10593,6 +10592,29 @@ export class DownloadManager extends EventEmitter { await sleep(retryDelayWithJitter(attempt, 250)); continue; } + if ( + item.totalBytes != null && item.totalBytes > 0 + && written > existingBytes + && shouldRewindResumeTail(normalizedLastError) + ) { + const rewindTarget = Math.max(0, written - RESUME_REWIND_BYTES); + item.downloadedBytes = Math.min(item.downloadedBytes, rewindTarget); + try { + await fs.promises.truncate(effectiveTargetPath, rewindTarget); + logAttemptEvent("WARN", "Resume-Schutz: letzter Versuch vor Linkerneuerung zurueckgespult", { + attempt, + written, + rewindTarget + }); + } catch (rewindError) { + logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen", { + attempt, + written, + rewindTarget, + error: compactErrorText(rewindError) + }); + } + } if (maxAttemptsBySetting > maxAttempts) { const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:") ? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}` diff --git a/tasks/audit-loop.md b/tasks/audit-loop.md index 650533f..ffca085 100644 --- a/tasks/audit-loop.md +++ b/tasks/audit-loop.md @@ -179,6 +179,37 @@ Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/2 Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13 benign) NICHT released — dokumentiert. +## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq +3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted. +Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt. +- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch + wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht), + resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler + nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt + echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei + manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte + RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei + truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound + size < totalBytes=minBytes → tryFinalizeItemFromDisk(9238) REJECTET → Re-Entry ueberschreibt Garbage. + EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize); + **null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten + Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde + helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion → + queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot. +- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0 + UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den + Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind + wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113 + + RANGE-1-Test abgedeckt. +- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc- + Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt. + Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/ + truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred. +- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed. +- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame + Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT + umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item. + ## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i 3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab. - **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die diff --git a/tasks/entscheidungen-offen.md b/tasks/entscheidungen-offen.md index 3d468e3..276a617 100644 --- a/tasks/entscheidungen-offen.md +++ b/tasks/entscheidungen-offen.md @@ -63,6 +63,20 @@ ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration n ## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf) +- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):** + Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und + das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende + Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die + richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme + fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der + Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge). + Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der + Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke + (ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema. +- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird + jetzt im nächsten Versuch erneut probiert statt still übergangen. + + - **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 171d092..9db5a8c 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1218,6 +1218,110 @@ describe("download manager", () => { } }); + it("does not embed a final-attempt garbage tail across a fresh-link resume (known-total content integrity)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-range1-")); + tempDirs.push(root); + const binary = Buffer.alloc(1024 * 1024, 0); + for (let i = 0; i < binary.length; i += 1) { + binary[i] = (i * 31 + 7) & 0xff; + } + const firstChunkBytes = 128 * 1024; + const injectedErrorChunk = Buffer.from("{\"error\":\"Missed session after 2000 ms\",\"success\":false}", "utf8"); + let unrestrictCalls = 0; + + const serveRange = (req: http.IncomingMessage): { start: number } => { + const range = String(req.headers.range || ""); + const m = range.match(/bytes=(\d+)-/i); + return { start: m ? Number(m[1]) : 0 }; + }; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + const { start } = serveRange(req); + if (route.startsWith("/corrupt")) { + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length - start)); + res.write(binary.subarray(start, Math.min(binary.length, start + firstChunkBytes))); + res.write(injectedErrorChunk); + setTimeout(() => { + try { res.socket?.destroy(); } catch { } + }, 60); + return; + } + if (route.startsWith("/clean")) { + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const base = `http://127.0.0.1:${address.port}`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + const target = unrestrictCalls === 1 ? `${base}/corrupt` : `${base}/clean`; + return new Response( + JSON.stringify({ download: target, filename: "range1.mkv", filesize: binary.length }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "range1", links: ["https://dummy/range1"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + const finalBytes = fs.readFileSync(item.targetPath); + expect(finalBytes.length).toBe(binary.length); + expect(finalBytes.equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("requests a fresh direct link after repeated same-link download failures", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);