Fix app freezes and false provider cooldowns
- Make saveSettings async to stop blocking the event loop during downloads - Add 120ms minimum gap for forced state emissions to prevent rapid-fire IPC - Fix circuit breaker feedback loop: reset failure count after cooldown expires - Add 120s time-decay for failure counter (transient bursts don't snowball) - Raise circuit breaker threshold from 5 to 8 consecutive failures - Stop counting network stalls as provider failures - Items without a provider only check primary provider cooldown, not all Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b73ea1386
commit
e90e731eaa
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.5.18",
|
"version": "1.5.19",
|
||||||
"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, findArchiveCandidates } from "./extractor";
|
import { collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { StoragePaths, saveSession, saveSessionAsync, saveSettings } from "./storage";
|
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } 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 = {
|
||||||
@ -725,6 +725,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private nonResumableActive = 0;
|
private nonResumableActive = 0;
|
||||||
|
|
||||||
private stateEmitTimer: NodeJS.Timeout | null = null;
|
private stateEmitTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastStateEmitAt = 0;
|
||||||
|
|
||||||
private speedBytesLastWindow = 0;
|
private speedBytesLastWindow = 0;
|
||||||
|
|
||||||
@ -2632,17 +2633,32 @@ export class DownloadManager extends EventEmitter {
|
|||||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
||||||
if (now - this.lastSettingsPersistAt >= 30000) {
|
if (now - this.lastSettingsPersistAt >= 30000) {
|
||||||
this.lastSettingsPersistAt = now;
|
this.lastSettingsPersistAt = now;
|
||||||
try { saveSettings(this.storagePaths, this.settings); } catch (err) { logger.warn(`saveSettings Fehler: ${compactErrorText(err as Error)}`); }
|
void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler: ${compactErrorText(err as Error)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitState(force = false): void {
|
private emitState(force = false): void {
|
||||||
|
const now = nowMs();
|
||||||
|
const MIN_FORCE_GAP_MS = 120;
|
||||||
if (force) {
|
if (force) {
|
||||||
if (this.stateEmitTimer) {
|
const sinceLastEmit = now - this.lastStateEmitAt;
|
||||||
clearTimeout(this.stateEmitTimer);
|
if (sinceLastEmit >= MIN_FORCE_GAP_MS) {
|
||||||
this.stateEmitTimer = null;
|
if (this.stateEmitTimer) {
|
||||||
|
clearTimeout(this.stateEmitTimer);
|
||||||
|
this.stateEmitTimer = null;
|
||||||
|
}
|
||||||
|
this.lastStateEmitAt = now;
|
||||||
|
this.emit("state", this.getSnapshot());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Too soon — schedule deferred forced emit
|
||||||
|
if (!this.stateEmitTimer) {
|
||||||
|
this.stateEmitTimer = setTimeout(() => {
|
||||||
|
this.stateEmitTimer = null;
|
||||||
|
this.lastStateEmitAt = nowMs();
|
||||||
|
this.emit("state", this.getSnapshot());
|
||||||
|
}, MIN_FORCE_GAP_MS - sinceLastEmit);
|
||||||
}
|
}
|
||||||
this.emit("state", this.getSnapshot());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.stateEmitTimer) {
|
if (this.stateEmitTimer) {
|
||||||
@ -2660,6 +2676,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
: 260;
|
: 260;
|
||||||
this.stateEmitTimer = setTimeout(() => {
|
this.stateEmitTimer = setTimeout(() => {
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
|
this.lastStateEmitAt = nowMs();
|
||||||
this.emit("state", this.getSnapshot());
|
this.emit("state", this.getSnapshot());
|
||||||
}, emitDelay);
|
}, emitDelay);
|
||||||
}
|
}
|
||||||
@ -3023,11 +3040,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private recordProviderFailure(provider: string): void {
|
private recordProviderFailure(provider: string): void {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const entry = this.providerFailures.get(provider) || { count: 0, lastFailAt: 0, cooldownUntil: 0 };
|
const entry = this.providerFailures.get(provider) || { count: 0, lastFailAt: 0, cooldownUntil: 0 };
|
||||||
|
// Decay: if last failure was >120s ago, reset count (transient burst is over)
|
||||||
|
if (entry.lastFailAt > 0 && now - entry.lastFailAt > 120000) {
|
||||||
|
entry.count = 0;
|
||||||
|
}
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
entry.lastFailAt = now;
|
entry.lastFailAt = now;
|
||||||
// Escalating cooldown: 5 failures→30s, 10→60s, 15→120s, 20+→300s
|
// Escalating cooldown: 8 failures→30s, 15→60s, 25→120s, 40+→300s
|
||||||
if (entry.count >= 5) {
|
if (entry.count >= 8) {
|
||||||
const tier = Math.min(Math.floor((entry.count - 5) / 5), 3);
|
const tier = entry.count >= 40 ? 3 : entry.count >= 25 ? 2 : entry.count >= 15 ? 1 : 0;
|
||||||
const cooldownMs = [30000, 60000, 120000, 300000][tier];
|
const cooldownMs = [30000, 60000, 120000, 300000][tier];
|
||||||
entry.cooldownUntil = now + cooldownMs;
|
entry.cooldownUntil = now + cooldownMs;
|
||||||
logger.warn(`Provider Circuit-Breaker: ${provider} ${entry.count} konsekutive Fehler, Cooldown ${cooldownMs / 1000}s`);
|
logger.warn(`Provider Circuit-Breaker: ${provider} ${entry.count} konsekutive Fehler, Cooldown ${cooldownMs / 1000}s`);
|
||||||
@ -3053,7 +3074,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const remaining = entry.cooldownUntil - nowMs();
|
const remaining = entry.cooldownUntil - nowMs();
|
||||||
return remaining > 0 ? remaining : 0;
|
if (remaining <= 0) {
|
||||||
|
// Cooldown expired — reset count so a single new failure doesn't re-trigger
|
||||||
|
entry.count = 0;
|
||||||
|
entry.cooldownUntil = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetStaleRetryState(): void {
|
private resetStaleRetryState(): void {
|
||||||
@ -3485,16 +3512,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
// Check provider cooldown before attempting unrestrict
|
// Check provider cooldown before attempting unrestrict
|
||||||
const lastProvider = item.provider || "";
|
const lastProvider = item.provider || "";
|
||||||
const cooldownProviders = lastProvider ? [lastProvider] : ["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "unknown"];
|
const cooldownProvider = lastProvider || this.settings.providerPrimary || "unknown";
|
||||||
let maxCooldownMs = 0;
|
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
||||||
for (const prov of cooldownProviders) {
|
if (cooldownMs > 0) {
|
||||||
const cd = this.getProviderCooldownRemaining(prov);
|
const delayMs = Math.min(cooldownMs + 1000, 310000);
|
||||||
if (cd > maxCooldownMs) {
|
|
||||||
maxCooldownMs = cd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (maxCooldownMs > 0) {
|
|
||||||
const delayMs = Math.min(maxCooldownMs + 1000, 310000);
|
|
||||||
this.queueRetry(item, active, delayMs, `Provider-Cooldown (${Math.ceil(delayMs / 1000)}s)`);
|
this.queueRetry(item, active, delayMs, `Provider-Cooldown (${Math.ceil(delayMs / 1000)}s)`);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
@ -3729,10 +3750,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const isSlowThroughput = stallErrorText.includes("slow_throughput");
|
const isSlowThroughput = stallErrorText.includes("slow_throughput");
|
||||||
const wasValidating = item.status === "validating";
|
const wasValidating = item.status === "validating";
|
||||||
active.stallRetries += 1;
|
active.stallRetries += 1;
|
||||||
// Record provider failure if stall during validation
|
|
||||||
if (wasValidating && item.provider) {
|
|
||||||
this.recordProviderFailure(item.provider);
|
|
||||||
}
|
|
||||||
logger.warn(`Stall erkannt: item=${item.fileName || item.id}, phase=${wasValidating ? "validating" : "downloading"}, retry=${active.stallRetries}/${retryDisplayLimit}, bytes=${item.downloadedBytes}, error=${stallErrorText || "none"}, provider=${item.provider || "?"}`);
|
logger.warn(`Stall erkannt: item=${item.fileName || item.id}, phase=${wasValidating ? "validating" : "downloading"}, retry=${active.stallRetries}/${retryDisplayLimit}, bytes=${item.downloadedBytes}, error=${stallErrorText || "none"}, provider=${item.provider || "?"}`);
|
||||||
// Shelve check: too many consecutive failures → long pause
|
// Shelve check: too many consecutive failures → long pause
|
||||||
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
||||||
|
|||||||
@ -414,6 +414,48 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
|||||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let asyncSettingsSaveRunning = false;
|
||||||
|
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string } | null = null;
|
||||||
|
|
||||||
|
async function writeSettingsPayload(paths: StoragePaths, payload: string): Promise<void> {
|
||||||
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||||
|
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
||||||
|
const tempPath = `${paths.configFile}.settings.tmp`;
|
||||||
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
|
try {
|
||||||
|
await fsp.rename(tempPath, paths.configFile);
|
||||||
|
} catch (renameError: unknown) {
|
||||||
|
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||||
|
await fsp.copyFile(tempPath, paths.configFile);
|
||||||
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
throw renameError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||||
|
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||||
|
const payload = JSON.stringify(persisted, null, 2);
|
||||||
|
if (asyncSettingsSaveRunning) {
|
||||||
|
asyncSettingsSaveQueued = { paths, payload };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
asyncSettingsSaveRunning = true;
|
||||||
|
try {
|
||||||
|
await writeSettingsPayload(paths, payload);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
||||||
|
} finally {
|
||||||
|
asyncSettingsSaveRunning = false;
|
||||||
|
if (asyncSettingsSaveQueued) {
|
||||||
|
const queued = asyncSettingsSaveQueued;
|
||||||
|
asyncSettingsSaveQueued = null;
|
||||||
|
void writeSettingsPayload(queued.paths, queued.payload).catch((err) => logger.error(`Async Settings-Save (queued) fehlgeschlagen: ${String(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function emptySession(): SessionState {
|
export function emptySession(): SessionState {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user