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:
parent
cc887eb8a1
commit
147269849d
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.14",
|
||||
"version": "1.4.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.14",
|
||||
"version": "1.4.15",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.4.14",
|
||||
"version": "1.4.15",
|
||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -23,7 +23,7 @@ import { DebridService, MegaWebUnrestrictor } from "./debrid";
|
||||
import { collectArchiveCleanupTargets, extractPackageArchives } from "./extractor";
|
||||
import { validateFileAgainstManifest } from "./integrity";
|
||||
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";
|
||||
|
||||
type ActiveTask = {
|
||||
@ -1309,8 +1309,12 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private persistNow(): void {
|
||||
this.lastPersistAt = nowMs();
|
||||
if (this.session.running) {
|
||||
void saveSessionAsync(this.storagePaths, this.session);
|
||||
} else {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
}
|
||||
|
||||
private emitState(force = false): void {
|
||||
if (force) {
|
||||
@ -1340,12 +1344,17 @@ export class DownloadManager extends EventEmitter {
|
||||
}, emitDelay);
|
||||
}
|
||||
|
||||
private speedEventsHead = 0;
|
||||
|
||||
private pruneSpeedEvents(now: number): void {
|
||||
while (this.speedEvents.length > 0 && this.speedEvents[0].at < now - 3000) {
|
||||
const event = this.speedEvents.shift();
|
||||
if (event) {
|
||||
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - event.bytes);
|
||||
const cutoff = now - 3000;
|
||||
while (this.speedEventsHead < this.speedEvents.length && this.speedEvents[this.speedEventsHead].at < cutoff) {
|
||||
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - this.speedEvents[this.speedEventsHead].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`);
|
||||
for (const active of stalled) {
|
||||
if (active.abortController.signal.aborted) {
|
||||
continue;
|
||||
}
|
||||
active.abortReason = "stall";
|
||||
active.abortController.abort("stall");
|
||||
}
|
||||
@ -2372,7 +2384,7 @@ export class DownloadManager extends EventEmitter {
|
||||
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);
|
||||
if (active.abortController.signal.aborted) {
|
||||
throw new Error(`aborted:${active.abortReason}`);
|
||||
@ -2566,32 +2578,38 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private refreshPackageStatus(pkg: PackageEntry): void {
|
||||
const items = pkg.itemIds
|
||||
.map((itemId) => this.session.items[itemId])
|
||||
.filter(Boolean) as DownloadItem[];
|
||||
if (items.length === 0) {
|
||||
let pending = 0;
|
||||
let success = 0;
|
||||
let failed = 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;
|
||||
}
|
||||
|
||||
const hasPending = items.some((item) => (
|
||||
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) {
|
||||
if (pending > 0) {
|
||||
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||
pkg.updatedAt = nowMs();
|
||||
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) {
|
||||
pkg.status = "failed";
|
||||
} else if (cancelled > 0 && success === 0) {
|
||||
@ -2602,7 +2620,16 @@ export class DownloadManager extends EventEmitter {
|
||||
pkg.updatedAt = nowMs();
|
||||
}
|
||||
|
||||
private cachedSpeedLimitKbps = 0;
|
||||
|
||||
private cachedSpeedLimitAt = 0;
|
||||
|
||||
private getEffectiveSpeedLimitKbps(): number {
|
||||
const now = nowMs();
|
||||
if (now - this.cachedSpeedLimitAt < 2000) {
|
||||
return this.cachedSpeedLimitKbps;
|
||||
}
|
||||
this.cachedSpeedLimitAt = now;
|
||||
const schedules = this.settings.bandwidthSchedules;
|
||||
if (schedules.length > 0) {
|
||||
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;
|
||||
if (inRange) {
|
||||
return entry.speedLimitKbps;
|
||||
this.cachedSpeedLimitKbps = entry.speedLimitKbps;
|
||||
return this.cachedSpeedLimitKbps;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
|
||||
import { defaultSettings } from "./constants";
|
||||
@ -210,3 +211,30 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user