From bece2f3e85a40ee7e6f9a56bdb4b8cf8994d0f1b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 19 Apr 2026 12:32:38 +0200 Subject: [PATCH] Performance: prune long-lived caches, hoist regexes, idle chart redraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three low-risk optimizations that reduce CPU/memory footprint without changing user-visible behavior: 1. Periodic cleanup of unbounded module-level Maps (24/7 stability): - debridLinkKeyCooldowns, debridLinkKeyCooldownDetails, debridLinkKeyRuntimeStatuses (debrid.ts) - megaDebridAccountCooldowns (debrid.ts) - allDebridHostInfoCache (download-manager.ts) - All pruned every 10 min via existing resetStaleRetryState() with a 1h grace window for debugging - Without this, modules accumulated entries indefinitely over days of continuous server operation 2. Hoist regex literals in resolveArchiveItemsFromList() to module scope. Avoids 5 RegExp constructions per call. The function is called per archive set during extraction discovery — adds up on packages with many archives. 3. BandwidthChart: skip the 250ms redraw interval while the session is stopped or paused. The chart renders once on state change to show the latest history, then sleeps until downloads resume. Saves 4 canvas redraws per second when idle (renderer CPU). No functional changes. All 565 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/debrid.ts | 35 ++++++++++++++++++++++++ src/main/download-manager.ts | 53 +++++++++++++++++++++++++++--------- src/renderer/App.tsx | 11 +++++++- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 7e40a73..094f786 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -59,6 +59,28 @@ export function resetDebridLinkRuntimeStateForTests(): void { debridLinkKeyRuntimeStatuses.clear(); } +/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries. + * Without this, module-level Maps grow unbounded over 24/7 operation. + * Removes entries whose cooldown expired more than 1 hour ago. */ +export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number { + let removed = 0; + const grace = 60 * 60 * 1000; // keep 1h grace for debugging + for (const [keyId, until] of debridLinkKeyCooldowns) { + if (until + grace < now) { + debridLinkKeyCooldowns.delete(keyId); + debridLinkKeyCooldownDetails.delete(keyId); + removed += 1; + } + } + for (const [keyId, status] of debridLinkKeyRuntimeStatuses) { + if (!debridLinkKeyCooldowns.has(keyId) && now - status.updatedAt > grace) { + debridLinkKeyRuntimeStatuses.delete(keyId); + removed += 1; + } + } + return removed; +} + export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void { setDebridLinkKeyCooldownState(keyId, cooldownMs, message, "temporary"); } @@ -140,6 +162,19 @@ export function resetMegaDebridRuntimeStateForTests(): void { megaDebridAccountCooldowns.clear(); } +/** Periodic cleanup of expired Mega-Debrid cooldown entries. */ +export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { + let removed = 0; + const grace = 60 * 60 * 1000; + for (const [id, detail] of megaDebridAccountCooldowns) { + if (detail.until + grace < now) { + megaDebridAccountCooldowns.delete(id); + removed += 1; + } + } + return removed; +} + export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldownMs: number, message = "Mega-Debrid Account im Cooldown"): void { setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary"); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 722d242..77bb288 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -51,7 +51,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; @@ -1325,6 +1325,15 @@ export function buildAutoRenameBaseNameFromFoldersWithOptions( return null; } +// Hoisted regex patterns — avoid recompiling on every resolveArchiveItemsFromList() call. +const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; +const ARCHIVE_RAR_RE = /^(.*)\.rar$/; +const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; +const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; +const ARCHIVE_GENERIC_001_RE = /^(.*)\.001$/; +const ARCHIVE_KNOWN_001_RE = /\.(zip|7z)\.001$/; +const REGEX_ESCAPE_RE = /[.*+?^${}()|[\]\\]/g; + export function resolveArchiveItemsFromList(archiveName: string, items: DownloadItem[]): DownloadItem[] { const normalizeArchiveMatchName = (value: string): string => stripDuplicateSuffixBeforeExtension(path.basename(String(value || ""))); @@ -1334,38 +1343,40 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download const itemBaseName = (item: DownloadItem): string => normalizeArchiveMatchName(item.targetPath || item.fileName || ""); - // Try pattern-based matching first (for multipart archives) + // Try pattern-based matching first (for multipart archives). + // Note: the constructed RegExps below depend on the input filename so they + // cannot be hoisted — but the *test* regexes above are now reused. let pattern: RegExp | null = null; - const multipartMatch = entryLower.match(/^(.*)\.part0*1\.rar$/); + const multipartMatch = entryLower.match(ARCHIVE_MULTIPART_RAR_RE); if (multipartMatch) { - const prefix = multipartMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const prefix = multipartMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); pattern = new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"); } if (!pattern) { - const rarMatch = entryLower.match(/^(.*)\.rar$/); + const rarMatch = entryLower.match(ARCHIVE_RAR_RE); if (rarMatch) { - const stem = rarMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stem = rarMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); pattern = new RegExp(`^${stem}\\.r(ar|\\d{2,3})$`, "i"); } } if (!pattern) { - const zipSplitMatch = entryLower.match(/^(.*)\.zip\.001$/); + const zipSplitMatch = entryLower.match(ARCHIVE_ZIP_SPLIT_RE); if (zipSplitMatch) { - const stem = zipSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stem = zipSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); pattern = new RegExp(`^${stem}\\.zip(\\.\\d+)?$`, "i"); } } if (!pattern) { - const sevenSplitMatch = entryLower.match(/^(.*)\.7z\.001$/); + const sevenSplitMatch = entryLower.match(ARCHIVE_7Z_SPLIT_RE); if (sevenSplitMatch) { - const stem = sevenSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stem = sevenSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); pattern = new RegExp(`^${stem}\\.7z(\\.\\d+)?$`, "i"); } } - if (!pattern && /^(.*)\.001$/.test(entryLower) && !/\.(zip|7z)\.001$/.test(entryLower)) { - const genericSplitMatch = entryLower.match(/^(.*)\.001$/); + if (!pattern && ARCHIVE_GENERIC_001_RE.test(entryLower) && !ARCHIVE_KNOWN_001_RE.test(entryLower)) { + const genericSplitMatch = entryLower.match(ARCHIVE_GENERIC_001_RE); if (genericSplitMatch) { - const stem = genericSplitMatch[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const stem = genericSplitMatch[1].replace(REGEX_ESCAPE_RE, "\\$&"); pattern = new RegExp(`^${stem}\\.\\d{3}$`, "i"); } } @@ -7328,6 +7339,22 @@ export class DownloadManager extends EventEmitter { logger.info(`Soft-Reset: Provider-Failures zurückgesetzt für ${provider}`); } } + // Prune AllDebrid host info cache entries older than 5 min (TTL is 60s, + // so 5 min is well past usable - just unbounded growth otherwise). + let allDebridPruned = 0; + for (const [host, entry] of this.allDebridHostInfoCache) { + if (now - entry.cachedAt > 5 * 60 * 1000) { + this.allDebridHostInfoCache.delete(host); + allDebridPruned += 1; + } + } + // Prune expired Debrid-Link / Mega-Debrid runtime state (module-level Maps + // that would otherwise grow over 24/7 operation). + const dlPruned = pruneExpiredDebridLinkRuntimeState(now); + const mdPruned = pruneExpiredMegaDebridRuntimeState(now); + if (allDebridPruned > 0 || dlPruned > 0 || mdPruned > 0) { + logger.info(`Soft-Reset: pruned ${allDebridPruned} AllDebrid host entries, ${dlPruned} Debrid-Link entries, ${mdPruned} Mega-Debrid entries`); + } } // ── Scheduler ────────────────────────────────────────────────────────── diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 31bb49e..9a33990 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1325,11 +1325,20 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp }, [running, paused]); useEffect(() => { + // Always draw once on mount / when running/paused state changes so the + // chart shows the latest history. + drawChart(); + // Only schedule periodic redraws while actively downloading — when + // stopped or paused the speed history doesn't change, so polling + // every 250ms would just burn CPU on the renderer process. + if (!running || paused) { + return; + } const interval = setInterval(() => { drawChart(); }, 250); return () => clearInterval(interval); - }, [drawChart]); + }, [drawChart, running, paused]); useEffect(() => { // Only record samples while the session is running and not paused