Release v1.4.15 with deep performance optimizations and crash prevention

- Convert session persistence to async during downloads (prevents 50-200ms UI freezes)
- Avoid unnecessary Buffer.from() copy on Uint8Array chunks (zero-copy when possible)
- Cache effective speed limit for 2s (eliminates Date object creation per chunk)
- Replace O(n) speed event shift() with pointer-based pruning
- Throttle speed event pruning to every 1.5s instead of per chunk
- Optimize refreshPackageStatus to single-loop counting (was 4 separate filter passes)
- Fix global stall watchdog race condition (re-check abort state before aborting)
- Add coalescing for async session saves (prevents write storms)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-27 21:35:05 +01:00
parent cc887eb8a1
commit 147269849d
4 changed files with 89 additions and 31 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.14", "version": "1.4.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.14", "version": "1.4.15",
"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.14", "version": "1.4.15",
"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

@ -23,7 +23,7 @@ import { DebridService, MegaWebUnrestrictor } from "./debrid";
import { collectArchiveCleanupTargets, extractPackageArchives } from "./extractor"; import { collectArchiveCleanupTargets, extractPackageArchives } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger"; import { logger } from "./logger";
import { StoragePaths, saveSession } from "./storage"; import { StoragePaths, saveSession, saveSessionAsync } from "./storage";
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils";
type ActiveTask = { type ActiveTask = {
@ -1309,8 +1309,12 @@ export class DownloadManager extends EventEmitter {
private persistNow(): void { private persistNow(): void {
this.lastPersistAt = nowMs(); this.lastPersistAt = nowMs();
if (this.session.running) {
void saveSessionAsync(this.storagePaths, this.session);
} else {
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
} }
}
private emitState(force = false): void { private emitState(force = false): void {
if (force) { if (force) {
@ -1340,12 +1344,17 @@ export class DownloadManager extends EventEmitter {
}, emitDelay); }, emitDelay);
} }
private speedEventsHead = 0;
private pruneSpeedEvents(now: number): void { private pruneSpeedEvents(now: number): void {
while (this.speedEvents.length > 0 && this.speedEvents[0].at < now - 3000) { const cutoff = now - 3000;
const event = this.speedEvents.shift(); while (this.speedEventsHead < this.speedEvents.length && this.speedEvents[this.speedEventsHead].at < cutoff) {
if (event) { this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - this.speedEvents[this.speedEventsHead].bytes);
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - event.bytes); this.speedEventsHead += 1;
} }
if (this.speedEventsHead > 50) {
this.speedEvents = this.speedEvents.slice(this.speedEventsHead);
this.speedEventsHead = 0;
} }
} }
@ -1681,6 +1690,9 @@ export class DownloadManager extends EventEmitter {
logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalled.length} Task(s) neu starten`); 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) { for (const active of stalled) {
if (active.abortController.signal.aborted) {
continue;
}
active.abortReason = "stall"; active.abortReason = "stall";
active.abortController.abort("stall"); active.abortController.abort("stall");
} }
@ -2372,7 +2384,7 @@ export class DownloadManager extends EventEmitter {
throw new Error("aborted:reconnect"); throw new Error("aborted:reconnect");
} }
const buffer = Buffer.from(chunk); const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`); throw new Error(`aborted:${active.abortReason}`);
@ -2566,32 +2578,38 @@ export class DownloadManager extends EventEmitter {
} }
private refreshPackageStatus(pkg: PackageEntry): void { private refreshPackageStatus(pkg: PackageEntry): void {
const items = pkg.itemIds let pending = 0;
.map((itemId) => this.session.items[itemId]) let success = 0;
.filter(Boolean) as DownloadItem[]; let failed = 0;
if (items.length === 0) { let cancelled = 0;
let total = 0;
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
total += 1;
const s = item.status;
if (s === "completed") {
success += 1;
} else if (s === "failed") {
failed += 1;
} else if (s === "cancelled") {
cancelled += 1;
} else {
pending += 1;
}
}
if (total === 0) {
return; return;
} }
const hasPending = items.some((item) => ( if (pending > 0) {
item.status === "queued"
|| item.status === "reconnect_wait"
|| item.status === "validating"
|| item.status === "downloading"
|| item.status === "paused"
|| item.status === "extracting"
|| item.status === "integrity_check"
));
if (hasPending) {
pkg.status = pkg.enabled ? "queued" : "paused"; pkg.status = pkg.enabled ? "queued" : "paused";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
return; return;
} }
const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length;
if (failed > 0) { if (failed > 0) {
pkg.status = "failed"; pkg.status = "failed";
} else if (cancelled > 0 && success === 0) { } else if (cancelled > 0 && success === 0) {
@ -2602,7 +2620,16 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
} }
private cachedSpeedLimitKbps = 0;
private cachedSpeedLimitAt = 0;
private getEffectiveSpeedLimitKbps(): number { private getEffectiveSpeedLimitKbps(): number {
const now = nowMs();
if (now - this.cachedSpeedLimitAt < 2000) {
return this.cachedSpeedLimitKbps;
}
this.cachedSpeedLimitAt = now;
const schedules = this.settings.bandwidthSchedules; const schedules = this.settings.bandwidthSchedules;
if (schedules.length > 0) { if (schedules.length > 0) {
const hour = new Date().getHours(); const hour = new Date().getHours();
@ -2615,13 +2642,16 @@ export class DownloadManager extends EventEmitter {
? hour >= entry.startHour || hour < entry.endHour ? hour >= entry.startHour || hour < entry.endHour
: hour >= entry.startHour && hour < entry.endHour; : hour >= entry.startHour && hour < entry.endHour;
if (inRange) { if (inRange) {
return entry.speedLimitKbps; this.cachedSpeedLimitKbps = entry.speedLimitKbps;
return this.cachedSpeedLimitKbps;
} }
} }
} }
if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) { if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) {
return this.settings.speedLimitKbps; this.cachedSpeedLimitKbps = this.settings.speedLimitKbps;
return this.cachedSpeedLimitKbps;
} }
this.cachedSpeedLimitKbps = 0;
return 0; return 0;
} }

View File

@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types"; import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
import { defaultSettings } from "./constants"; import { defaultSettings } from "./constants";
@ -210,3 +211,30 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
fs.renameSync(tempPath, paths.sessionFile); fs.renameSync(tempPath, paths.sessionFile);
} }
let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; session: SessionState } | null = null;
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
if (asyncSaveRunning) {
asyncSaveQueued = { paths, session };
return;
}
asyncSaveRunning = true;
try {
await fs.promises.mkdir(paths.baseDir, { recursive: true });
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`;
await fsp.writeFile(tempPath, payload, "utf8");
await fsp.rename(tempPath, paths.sessionFile);
} catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally {
asyncSaveRunning = false;
if (asyncSaveQueued) {
const queued = asyncSaveQueued;
asyncSaveQueued = null;
void saveSessionAsync(queued.paths, queued.session);
}
}
}