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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user