Fix: stille Mid-File-Korruption beim Resume nach Verbindungsabbruch (known-total) + Rewind-Haertung (Runde-8-Audit)
RANGE-1 (HIGH, adversarisch 3/3): Wenn ein Debrid-Server beim Socket-Abbruch einen Garbage-Block in den Body schreibt UND das im LETZTEN inneren Versuch passiert, wurde der Tail nicht zurueckgespult: der `attempt < maxAttempts`-Guard verhindert das Vormerken, resumeRewindBytesNextAttempt ist funktions-lokal und ueberlebt den downloadToFile-Re-Entry nicht, und der Outer-Handler nimmt fuer terminated-class den generic-retry-Zweig (loescht die Teil-Datei NICHT). Beim Fresh-Link-Resume wurden die echten Bytes NACH dem Garbage angehaengt → eine Datei mit exakt korrekter Laenge, die die reine Laengen-Pruefung (validateDownloadedFileCompletion) besteht; bei manifestlosen .mkv/.mp4 ohne Pruefsumme fiel die Korruption nie auf. Fix: am Exhaustion-Punkt vor dem Throw den verdaechtigen Tail zurueckspulen (truncate auf written - RESUME_REWIND_BYTES, downloadedBytes ZUERST gesetzt, damit der binary-Re-Entry-prealloc-reconcile auch bei fehlgeschlagenem truncate greift). GEGATED auf bekannte Groesse (totalBytes != null && > 0): dann ist rewound size < totalBytes = minBytes, sodass tryFinalizeItemFromDisk die gekuerzte Datei ablehnt und der Re-Entry den Garbage sauber ueberschreibt. Ehrliche Einordnung: behoben fuer Medien mit bekannter Groesse (Debrid liefert fast immer fileSize). Der seltene Fall OHNE Groessenangabe behaelt eine separate, schon vorher bestehende stille Korruption unter dem dokumentierten Size-only-Validation-Blindspot — durch dieses Rueckspulen nicht loesbar (kein Laengensignal; nur ein Hard-Reset wuerde helfen, groesserer separater Eingriff). Bewusst NICHT angefasst, um keinen vierten Bug einzubauen. REWIND-TRUNCATE-FAIL (MED, 3/3): Das `finally` setzte resumeRewindBytesNextAttempt=0 unbedingt — auch wenn die Rewind-truncate warf (transienter win32-EBUSY/AV-Lock) → Garbage-Tail blieb, Flag gecleart, nie erneut versucht. Fix: Reset nur noch im Success-Branch, sodass ein fehlgeschlagenes Rueckspulen im naechsten Versuch erneut probiert wird. PREALLOC-ZEROS-ACCEPTED (MED, schwaechste) dokumentiert nicht gefixt: dominante Medien/Archive sind immun (footprint-threshold 0); narrow non-binary-Conjunction. Advisor vor Implementierung konsultiert (HIGH-Hot-Path); Check A (totalBytes-null finalisiert die gekuerzte Datei) am echten Code verifiziert und der Fix entsprechend auf known-total gegated. Rot-bewiesen: Cross-Call-Integrationstest (final-attempt Garbage-Inject + Drop → Exhaustion → queueRetry → 2. downloadToFile via Fresh-Link → byte-genaue Inhaltsgleichheit). Ohne den Fix ist die Laengen-Assertion gruen und die Inhalts-Assertion rot (exakt-laengen-Korruption) — genau die RANGE-1-Signatur. Volle Suite 885 gruen, tsc unveraendert (6).
This commit is contained in:
parent
0f5accc756
commit
eacd0c9d81
@ -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)}`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Response> => {
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user