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": [ 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 2a275382..37f611d6 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'); @@ -497,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; @@ -1256,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)); diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..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 ────────────────────────────────────────── @@ -257,6 +264,43 @@ 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 + +// `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 { + scheduleCheck(); + }, + }; +} + // Persistent serve-mode process for fast transcription (models stay loaded in memory) let parakeetServerProcess: any = null; // ChildProcess let parakeetServerReady = false; @@ -264,6 +308,11 @@ 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, + () => parakeetPendingRequest !== null +); function killParakeetServer(): void { if (parakeetServerProcess) { @@ -283,6 +332,7 @@ function killParakeetServer(): void { } function ensureParakeetServer(): Promise { + parakeetIdleShutdown.bump(); if (parakeetServerReady && parakeetServerProcess && !parakeetServerProcess.killed) { return Promise.resolve(); } @@ -619,6 +669,11 @@ 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, + () => qwen3PendingRequest !== null +); function killQwen3Server(): void { if (qwen3ServerProcess) { @@ -638,6 +693,7 @@ function killQwen3Server(): void { } function ensureQwen3Server(): Promise { + qwen3IdleShutdown.bump(); if (qwen3ServerReady && qwen3ServerProcess && !qwen3ServerProcess.killed) { return Promise.resolve(); } @@ -1153,6 +1209,11 @@ 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, + () => whisperCppPendingRequest !== null +); function killWhisperCppServer(): void { if (whisperCppServerProcess) { @@ -1172,6 +1233,7 @@ function killWhisperCppServer(): void { } function ensureWhisperCppServer(): Promise { + whisperCppIdleShutdown.bump(); if (whisperCppServerReady && whisperCppServerProcess && !whisperCppServerProcess.killed) { return Promise.resolve(); } @@ -1685,6 +1747,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); @@ -1741,6 +1820,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)); @@ -1776,7 +1862,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; @@ -7868,7 +7954,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}"`); } @@ -13522,6 +13608,50 @@ 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 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; + + 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(); + + // 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', writeHeapDebugSnapshot); + 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); @@ -13562,7 +13692,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); } @@ -13605,8 +13735,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 @@ -15050,7 +15181,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.` }; } @@ -15110,7 +15241,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, @@ -18974,12 +19105,12 @@ 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[] = []; 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, @@ -19251,7 +19382,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(); @@ -19269,6 +19400,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. @@ -19343,8 +19475,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', () => {