From 5b4ad99923f70e3cc13acc2d14c60c9cbcfb3db6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 19 Apr 2026 13:30:42 +0200 Subject: [PATCH] Performance: cloneSession shallow refs + scheduler 1-pass + speed obj alloc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three deeper optimizations focused on hot allocations: 1. cloneSession(): items/packages references shared instead of per-item shallow clone. The IPC layer runs structuredClone() in the same tick so the renderer always gets an isolated copy; in-process consumers read snapshots synchronously without mutating. Eliminates ~5000 object allocations per emit on a 5000-item queue. 2. findNextQueuedItem(): single-pass priority scan instead of 3 separate passes (high → normal → low). Returns immediately on high-priority match; collects best normal/low candidate while iterating. Saves up to 2x O(n) iterations per scheduler tick. 3. packageSpeedBps: direct loop assembly instead of Object.fromEntries([...Map].map(...)) (3 allocs per entry → 1). Idle case now returns a stable EMPTY_PACKAGE_SPEED_BPS reference so the renderer's useMemo on it doesn't recompute on every snapshot while the queue is paused/stopped. All 565 tests green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/download-manager.ts | 120 +++++++++++++++++++++-------------- 1 file changed, 72 insertions(+), 48 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 8a5e4ce..ce23534 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -363,21 +363,25 @@ function generateHistoryId(): string { return `hist-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +/** Stable empty object reference reused for snapshots when no package speeds + * are active. Avoids allocating a fresh `{}` per snapshot which breaks + * React.memo()/useMemo dependency comparisons in the renderer. */ +const EMPTY_PACKAGE_SPEED_BPS: Readonly> = Object.freeze({}); + function cloneSession(session: SessionState): SessionState { - const clonedItems: Record = {}; - for (const key of Object.keys(session.items)) { - clonedItems[key] = { ...session.items[key] }; - } - const clonedPackages: Record = {}; - for (const key of Object.keys(session.packages)) { - const pkg = session.packages[key]; - clonedPackages[key] = { ...pkg, itemIds: [...pkg.itemIds] }; - } + // Shallow clone only — items/packages are emitted to the renderer via IPC, + // which runs structuredClone() on the payload in the same event-loop tick + // (so the renderer always gets an isolated deep copy). All in-process + // consumers of getSnapshot() (app-controller, debug-server, link export) + // read the snapshot synchronously without mutating it. Doing a per-item + // shallow clone here was a redundant ~5000 object allocations per emit + // for a 5000-item queue. Cloning only the outer Records keeps consumers + // safe from later additions/removals while avoiding per-item allocation. return { ...session, packageOrder: [...session.packageOrder], - packages: clonedPackages, - items: clonedItems + packages: { ...session.packages }, + items: { ...session.items } }; } @@ -2060,9 +2064,17 @@ export class DownloadManager extends EventEmitter { canPause: this.session.running, clipboardActive: this.settings.clipboardWatch, reconnectSeconds: Math.ceil(reconnectMs / 1000), - packageSpeedBps: !this.session.running || paused ? {} : Object.fromEntries( - [...this.speedBytesPerPackage].map(([pid, bytes]) => [pid, Math.floor(bytes / SPEED_WINDOW_SECONDS)]) - ) + packageSpeedBps: !this.session.running || paused + ? EMPTY_PACKAGE_SPEED_BPS + : (() => { + // Direct loop avoids the [...Map].map().Object.fromEntries() allocation + // chain (3 allocations per entry → 1). + const out: Record = {}; + for (const [pid, bytes] of this.speedBytesPerPackage) { + out[pid] = Math.floor(bytes / SPEED_WINDOW_SECONDS); + } + return out; + })() }; } @@ -7579,44 +7591,56 @@ export class DownloadManager extends EventEmitter { private findNextQueuedItem(): { packageId: string; itemId: string } | null { const now = nowMs(); - const priorityOrder: Array = ["high", "normal", "low"]; - for (const prio of priorityOrder) { - for (const packageId of this.session.packageOrder) { - const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || !pkg.enabled) { - continue; + // Single-pass priority selection: instead of iterating all packages 3 times + // (once per priority tier), iterate once and remember the best + // normal/low candidate found. "high" priority returns immediately. This + // saves up to 2x O(n) passes per scheduler tick on large queues where + // most packages have the default "normal" priority. + let normalCandidate: { packageId: string; itemId: string } | null = null; + let lowCandidate: { packageId: string; itemId: string } | null = null; + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) continue; + if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; + const pkgPrio = pkg.priority || "normal"; + // Once we've found a normal candidate we don't need to scan low-priority + // packages anymore — they would lose anyway. + if (normalCandidate && pkgPrio === "low") continue; + // If we already have a normal candidate and this package is also normal, + // keep scanning anyway (we still need to check if it has a startable + // item — but the first one wins, this is just for correctness). + if (normalCandidate && pkgPrio === "normal") continue; + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item) continue; + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (this.delayPacedStartForItem(item, now)) continue; + if (this.shouldDelayStartForItem(item)) continue; + + const candidate = { packageId, itemId }; + if (pkgPrio === "high") { + if (retryAfter > 0) this.retryAfterByItem.delete(itemId); + return candidate; // highest priority — return immediately } - if ((pkg.priority || "normal") !== prio) { - continue; - } - if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { - continue; - } - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; - if (!item) { - continue; - } - const retryAfter = this.retryAfterByItem.get(itemId) || 0; - if (retryAfter > now) { - continue; - } - if (retryAfter > 0) { - this.retryAfterByItem.delete(itemId); - } - if (item.status === "queued" || item.status === "reconnect_wait") { - if (this.delayPacedStartForItem(item, now)) { - continue; - } - if (this.shouldDelayStartForItem(item)) { - continue; - } - return { packageId, itemId }; - } + if (pkgPrio === "normal") { + normalCandidate = candidate; + } else if (!lowCandidate) { + lowCandidate = candidate; } + break; // stop scanning items in this package } } - return null; + + const chosen = normalCandidate || lowCandidate; + if (chosen) { + const retryAfter = this.retryAfterByItem.get(chosen.itemId) || 0; + if (retryAfter > 0) this.retryAfterByItem.delete(chosen.itemId); + } + return chosen; } /** Single-pass alternative to hasQueuedItems + hasDelayedQueuedItems.