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",
"version": "1.4.88",
"version": "1.4.89",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -3926,6 +3926,17 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs();
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 {
await fs.promises.rm(effectiveTargetPath, { force: true });
@ -3988,6 +3999,13 @@ export class DownloadManager extends EventEmitter {
const resumable = response.status === 206 || acceptRanges;
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 contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
@ -3996,7 +4014,8 @@ export class DownloadManager extends EventEmitter {
} else if (totalFromRange) {
item.totalBytes = totalFromRange;
} 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";
@ -4044,10 +4063,10 @@ export class DownloadManager extends EventEmitter {
stream.off("drain", onDrain);
stream.off("error", onError);
active.abortController.signal.removeEventListener("abort", onAbort);
if (!active.abortController.signal.aborted) {
active.abortReason = "stall";
active.abortController.abort("stall");
}
// Do NOT abort the controller here drain timeout means disk is slow,
// not network stall. Rejecting without abort lets the inner retry loop
// 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"));
}, drainTimeoutMs);
@ -4276,6 +4295,10 @@ export class DownloadManager extends EventEmitter {
}
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).