Fix core download bugs: resume corruption, 416 handling, stream leak, drain timeout
Some checks are pending
Build and Release / build (push) Waiting to run

- HTTP 200 on resume: detect server ignoring Range header, write in truncate mode
  instead of appending (prevents doubled/corrupted files)
- HTTP 416 without Content-Range: assume complete if >1MB exists instead of
  deleting potentially multi-GB finished files
- Stream handle leak: explicit destroy() after finally to prevent fd exhaustion
- Drain timeout: don't abort controller on disk backpressure, let inner retry
  loop handle it instead of escalating to full stall pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-02 15:12:33 +01:00
parent 550942aad7
commit 3ed31b7994
2 changed files with 29 additions and 6 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.88", "version": "1.4.89",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -3926,6 +3926,17 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs(); item.updatedAt = nowMs();
return { resumable: true }; return { resumable: true };
} }
// No total available but we have substantial data - assume file is complete
// This prevents deleting multi-GB files when the server sends 416 without Content-Range
if (!expectedTotal && existingBytes > 1048576) {
logger.warn(`HTTP 416 ohne Größeninfo, ${humanSize(existingBytes)} vorhanden als vollständig behandelt: ${item.fileName}`);
item.totalBytes = existingBytes;
item.downloadedBytes = existingBytes;
item.progressPercent = 100;
item.speedBps = 0;
item.updatedAt = nowMs();
return { resumable: true };
}
try { try {
await fs.promises.rm(effectiveTargetPath, { force: true }); await fs.promises.rm(effectiveTargetPath, { force: true });
@ -3988,6 +3999,13 @@ export class DownloadManager extends EventEmitter {
const resumable = response.status === 206 || acceptRanges; const resumable = response.status === 206 || acceptRanges;
active.resumable = resumable; active.resumable = resumable;
// CRITICAL: If we sent Range header but server responded 200 (not 206),
// it's sending the full file. We MUST write in truncate mode, not append.
const serverIgnoredRange = existingBytes > 0 && response.status === 200;
if (serverIgnoredRange) {
logger.warn(`Server ignorierte Range-Header (HTTP 200 statt 206), starte von vorne: ${item.fileName}`);
}
const rawContentLength = Number(response.headers.get("content-length") || 0); const rawContentLength = Number(response.headers.get("content-length") || 0);
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0; const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range")); const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
@ -3996,7 +4014,8 @@ export class DownloadManager extends EventEmitter {
} else if (totalFromRange) { } else if (totalFromRange) {
item.totalBytes = totalFromRange; item.totalBytes = totalFromRange;
} else if (contentLength > 0) { } else if (contentLength > 0) {
item.totalBytes = existingBytes + contentLength; // Only add existingBytes for 206 responses; for 200 the Content-Length is the full file
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
} }
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w"; const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
@ -4044,10 +4063,10 @@ export class DownloadManager extends EventEmitter {
stream.off("drain", onDrain); stream.off("drain", onDrain);
stream.off("error", onError); stream.off("error", onError);
active.abortController.signal.removeEventListener("abort", onAbort); active.abortController.signal.removeEventListener("abort", onAbort);
if (!active.abortController.signal.aborted) { // Do NOT abort the controller here drain timeout means disk is slow,
active.abortReason = "stall"; // not network stall. Rejecting without abort lets the inner retry loop
active.abortController.abort("stall"); // handle it (resume download) instead of escalating to processItem's
} // stall handler which would re-unrestrict and record provider failures.
reject(new Error("write_drain_timeout")); reject(new Error("write_drain_timeout"));
}, drainTimeoutMs); }, drainTimeoutMs);
@ -4276,6 +4295,10 @@ export class DownloadManager extends EventEmitter {
} }
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`); logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
} }
// Ensure stream is fully destroyed before potential retry opens new handle
if (!stream.destroyed) {
stream.destroy();
}
} }
// Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200). // Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200).