Release v1.4.13 with global stall watchdog and freeze recovery

This commit is contained in:
Sucukdeluxe 2026-02-27 20:53:07 +01:00
parent 0f85cd4c8d
commit 6d8ead8598
4 changed files with 180 additions and 3 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.12", "version": "1.4.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.12", "version": "1.4.13",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.12", "version": "1.4.13",
"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

@ -40,6 +40,8 @@ const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000;
const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
function getDownloadStallTimeoutMs(): number { function getDownloadStallTimeoutMs(): number {
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
@ -56,6 +58,19 @@ function getDownloadConnectTimeoutMs(): number {
return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; 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 = { type DownloadManagerOptions = {
megaWebUnrestrict?: MegaWebUnrestrictor; megaWebUnrestrict?: MegaWebUnrestrictor;
}; };
@ -213,6 +228,10 @@ export class DownloadManager extends EventEmitter {
private lastReconnectMarkAt = 0; private lastReconnectMarkAt = 0;
private lastGlobalProgressBytes = 0;
private lastGlobalProgressAt = 0;
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super(); super();
this.settings = settings; this.settings = settings;
@ -1044,6 +1063,8 @@ export class DownloadManager extends EventEmitter {
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.speedEvents = []; this.speedEvents = [];
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.summary = null; this.summary = null;
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
@ -1064,6 +1085,8 @@ export class DownloadManager extends EventEmitter {
this.lastReconnectMarkAt = 0; this.lastReconnectMarkAt = 0;
this.speedEvents = []; this.speedEvents = [];
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.summary = null; this.summary = null;
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
@ -1075,6 +1098,8 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("stop"); this.abortPostProcessing("stop");
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
active.abortReason = "stop"; active.abortReason = "stop";
@ -1090,6 +1115,8 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("shutdown"); this.abortPostProcessing("shutdown");
let requeuedItems = 0; let requeuedItems = 0;
@ -1592,6 +1619,8 @@ export class DownloadManager extends EventEmitter {
this.startItem(next.packageId, next.itemId); this.startItem(next.packageId, next.itemId);
} }
this.runGlobalStallWatchdog(now);
if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) { if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) {
this.finishRun(); this.finishRun();
break; break;
@ -1611,6 +1640,48 @@ export class DownloadManager extends EventEmitter {
return this.session.reconnectUntil > nowMs(); 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 { private requestReconnect(reason: string): void {
if (!this.settings.autoReconnect) { if (!this.settings.autoReconnect) {
return; return;
@ -2757,6 +2828,8 @@ export class DownloadManager extends EventEmitter {
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.persistNow(); this.persistNow();
this.emitState(); this.emitState();
} }

View File

@ -622,6 +622,110 @@ describe("download manager", () => {
} }
}, 35000); }, 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<Response> => {
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 () => { it("uses content-disposition filename when provider filename is opaque", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);