From 6d8ead8598326579a12ec0281a31365a22a4ff7b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 20:53:07 +0100 Subject: [PATCH] Release v1.4.13 with global stall watchdog and freeze recovery --- package-lock.json | 4 +- package.json | 2 +- src/main/download-manager.ts | 73 +++++++++++++++++++++++ tests/download-manager.test.ts | 104 +++++++++++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7ddb15..1e4e69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.12", + "version": "1.4.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.12", + "version": "1.4.13", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 1295f58..adb6371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.12", + "version": "1.4.13", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 93cb5bf..cdce9fd 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -40,6 +40,8 @@ const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000; const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; +const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000; + function getDownloadStallTimeoutMs(): number { const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { @@ -56,6 +58,19 @@ function getDownloadConnectTimeoutMs(): number { return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; } +function getGlobalStallWatchdogTimeoutMs(): number { + const fromEnv = Number(process.env.RD_GLOBAL_STALL_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv)) { + if (fromEnv <= 0) { + return 0; + } + if (fromEnv >= 2000 && fromEnv <= 600000) { + return Math.floor(fromEnv); + } + } + return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS; +} + type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; }; @@ -213,6 +228,10 @@ export class DownloadManager extends EventEmitter { private lastReconnectMarkAt = 0; + private lastGlobalProgressBytes = 0; + + private lastGlobalProgressAt = 0; + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; @@ -1044,6 +1063,8 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.speedEvents = []; this.speedBytesLastWindow = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); this.summary = null; this.persistSoon(); this.emitState(true); @@ -1064,6 +1085,8 @@ export class DownloadManager extends EventEmitter { this.lastReconnectMarkAt = 0; this.speedEvents = []; this.speedBytesLastWindow = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); this.summary = null; this.persistSoon(); this.emitState(true); @@ -1075,6 +1098,8 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); this.abortPostProcessing("stop"); for (const active of this.activeTasks.values()) { active.abortReason = "stop"; @@ -1090,6 +1115,8 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); this.abortPostProcessing("shutdown"); let requeuedItems = 0; @@ -1592,6 +1619,8 @@ export class DownloadManager extends EventEmitter { this.startItem(next.packageId, next.itemId); } + this.runGlobalStallWatchdog(now); + if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) { this.finishRun(); break; @@ -1611,6 +1640,48 @@ export class DownloadManager extends EventEmitter { return this.session.reconnectUntil > nowMs(); } + private runGlobalStallWatchdog(now: number): void { + const timeoutMs = getGlobalStallWatchdogTimeoutMs(); + if (timeoutMs <= 0) { + return; + } + + if (!this.session.running || this.session.paused || this.reconnectActive()) { + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = now; + return; + } + + if (this.session.totalDownloadedBytes !== this.lastGlobalProgressBytes) { + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = now; + return; + } + + if (now - this.lastGlobalProgressAt < timeoutMs) { + return; + } + + const stalled = Array.from(this.activeTasks.values()).filter((active) => { + if (active.abortController.signal.aborted) { + return false; + } + const item = this.session.items[active.itemId]; + return Boolean(item && item.status === "downloading"); + }); + if (stalled.length === 0) { + this.lastGlobalProgressAt = now; + return; + } + + logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalled.length} Task(s) neu starten`); + for (const active of stalled) { + active.abortReason = "stall"; + active.abortController.abort("stall"); + } + this.lastGlobalProgressAt = now; + } + private requestReconnect(reason: string): void { if (!this.settings.autoReconnect) { return; @@ -2757,6 +2828,8 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); + this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; + this.lastGlobalProgressAt = nowMs(); this.persistNow(); this.emitState(); } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index c8a8be2..c2d125f 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -622,6 +622,110 @@ describe("download manager", () => { } }, 35000); + it("recovers via global watchdog when stream hangs without reader timeout", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(240 * 1024, 31); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + const previousGlobalWatchdog = process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "120000"; + process.env.RD_CONNECT_TIMEOUT_MS = "120000"; + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = "2500"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/watchdog-stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + const firstChunk = Math.floor(binary.length / 3); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, firstChunk)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + 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 directUrl = `http://127.0.0.1:${address.port}/watchdog-stall`; + + 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")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "watchdog-stall.bin", + 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: "watchdog-stall", links: ["https://dummy/watchdog-stall"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + if (previousGlobalWatchdog === undefined) { + delete process.env.RD_GLOBAL_STALL_TIMEOUT_MS; + } else { + process.env.RD_GLOBAL_STALL_TIMEOUT_MS = previousGlobalWatchdog; + } + server.close(); + await once(server, "close"); + } + }, 35000); + it("uses content-disposition filename when provider filename is opaque", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);