From a95e4fb0881144d6dce64b89453dd0854fe77e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:54:57 +0200 Subject: [PATCH 01/11] perf(extensions): cap manifest/bundle caches with LRU eviction _extensionManifestCache and _extensionBundleCodeCache were plain Maps with no size limit, so a long session opening many distinct extensions accumulated their full bundled text in memory indefinitely. Cap both at a bounded LRU (200 manifests, 40 bundles). --- src/main/extension-runner.ts | 48 ++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 2a275382..fa720ecc 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -144,9 +144,53 @@ interface CachedTextFile { value: string; } +/** + * Map-backed LRU cache with a hard size cap. Reads move the key to the + * most-recently-used position; inserts past the cap evict the oldest entry. + * Keeps long sessions that open many distinct extensions from accumulating + * unbounded bundle/manifest text in memory. + */ +class LruCache { + private readonly map = new Map(); + + constructor(private readonly maxEntries: number) {} + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: K, value: V): void { + this.map.delete(key); + this.map.set(key, value); + if (this.map.size > this.maxEntries) { + const oldestKey = this.map.keys().next().value as K; + this.map.delete(oldestKey); + } + } + + delete(key: K): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + keys(): IterableIterator { + return this.map.keys(); + } +} + +const MAX_EXTENSION_MANIFEST_CACHE_ENTRIES = 200; +const MAX_EXTENSION_BUNDLE_CACHE_ENTRIES = 40; + let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null; -const _extensionManifestCache = new Map(); -const _extensionBundleCodeCache = new Map(); +const _extensionManifestCache = new LruCache(MAX_EXTENSION_MANIFEST_CACHE_ENTRIES); +const _extensionBundleCodeCache = new LruCache(MAX_EXTENSION_BUNDLE_CACHE_ENTRIES); function getManagedExtensionsDir(): string { const dir = path.join(app.getPath('userData'), 'extensions'); From a3dfd28209ced6603c6838b1e2c8a6fc2be5d227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:56:40 +0200 Subject: [PATCH 02/11] perf(main): stop pre-warming window-manager-worker at startup callWindowManagerWorker() already forks the worker lazily on first use via ensureWindowManagerWorker(). Most sessions never touch window management, so eagerly forking a full Node child process at app.whenReady() just to save latency on a feature most users never hit isn't worth the constant RAM cost. --- src/main/main.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..9a9b7288 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13605,8 +13605,9 @@ app.whenReady().then(async () => { trackEvent("app_started"); app.setAsDefaultProtocolClient('supercmd'); scrubInternalClipboardProbe('app startup'); - // Warm the worker so the first window-management action does not race spawn. - setTimeout(() => { ensureWindowManagerWorker(); }, 0); + // Worker is forked lazily by callWindowManagerWorker() on first actual use + // (ensureWindowManagerWorker() inside sendAttempt) — most sessions never + // touch window management, so we no longer pre-warm it at startup. // Some external image hosts (e.g. libgen.bz, libgen.li, libgen.is) only // serve covers when a Referer header is present — without one they return From 1922a7d029fc4b7351cb87be0f5eb14a620a534b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:57:22 +0200 Subject: [PATCH 03/11] perf(main): auto-shutdown idle transcription servers after 10 minutes The parakeet/qwen3/whisper.cpp persistent serve-mode processes keep a model loaded in memory for fast repeat transcription, but previously stayed alive until the app quit once started even once. Add a shared idle-shutdown scheduler that kills each one after 10 minutes without use, reset on every real invocation. --- src/main/main.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/main/main.ts b/src/main/main.ts index 9a9b7288..8452b2c2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -257,6 +257,25 @@ type ParakeetModelStatus = { let parakeetModelStatus: ParakeetModelStatus | null = null; let parakeetModelEnsurePromise: Promise | null = null; +// Persistent transcription servers (parakeet/qwen3/whisper.cpp) keep a model +// loaded in memory for fast repeat use, but otherwise sit idle for the rest +// of the session once used once. Auto-kill them after a period of no use so +// they don't hold their memory footprint until the app quits. +const AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS = 10 * 60_000; // 10 minutes + +function createIdleShutdownScheduler(killFn: () => void, idleMs: number): { bump: () => void } { + let timer: ReturnType | null = null; + return { + bump(): void { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + killFn(); + }, idleMs); + }, + }; +} + // Persistent serve-mode process for fast transcription (models stay loaded in memory) let parakeetServerProcess: any = null; // ChildProcess let parakeetServerReady = false; @@ -264,6 +283,10 @@ let parakeetServerStarting: Promise | null = null; let parakeetServerBuffer = ''; type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let parakeetPendingRequest: PendingParakeetRequest | null = null; +const parakeetIdleShutdown = createIdleShutdownScheduler( + () => killParakeetServer(), + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS +); function killParakeetServer(): void { if (parakeetServerProcess) { @@ -283,6 +306,7 @@ function killParakeetServer(): void { } function ensureParakeetServer(): Promise { + parakeetIdleShutdown.bump(); if (parakeetServerReady && parakeetServerProcess && !parakeetServerProcess.killed) { return Promise.resolve(); } @@ -619,6 +643,10 @@ let qwen3ServerStarting: Promise | null = null; let qwen3ServerBuffer = ''; type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void }; let qwen3PendingRequest: PendingQwen3Request | null = null; +const qwen3IdleShutdown = createIdleShutdownScheduler( + () => killQwen3Server(), + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS +); function killQwen3Server(): void { if (qwen3ServerProcess) { @@ -638,6 +666,7 @@ function killQwen3Server(): void { } function ensureQwen3Server(): Promise { + qwen3IdleShutdown.bump(); if (qwen3ServerReady && qwen3ServerProcess && !qwen3ServerProcess.killed) { return Promise.resolve(); } @@ -1153,6 +1182,10 @@ let whisperCppServerStarting: Promise | null = null; let whisperCppServerBuffer = ''; type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let whisperCppPendingRequest: PendingWhisperCppRequest | null = null; +const whisperCppIdleShutdown = createIdleShutdownScheduler( + () => killWhisperCppServer(), + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS +); function killWhisperCppServer(): void { if (whisperCppServerProcess) { @@ -1172,6 +1205,7 @@ function killWhisperCppServer(): void { } function ensureWhisperCppServer(): Promise { + whisperCppIdleShutdown.bump(); if (whisperCppServerReady && whisperCppServerProcess && !whisperCppServerProcess.killed) { return Promise.resolve(); } From c63e3e7b78510da1637e90427d99d3c2f14f6e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:58:00 +0200 Subject: [PATCH 04/11] perf(main): make extension discovery and commands cache non-blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit discoverInstalledExtensionCommands() now yields to the event loop every 5 extensions instead of scanning the whole install in one uninterrupted synchronous burst, and initCommandsCache()/loadCommandsDiskCache() move to fs.promises with icon re-attachment done concurrently via Promise.all instead of one fs.readFileSync per command — both run right before createWindow() on every launch. --- scripts/measure-extension-bundle-cache.mjs | 3 +- src/main/commands.ts | 35 ++++++++++++++-------- src/main/extension-runner.ts | 15 ++++++++-- src/main/main.ts | 4 +-- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs index 695347ae..bfc3a2f4 100644 --- a/scripts/measure-extension-bundle-cache.mjs +++ b/scripts/measure-extension-bundle-cache.mjs @@ -229,8 +229,7 @@ async function main() { const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => { for (let i = 0; i < iterations; i++) { - const commands = runner - .discoverInstalledExtensionCommands() + const commands = (await runner.discoverInstalledExtensionCommands()) .filter((command) => command.mode === 'menu-bar'); assert.equal(commands.length, 1); const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName); diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..9c1e0e80 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -129,19 +129,21 @@ function getCommandsDiskCachePath(): string { return commandsDiskCachePath; } -function loadCommandsDiskCache(): CommandInfo[] | null { +async function loadCommandsDiskCache(): Promise { try { - const raw = fs.readFileSync(getCommandsDiskCachePath(), 'utf-8'); + const raw = await fs.promises.readFile(getCommandsDiskCachePath(), 'utf-8'); const parsed = JSON.parse(raw) as { version: number; commands: CommandInfo[] }; if (parsed?.version !== COMMANDS_DISK_CACHE_VERSION) return null; const cmds = parsed.commands; - // Re-attach icons from the per-app icon disk cache (fast file reads). - for (const cmd of cmds) { - if (cmd.path) { - const icon = getCachedIcon(cmd.path); - if (icon) cmd.iconDataUrl = icon; - } - } + // Re-attach icons from the per-app icon disk cache. Reading them + // concurrently (fs.promises, backed by libuv's threadpool) instead of one + // fs.readFileSync per command avoids serializing every icon read on the + // main thread right before the first window is created. + await Promise.all(cmds.map(async (cmd) => { + if (!cmd.path) return; + const icon = await getCachedIconAsync(cmd.path); + if (icon) cmd.iconDataUrl = icon; + })); return cmds; } catch { return null; @@ -163,8 +165,8 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void { } /** Call once after app.whenReady() to pre-populate the in-memory cache from disk. */ -export function initCommandsCache(): void { - const cmds = loadCommandsDiskCache(); +export async function initCommandsCache(): Promise { + const cmds = await loadCommandsDiskCache(); if (cmds) { cachedCommands = cmds; staleCommandsFallback = cmds; @@ -207,6 +209,15 @@ function getCachedIcon(bundlePath: string): string | undefined { return undefined; } +async function getCachedIconAsync(bundlePath: string): Promise { + try { + const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`); + return await fs.promises.readFile(cacheFile, 'utf-8'); + } catch { + return undefined; + } +} + function setCachedIcon(bundlePath: string, dataUrl: string): void { try { const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`); @@ -1849,7 +1860,7 @@ async function discoverAndBuildCommands(): Promise { // Installed community extensions let extensionCommands: CommandInfo[] = []; try { - extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({ + extensionCommands = (await discoverInstalledExtensionCommands()).map((ext) => ({ id: ext.id, title: ext.title, subtitle: ext.extensionTitle, diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index fa720ecc..050a749a 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -541,13 +541,24 @@ function normalizePreferenceSchema(pref: any, scope: 'extension' | 'command'): E // ─── Discovery ────────────────────────────────────────────────────── +const DISCOVERY_YIELD_EVERY_N_EXTENSIONS = 5; + /** * Scan installed extensions directory and return a flat list of * commands that should appear in the launcher. + * + * Yields back to the event loop every few extensions so a large install + * (many manifests + icon reads, all sync fs calls) doesn't monopolize the + * single main-process thread for the whole scan in one uninterrupted burst. */ -export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] { +export async function discoverInstalledExtensionCommands(): Promise { const results: ExtensionCommandInfo[] = []; - for (const source of collectInstalledExtensions()) { + const sources = collectInstalledExtensions(); + for (let i = 0; i < sources.length; i++) { + if (i > 0 && i % DISCOVERY_YIELD_EVERY_N_EXTENSIONS === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + const source = sources[i]; const extPath = source.extPath; const extName = source.extName; diff --git a/src/main/main.ts b/src/main/main.ts index 8452b2c2..9ba70a1f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19009,7 +19009,7 @@ if let tiff = image?.tiffRepresentation { // Get all menu-bar extension bundles so the renderer can run them ipcMain.handle('get-menubar-extensions', async () => { - const allCmds = discoverInstalledExtensionCommands(); + const allCmds = await discoverInstalledExtensionCommands(); const menuBarCmds = allCmds.filter((c) => c.mode === 'menu-bar'); const bundles: any[] = []; @@ -19286,7 +19286,7 @@ if let tiff = image?.tiffRepresentation { // This populates cachedCommands with cacheTimestamp=0 (stale), so the first // getAvailableCommands() call serves the disk cache immediately and kicks off // a silent background refresh. - initCommandsCache(); + await initCommandsCache(); createWindow(); From 4187158ec3fada5fb3d1af26e65d325e6b814976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:58:08 +0200 Subject: [PATCH 05/11] fix(build): unpack extract-file-icon and node-gyp-build from asar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-window-manager requires both at runtime to load its native addon and resolve file icons, but only node-window-manager itself was listed in asarUnpack. In any packaged build the require() call crossed from the unpacked filesystem location back into the still-packed asar and failed, so window-manager-worker responded "window manager unavailable" to every request — breaking window management, "restore previous app" capture, and window layout commands in every distributable build. Found by actually running a packaged .dmg and reproducing the failure with ELECTRON_RUN_AS_NODE=1, not from code review. --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index f262a8c1..ceb20486 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "node_modules/@esbuild/**", "node_modules/node-edge-tts/**", "node_modules/node-window-manager/**", + "node_modules/extract-file-icon/**", + "node_modules/node-gyp-build/**", "node_modules/electron-liquid-glass/**" ], "files": [ From c4b808f40517f1e0967fa1cd4d11f79f25edd0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Thu, 16 Jul 2026 13:58:21 +0200 Subject: [PATCH 06/11] fix(main): drain in-flight extension/script work before quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension bundling (esbuild + fs) and script command execution (child_process) run real Node async work on the main process. If one of those callbacks completed after Electron began tearing down the V8/Node environment during quit, Node aborted natively (EXC_BREAKPOINT/SIGTRAP) instead of throwing a catchable JS error — reproduced repeatedly in production crash reports (SuperCmd-2026-07-13-110438.ips and others, all identical signature: CrBrowserMain thread, CrShutdownDetector present, no ASI message). Track every getExtensionBundle/executeScriptCommand/buildAllCommands call in a registry. before-quit now intercepts the first quit attempt, waits up to 2s for tracked work to settle, and only then lets the app actually quit — bounded so a stuck extension can't block shutdown indefinitely. See the ADR stored for this project (Users-cgomez-orca-workspaces- SuperCmd-Fix-shoutdown) via codebase-memory for the full crash diagnosis and the alternatives considered. --- src/main/main.ts | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 9ba70a1f..7f9080ba 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1719,6 +1719,23 @@ const windowManagerWorkerPending = new Map; }>(); +// ─── Graceful shutdown: drain in-flight extension/script work ─────── +// Extension bundling (esbuild + fs) and script command execution +// (child_process) run real Node async work on the main process. If one of +// those callbacks completes after Electron starts tearing down the V8/Node +// environment on quit, Node has been observed to abort natively +// (EXC_BREAKPOINT/SIGTRAP) instead of throwing a catchable JS error. Track +// this work so before-quit can wait for it to settle first. +const EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS = 2000; +const inFlightExtensionWork = new Set>(); + +function trackInFlightExtensionWork(promise: Promise): Promise { + inFlightExtensionWork.add(promise); + const untrack = () => { inFlightExtensionWork.delete(promise); }; + promise.then(untrack, untrack); + return promise; +} + function parseMajorVersion(value: string | undefined): number | null { if (!value) return null; const major = Number.parseInt(String(value).split('.')[0], 10); @@ -7902,7 +7919,7 @@ async function buildLaunchBundle(options: { sourceExtensionName, sourcePreferences, } = options; - const result = await getExtensionBundle(extensionName, commandName); + const result = await trackInFlightExtensionWork(getExtensionBundle(extensionName, commandName)); if (!result) { throw new Error(`Command "${commandName}" not found in extension "${extensionName}"`); } @@ -13596,7 +13613,7 @@ async function rebuildExtensions() { // This ensures we always have fresh builds on startup. console.log(`Rebuilding extension: ${name}`); try { - await buildAllCommands(name); + await trackInFlightExtensionWork(buildAllCommands(name)); } catch (e) { console.error(`Failed to rebuild ${name}:`, e); } @@ -15085,7 +15102,7 @@ app.whenReady().then(async () => { async (_event: any, extName: string, cmdName: string) => { try { // Read the pre-built bundle (built at install time), or build on-demand - const result = await getExtensionBundle(extName, cmdName); + const result = await trackInFlightExtensionWork(getExtensionBundle(extName, cmdName)); if (!result) { return { error: `No pre-built bundle for ${extName}/${cmdName}. Try reinstalling the extension.` }; } @@ -15145,7 +15162,7 @@ app.whenReady().then(async () => { : {}; const background = Boolean(payload?.background); - const executed = await executeScriptCommand(commandId, argumentValues); + const executed = await trackInFlightExtensionWork(executeScriptCommand(commandId, argumentValues)); if ('missingArguments' in executed) { return { success: false, @@ -19014,7 +19031,7 @@ if let tiff = image?.tiffRepresentation { const bundles: any[] = []; for (const cmd of menuBarCmds) { - const bundle = await getExtensionBundle(cmd.extName, cmd.cmdName); + const bundle = await trackInFlightExtensionWork(getExtensionBundle(cmd.extName, cmd.cmdName)); if (bundle) { bundles.push({ code: bundle.code, @@ -19378,8 +19395,26 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', () => { +let hasAttemptedExtensionWorkDrainOnQuit = false; + +app.on('before-quit', (event: any) => { prepareWindowsForAppQuit(); + if (hasAttemptedExtensionWorkDrainOnQuit || inFlightExtensionWork.size === 0) return; + hasAttemptedExtensionWorkDrainOnQuit = true; + event.preventDefault(); + const pending = Array.from(inFlightExtensionWork); + console.log(`[Shutdown] Draining ${pending.length} in-flight extension/script operation(s) before quitting...`); + const drained = Promise.allSettled(pending); + const timedOut = new Promise((resolve) => setTimeout(resolve, EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS)); + Promise.race([drained, timedOut]).then(() => { + if (inFlightExtensionWork.size > 0) { + console.warn( + `[Shutdown] ${inFlightExtensionWork.size} extension/script operation(s) still pending after ` + + `${EXTENSION_WORK_QUIT_DRAIN_TIMEOUT_MS}ms; quitting anyway.` + ); + } + app.quit(); + }); }); app.on('will-quit', () => { From befebaf89eb7d8f6c2dcde13b183b8e0f2f794bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Fri, 17 Jul 2026 07:39:15 +0200 Subject: [PATCH 07/11] fix: don't kill transcription servers mid-request on idle-shutdown ensureXServer() (parakeet/qwen3/whisper.cpp) bumped the 10-minute idle timer once at the start, before the request was even sent, and nothing re-armed it while a request was in flight. A transcription running past 10 minutes (long audio, slow hardware) got its server killed and its pending request rejected mid-flight, even though the server was busy, not idle. createIdleShutdownScheduler now takes an isBusy() predicate; when the timer fires while a request is still pending it defers instead of killing, and only shuts the server down once it's genuinely idle. --- src/main/main.ts | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 7f9080ba..4d34c8a7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -263,15 +263,33 @@ let parakeetModelEnsurePromise: Promise | null = null; // they don't hold their memory footprint until the app quits. const AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS = 10 * 60_000; // 10 minutes -function createIdleShutdownScheduler(killFn: () => void, idleMs: number): { bump: () => void } { +// `isBusy` guards against killing a server mid-request: ensureXServer() bumps +// the timer once, up front, before the caller has even sent its request — for +// a transcription that runs longer than idleMs (long audio, slow hardware), +// nothing re-bumps the timer while it's in flight. Without this guard the +// timer would fire and kill a server that's actively working, not idle. +function createIdleShutdownScheduler( + killFn: () => void, + idleMs: number, + isBusy: () => boolean +): { bump: () => void } { let timer: ReturnType | null = null; + const scheduleCheck = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + if (isBusy()) { + // Still working — defer the kill and check again after another + // idle window rather than shutting down a busy server. + scheduleCheck(); + return; + } + killFn(); + }, idleMs); + }; return { bump(): void { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - timer = null; - killFn(); - }, idleMs); + scheduleCheck(); }, }; } @@ -285,7 +303,8 @@ type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Erro let parakeetPendingRequest: PendingParakeetRequest | null = null; const parakeetIdleShutdown = createIdleShutdownScheduler( () => killParakeetServer(), - AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS, + () => parakeetPendingRequest !== null ); function killParakeetServer(): void { @@ -645,7 +664,8 @@ type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) let qwen3PendingRequest: PendingQwen3Request | null = null; const qwen3IdleShutdown = createIdleShutdownScheduler( () => killQwen3Server(), - AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS, + () => qwen3PendingRequest !== null ); function killQwen3Server(): void { @@ -1184,7 +1204,8 @@ type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Er let whisperCppPendingRequest: PendingWhisperCppRequest | null = null; const whisperCppIdleShutdown = createIdleShutdownScheduler( () => killWhisperCppServer(), - AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS + AI_TRANSCRIPTION_SERVER_IDLE_SHUTDOWN_MS, + () => whisperCppPendingRequest !== null ); function killWhisperCppServer(): void { From 022c896860a056437185befa5a641b4b305e7ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Fri, 17 Jul 2026 21:35:52 +0200 Subject: [PATCH 08/11] fix(extension-runner): clear lastBuildError after a successful rebuild lastBuildError only ever grew via .set() on build failure and was never evicted on a later successful rebuild of the same command, so it retained stale error strings for the lifetime of the process. --- src/main/extension-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 050a749a..37f611d6 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -1311,7 +1311,9 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom extPath, `${extName}/${cmdName}` ); - return fs.existsSync(outFile); + const built = fs.existsSync(outFile); + if (built) lastBuildError.delete(`${extName}/${cmdName}`); + return built; } catch (e: any) { console.error(` On-demand esbuild failed for ${extName}/${cmdName}:`, e); lastBuildError.set(`${extName}/${cmdName}`, e?.message || String(e)); From c2465153dbcf83b5f25a2f5ab29c04841e15e3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Fri, 17 Jul 2026 21:35:56 +0200 Subject: [PATCH 09/11] feat(main): add opt-in heap diagnostics for the main process Gated behind SC_HEAP_DEBUG=1 so it's inert for regular users. Logs process.memoryUsage() every 5 minutes and registers Cmd+Alt+Shift+H to write a heap snapshot, to help confirm whether the main-process heap grows unbounded during a long session (investigating the 2026-07-17 EXC_BREAKPOINT crash on CrBrowserMain). --- src/main/main.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/main/main.ts b/src/main/main.ts index 4d34c8a7..0fd8bd78 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13594,6 +13594,42 @@ function registerCommandHotkeys(hotkeys: Record): void { syncHyperKeyMonitor(); } +// Opt-in diagnostics for the main-process JS heap. Enabled only when +// SC_HEAP_DEBUG=1 is set, so it never runs for regular users — set it when +// reproducing a long-session OOM to see whether the main process heap grows +// unbounded and to capture a snapshot for retainer analysis. +function setupHeapDebugInstrumentation(): void { + if (process.env.SC_HEAP_DEBUG !== '1') return; + + const HEAP_LOG_INTERVAL_MS = 5 * 60 * 1000; + setInterval(() => { + const mem = process.memoryUsage(); + console.log( + `[HeapDebug] rss=${(mem.rss / 1048576).toFixed(1)}MB heapUsed=${(mem.heapUsed / 1048576).toFixed(1)}MB heapTotal=${(mem.heapTotal / 1048576).toFixed(1)}MB external=${(mem.external / 1048576).toFixed(1)}MB` + ); + }, HEAP_LOG_INTERVAL_MS).unref(); + + try { + const success = globalShortcut.register('CommandOrControl+Alt+Shift+H', () => { + try { + const v8 = require('v8'); + const outDir = path.join(app.getPath('logs'), 'heap-snapshots'); + fs.mkdirSync(outDir, { recursive: true }); + const outFile = path.join(outDir, `main-${Date.now()}.heapsnapshot`); + v8.writeHeapSnapshot(outFile); + console.log(`[HeapDebug] Wrote heap snapshot to ${outFile}`); + } catch (error) { + console.warn('[HeapDebug] Failed to write heap snapshot:', error); + } + }); + if (!success) { + console.warn('[HeapDebug] Failed to register heap snapshot shortcut'); + } + } catch (error) { + console.warn('[HeapDebug] Error registering heap snapshot shortcut:', error); + } +} + function registerDevToolsShortcut(): void { try { unregisterShortcutVariants(DEVTOOLS_SHORTCUT); @@ -19342,6 +19378,7 @@ if let tiff = image?.tiffRepresentation { registerGlobalShortcut(settings.globalShortcut); registerCommandHotkeys(settings.commandHotkeys); registerDevToolsShortcut(); + setupHeapDebugInstrumentation(); // Fallback: when another SuperCmd window gains focus (e.g. Settings), // close the launcher in default mode even if a native blur event was missed. From c37153fbbf8f511361775f75ce7d010c229e4ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Mon, 20 Jul 2026 08:12:27 +0200 Subject: [PATCH 10/11] fix(window-manager): stop swallowing worker stdout/stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window-manager worker was forked with stdio fully ignored, so any underlying error (e.g. node-window-manager failing to load) was silently dropped — callers only ever saw the generic "window manager unavailable" message. Pipe stdout/stderr back into the main process console instead. --- src/main/main.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/main.ts b/src/main/main.ts index 0fd8bd78..7e65d2f7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1813,6 +1813,13 @@ function scheduleWindowManagerWorkerRestart(): void { } function attachWindowManagerWorkerListeners(proc: ChildProcess): void { + proc.stdout?.on('data', (chunk) => { + console.log(`[WindowManagerWorker] ${String(chunk).trimEnd()}`); + }); + proc.stderr?.on('data', (chunk) => { + console.error(`[WindowManagerWorker] ${String(chunk).trimEnd()}`); + }); + proc.on('message', (message: WindowManagerWorkerResponse) => { if (!message || typeof message !== 'object') return; const pending = windowManagerWorkerPending.get(Number(message.id)); @@ -1848,7 +1855,7 @@ function ensureWindowManagerWorker(): ChildProcess | null { try { const workerPath = getWindowManagerWorkerPath(); const proc = fork(workerPath, [], { - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], execArgv: [], }); windowManagerWorker = proc; From fdef19a23fafb882aeb403c678dd3e342efb843c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81sar=20Go=CC=81mez=20Antonio?= Date: Mon, 20 Jul 2026 12:35:35 +0200 Subject: [PATCH 11/11] fix(main): raise V8 old-space limit to avoid heap-ceiling SIGTRAP abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced the original crash signature live: after a long session the main-process heap climbed toward V8's default old-space ceiling (~2GB), Mark-Compact GC could no longer reclaim enough, and the process aborted with SIGTRAP — the same signal from the original crash report. Raising --max-old-space-size via app.commandLine gives it headroom on machines that have it to spare, buying time while the underlying retainer is tracked down separately. Also switch the SC_HEAP_DEBUG snapshot capture to a SIGUSR2 handler (in addition to the existing hotkey) so a snapshot can be requested from a script/terminal without the app needing focus, and write snapshots to os.tmpdir() instead of app.getPath('logs') — the logs dir has been observed losing files to unrelated cleanup shortly after capture. --- src/main/main.ts | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 7e65d2f7..2eb8a6e0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -228,6 +228,13 @@ try { app.setName('SuperCmd'); } catch {} +// V8's default old-space ceiling (~2GB) was observed being hit after a long +// session (Mark-Compact unable to reclaim, then a SIGTRAP abort) even under +// normal use. Raise it — must be set before app is ready. +try { + app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096'); +} catch {} + // ─── Native Binary Helpers ────────────────────────────────────────── @@ -13605,6 +13612,21 @@ function registerCommandHotkeys(hotkeys: Record): void { // SC_HEAP_DEBUG=1 is set, so it never runs for regular users — set it when // reproducing a long-session OOM to see whether the main process heap grows // unbounded and to capture a snapshot for retainer analysis. +function writeHeapDebugSnapshot(): void { + try { + const v8 = require('v8'); + // /tmp instead of app.getPath('logs') — the logs dir has been observed to + // get swept by unrelated cleanup, losing snapshots minutes after capture. + const outDir = path.join(require('os').tmpdir(), 'supercmd-heap-snapshots'); + fs.mkdirSync(outDir, { recursive: true }); + const outFile = path.join(outDir, `main-${Date.now()}.heapsnapshot`); + v8.writeHeapSnapshot(outFile); + console.log(`[HeapDebug] Wrote heap snapshot to ${outFile}`); + } catch (error) { + console.warn('[HeapDebug] Failed to write heap snapshot:', error); + } +} + function setupHeapDebugInstrumentation(): void { if (process.env.SC_HEAP_DEBUG !== '1') return; @@ -13616,19 +13638,12 @@ function setupHeapDebugInstrumentation(): void { ); }, HEAP_LOG_INTERVAL_MS).unref(); + // Signal-triggered capture — lets us snapshot from a script/terminal without + // needing the app focused or a physical keypress. + process.on('SIGUSR2', writeHeapDebugSnapshot); + try { - const success = globalShortcut.register('CommandOrControl+Alt+Shift+H', () => { - try { - const v8 = require('v8'); - const outDir = path.join(app.getPath('logs'), 'heap-snapshots'); - fs.mkdirSync(outDir, { recursive: true }); - const outFile = path.join(outDir, `main-${Date.now()}.heapsnapshot`); - v8.writeHeapSnapshot(outFile); - console.log(`[HeapDebug] Wrote heap snapshot to ${outFile}`); - } catch (error) { - console.warn('[HeapDebug] Failed to write heap snapshot:', error); - } - }); + const success = globalShortcut.register('CommandOrControl+Alt+Shift+H', writeHeapDebugSnapshot); if (!success) { console.warn('[HeapDebug] Failed to register heap snapshot shortcut'); }