Fix core download bugs: resume corruption, 416 handling, stream leak, drain timeout
Some checks are pending
Build and Release / build (push) Waiting to run
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:
parent
550942aad7
commit
3ed31b7994
@ -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",
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user