diff --git a/handoffs/current.md b/handoffs/current.md new file mode 100644 index 00000000..731752d8 --- /dev/null +++ b/handoffs/current.md @@ -0,0 +1,142 @@ +# Current Handoff + +## Current Status + +Investigating a recurring `EXC_BREAKPOINT`/`SIGTRAP` crash on `CrBrowserMain` +(main process). Confirmed root cause: `file-search-index.ts` rebuilds a +~1.1M-entry home-directory index on an interval, and previously kept the old +snapshot alive while building the new one — a multi-GB spike that blows any +V8 heap ceiling. Fix is written, compiled, and packaged into the latest DMG, +but **not yet confirmed working** — the last test run showed old (pre-fix) +behavior, most likely because a stale process/build was tested rather than +the new one. Branch `feat/fix-review-crash` also now contains two previously +orphaned, unmerged upstream branches (see Decisions). + +## Completed Work + +- **`lastBuildError` leak** (`extension-runner.ts`): now cleared on successful + rebuild instead of growing forever. +- **Window-manager worker fixes**: stopped silencing its stdout/stderr + (`main.ts`), which surfaced the real bug — `extract-file-icon` / + `node-gyp-build` weren't in `asarUnpack`, so `node-window-manager` failed to + load in packaged builds (`package.json`). +- **V8 heap ceiling fix, corrected**: first attempt + (`app.commandLine.appendSwitch('js-flags', ...)`) confirmed NOT to reach the + main process (only affects Chromium renderer processes). Second attempt + (`v8.setFlagsFromString`) confirmed ineffective too (heap_size_limit is + fixed at isolate creation). Working fix: re-exec the process once with + `--js-flags=--max-old-space-size=4096` on argv (env-marker guarded against + looping). Verified live: process reports `~4096MB` limit. +- **Diagnostics added** (`SC_HEAP_DEBUG=1` env gate): periodic + `process.memoryUsage()` log every 5 min, heap snapshot capture via + `Cmd+Alt+Shift+H` or `SIGUSR2`, written to `/tmp` (not `~/Library/Logs` — + that directory has repeatedly lost files minutes after being written, + cause unconfirmed, treat as unreliable for this investigation). +- **Recovered two orphaned upstream branches** (both had open PRs, neither + merged into `main`, which itself is stalled at `2da7b9e`) and merged both + into `feat/fix-review-crash`, cleanly, no conflicts: + - `feat/Fix-shoutdown` (PR #630): LRU-cache extension bundles, stop + pre-warming window-manager-worker, non-blocking discovery, idle + transcription server shutdown (+ mid-request-kill bugfix), drain + in-flight extension/script work before quit (this one has its own ADR + diagnosing a *different* EXC_BREAKPOINT/SIGTRAP cause — a Node callback + completing after Electron tears down V8 on quit; not yet confirmed + whether it's still occurring separately from the FileIndex issue). + - `feat/feat-confetti` (PR #631): expanded tray menu (Settings, Extension + Store, Launch at Login, Check for Updates), Confetti/Fireworks/Snow/Rain + commands, esbuild moved to `optionalDependencies` (fixes `EBADPLATFORM` + on `npm install` for single-arch Macs). + - Pushed today's 4 relevant commits (lastBuildError, heap diagnostics, + window-manager stdio, heap-limit fix) onto `feat/Fix-shoutdown` and to + origin, updating PR #630 live (11 commits now). +- **`file-search-index.ts` fix** (latest commit, root-cause fix): + - Old snapshot dropped (`activeIndex = null`) before building the new one, + instead of after — eliminates the double-memory rebuild spike. Trade-off: + search returns empty during the rebuild window. + - `MAX_INDEX_ENTRIES` 1.2M → 400k; `DEFAULT_REFRESH_INTERVAL_MS` 8min → + 60min. + - Added a manual `system-rebuild-file-index` command + (`commands.ts`/`executeCommand`) for on-demand refresh. + - Compiled binary verified to contain `400000` (not `1200000`) — the fix is + genuinely in the shipped DMG. + +## Work In Progress + +- **Confirming the FileIndex fix actually resolves the crash.** Last test run + showed 1.1M entries and sub-60min interval rebuilds — i.e. old behavior — + strongly suggesting a stale process or pre-fix install was tested, not that + the fix failed. User was about to redo the test cleanly (kill all + processes, reinstall from `~/Downloads/SuperCmd-1.0.26-arm64.dmg`, verify + `grep -ao "400000" .../app.asar` before launching). + +## Pending Work + +1. Re-verify the FileIndex fix with a clean process + confirmed-current + build, ideally past the ~48–96min window where every prior crash occurred. +2. If it still crashes: get another `[HeapDebug]`/`[FileIndex]` log + `.ips` + from `~/Library/Logs/DiagnosticReports/` (read it immediately, it may + disappear) and check whether the heap trend shows another single big + jump (another oversized structure) or a slow genuine leak this time. +3. If it holds: consider whether `feat/Fix-shoutdown`'s quit-time race + condition (drain-before-quit, already merged) is a second, independent, + much rarer cause — no direct evidence yet that it's firing separately. +4. Two open upstream PRs (#630, #631) are `MERGEABLE` but `BLOCKED` on + review — no CI configured on this repo, no human reviewer assigned. + `main` itself has been stalled at `2da7b9e` this whole time; consider + whether that's expected or itself a process gap worth raising. +5. Optional, not started: `global.gc()` periodic-forcing was proposed as a + heap mitigation but deferred by user request ("ya veremos") in favor of + chasing the FileIndex root cause first. + +## Blockers + +- None technical. Confirmation is blocked only on the user's next test run. + +## Decisions + +- Bring both orphaned branches into `feat/fix-review-crash` rather than + leave them stranded or merge straight to `main` (user's call, asked + explicitly). +- Accept a brief "search returns nothing" window during FileIndex rebuilds + in exchange for not doubling peak memory (user's call, picked this option + explicitly over alternatives). +- Diagnostics write to `/tmp`, not `~/Library/Logs` — that directory has + been unreliable mid-session (files vanishing) independent of anything in + this codebase; don't rely on it for future debugging in this repo either. + +## Risks + +- The ~48–96min crash timing has been remarkably consistent across multiple + different fixes (heap ceiling raised, FileIndex capped) — if the next test + still crashes around that window, the FileIndex theory may be wrong or + incomplete, and the quit-time-race theory (already fixed via + drain-before-quit) or a third, unfound cause should be considered before + assuming "still the same bug." +- `git log --oneline` output was momentarily misleading mid-session (looked + stale after a `cherry-pick --abort`) — cross-checked via `rev-parse`/ + `cat-file` and it was fine, but worth using low-level plumbing commands to + double check if branch state ever looks wrong again in this repo. +- Multiple stale/orphaned SuperCmd processes have repeatedly confused test + results this session (old tray menu, "app still runs after deleting it", + and likely the last FileIndex test). Always confirm zero running processes + and verify the installed binary's content (e.g. `grep -ao` a known string) + before trusting a test result in this project going forward. + +## Next Actions + +- Wait for user's clean re-test of the FileIndex fix. +- Depending on result, either close out the crash investigation or resume + hunting (see Pending Work #2–3). + +## References + +- Crash reports (may vanish from disk, read immediately if consulted): + `~/Library/Logs/DiagnosticReports/SuperCmd-2026-07-{17,20,21}-*.ips` +- `src/main/file-search-index.ts`, `src/main/extension-runner.ts`, + `src/main/main.ts`, `src/main/commands.ts` +- Upstream: `SuperCmdLabs/SuperCmd` PR #630, PR #631 +- ADR (via `codebase-memory-mcp`, project + `Users-cgomez-orca-workspaces-SuperCmd-Fix-shoutdown`): quit-time-race + diagnosis for the drain-before-quit fix +- Local DMG builds: `~/Downloads/SuperCmd-1.0.26-arm64.dmg` (rebuilt several + times today — always verify MD5/content before trusting a test against it) diff --git a/package-lock.json b/package-lock.json index cbe18d8a..a6de6f4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -39,6 +39,10 @@ "typescript": "^5.3.3", "vite": "^5.0.11", "wait-on": "^7.2.0" + }, + "optionalDependencies": { + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index f262a8c1..9d2f3314 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,6 @@ }, "dependencies": { "@aptabase/electron": "^0.3.1", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", @@ -57,6 +55,10 @@ "react-dom": "^18.2.0", "transliteration": "^2.6.1" }, + "optionalDependencies": { + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12" + }, "build": { "appId": "com.supercmd.app", "productName": "SuperCmd", @@ -76,6 +78,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..9a92cd71 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -13,6 +13,7 @@ import { app } from 'electron'; import { exec, execFile, spawn } from 'child_process'; import { promisify } from 'util'; +import { rebuildFileSearchIndex } from './file-search-index'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -67,6 +68,22 @@ const SHUTDOWN_ICON_DATA_URL = svgToBase64DataUrl( '' ); +const CONFETTI_ICON_DATA_URL = svgToBase64DataUrl( + '' +); + +const FIREWORKS_ICON_DATA_URL = svgToBase64DataUrl( + '' +); + +const SNOW_ICON_DATA_URL = svgToBase64DataUrl( + '' +); + +const RAIN_ICON_DATA_URL = svgToBase64DataUrl( + '' +); + export interface CommandInfo { id: string; title: string; @@ -129,19 +146,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 +182,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 +226,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`); @@ -1308,6 +1336,38 @@ async function discoverAndBuildCommands(): Promise { keywords: ['emoji', 'picker', 'trigger', 'smiley', 'emoticon'], category: 'system', }, + { + id: 'system-confetti', + title: 'Confetti', + subtitle: 'Celebrate with a burst of confetti', + keywords: ['confetti', 'celebrate', 'party', 'fun', 'congrats', 'congratulations', 'celebration'], + iconDataUrl: CONFETTI_ICON_DATA_URL, + category: 'system', + }, + { + id: 'system-fireworks', + title: 'Fireworks', + subtitle: 'Launch a fireworks show', + keywords: ['fireworks', 'rockets', 'celebrate', 'party', 'fun', 'show', 'pyrotechnics'], + iconDataUrl: FIREWORKS_ICON_DATA_URL, + category: 'system', + }, + { + id: 'system-snow', + title: 'Snow', + subtitle: 'Let it snow on your screen', + keywords: ['snow', 'snowfall', 'winter', 'christmas', 'fun', 'weather'], + iconDataUrl: SNOW_ICON_DATA_URL, + category: 'system', + }, + { + id: 'system-rain', + title: 'Rain', + subtitle: 'Make it rain on your screen', + keywords: ['rain', 'rainfall', 'storm', 'weather', 'fun'], + iconDataUrl: RAIN_ICON_DATA_URL, + category: 'system', + }, { id: 'system-reset-launcher-position', title: 'Reset Launcher Position', @@ -1315,6 +1375,12 @@ async function discoverAndBuildCommands(): Promise { iconDataUrl: RESET_POSITION_ICON_DATA_URL, category: 'system', }, + { + id: 'system-rebuild-file-index', + title: 'Rebuild File Search Index', + keywords: ['rebuild', 'refresh', 'reindex', 'file', 'search', 'index', 'supercmd'], + category: 'system', + }, { id: 'system-open-settings', title: 'SuperCmd Settings', @@ -1849,7 +1915,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, @@ -2108,6 +2174,16 @@ export async function executeCommand(id: string): Promise { } } + if (id === 'system-rebuild-file-index') { + try { + await rebuildFileSearchIndex('manual-command'); + return true; + } catch (error) { + console.error('Failed to rebuild file search index:', error); + return false; + } + } + // Use stale fallback when available to avoid blocking on a fresh discovery // while the cache is being rebuilt in the background. const commands = cachedCommands ?? staleCommandsFallback ?? await getAvailableCommands(); 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/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..d9e0d8f3 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -57,12 +57,16 @@ type IndexSnapshot = { const SEARCH_TOKEN_SPLIT_REGEX = /[^a-z0-9]+/g; const MAX_PREFIX_LENGTH = 12; -const MAX_INDEX_ENTRIES = 1_200_000; +// A full rebuild briefly holds the old and new snapshot in memory at once +// (see rebuildFileSearchIndex) — for home directories with hundreds of +// thousands of entries, that peak alone can approach the process's heap +// ceiling. Cap entries and space rebuilds out to keep that peak bounded. +const MAX_INDEX_ENTRIES = 400_000; const DEFAULT_MAX_RESULTS = 80; const MAX_QUERY_RESULTS = 5_000; const MAX_FILE_METADATA_STAT_RESULTS = 240; const MIN_REBUILD_GAP_MS = 45_000; -const DEFAULT_REFRESH_INTERVAL_MS = 8 * 60_000; +const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60_000; const WATCH_EVENT_DEBOUNCE_MS = 500; const MAX_SPOTLIGHT_CANDIDATES = 10_000; const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400; @@ -637,6 +641,13 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { rebuildPromise = (async () => { indexing = true; + // Drop the old snapshot before building the new one instead of after. + // Keeping both alive for the duration of the scan (previously: old stays + // referenced via `activeIndex` until the new one is fully built) doubles + // peak memory for a structure that can itself run into the GB range for + // large home directories — searches just return empty during the rebuild + // window instead, which is a fair trade against crashing the whole app. + activeIndex = null; try { const snapshot = await buildIndexSnapshot(configuredHomeDir); activeIndex = snapshot; diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..b95527d3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,35 @@ process.stdout?.on?.('error', () => {}); process.stderr?.on?.('error', () => {}); +// Raise V8's old-space ceiling for THIS process (the main/browser process's +// own embedded Node runtime) by re-exec'ing with the flag directly on argv. +// Two other approaches were tried and confirmed NOT to work for this specific +// process: app.commandLine.appendSwitch('js-flags', ...) only reaches +// Chromium-managed renderer processes (a real crash at the heap ceiling +// recurred with it in place), and v8.setFlagsFromString() cannot change +// memory-layout flags after the isolate has already been created (verified +// empirically — heap_size_limit is unchanged before/after the call). Passing +// --js-flags on the actual argv of a fresh process is the one mechanism +// Electron honors for its own main-process V8 instance. +if (!process.env.SC_HEAP_REEXECED) { + try { + const { spawn } = require('child_process'); + const child = spawn( + process.execPath, + ['--js-flags=--max-old-space-size=4096', ...process.argv.slice(1)], + { stdio: 'inherit', detached: true, env: { ...process.env, SC_HEAP_REEXECED: '1' } } + ); + child.unref(); + process.exit(0); + } catch (error) { + console.warn('[HeapDebug] Failed to re-exec with raised V8 old-space limit:', error); + } +} +try { + const limitMb = Math.round(require('v8').getHeapStatistics().heap_size_limit / 1048576); + console.log(`[HeapDebug] V8 old-space limit for this process: ~${limitMb}MB`); +} catch {} + import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; @@ -257,6 +286,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 +330,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 +354,7 @@ function killParakeetServer(): void { } function ensureParakeetServer(): Promise { + parakeetIdleShutdown.bump(); if (parakeetServerReady && parakeetServerProcess && !parakeetServerProcess.killed) { return Promise.resolve(); } @@ -619,6 +691,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 +715,7 @@ function killQwen3Server(): void { } function ensureQwen3Server(): Promise { + qwen3IdleShutdown.bump(); if (qwen3ServerReady && qwen3ServerProcess && !qwen3ServerProcess.killed) { return Promise.resolve(); } @@ -1153,6 +1231,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 +1255,7 @@ function killWhisperCppServer(): void { } function ensureWhisperCppServer(): Promise { + whisperCppIdleShutdown.bump(); if (whisperCppServerReady && whisperCppServerProcess && !whisperCppServerProcess.killed) { return Promise.resolve(); } @@ -1685,6 +1769,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 +1842,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 +1884,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; @@ -2481,8 +2589,6 @@ let memoryStatusHideTimer: NodeJS.Timeout | null = null; let memoryStatusFadeFinalizeTimer: NodeJS.Timeout | null = null; let memoryStatusRenderSeq = 0; let memoryStatusHideTimerSeq = 0; -let confettiWindow: InstanceType | null = null; -let confettiCloseTimer: NodeJS.Timeout | null = null; let settingsWindow: InstanceType | null = null; let extensionStoreWindow: InstanceType | null = null; let notesWindow: InstanceType | null = null; @@ -2841,24 +2947,25 @@ function getConfettiWindowHtml(): string { const COLORS = ['#ff4d6d', '#ffd166', '#06d6a0', '#4cc9f0', '#f72585', '#b8f2e6', '#ffffff']; const particles = []; - // Two bursts from lower-left and lower-right, plus a center shower + // Two wide bursts from the bottom corners, plus a center shower, sized + // to cover most of the screen rather than three small isolated clumps. const bursts = [ - { x: canvas.width * 0.15, y: canvas.height * 0.85, angle: -Math.PI * 0.30, spread: 0.9 }, - { x: canvas.width * 0.85, y: canvas.height * 0.85, angle: -Math.PI * 0.70, spread: 0.9 }, - { x: canvas.width * 0.50, y: canvas.height * 0.55, angle: -Math.PI * 0.50, spread: 1.1 }, + { x: canvas.width * 0.06, y: canvas.height * 0.95, angle: -Math.PI * 0.28, spread: 1.0 }, + { x: canvas.width * 0.94, y: canvas.height * 0.95, angle: -Math.PI * 0.72, spread: 1.0 }, + { x: canvas.width * 0.50, y: canvas.height * 0.55, angle: -Math.PI * 0.50, spread: 1.3 }, ]; - const gravity = 0.32 * dpr; + const gravity = 0.28 * dpr; for (const b of bursts) { - const count = 110; + const count = 220; for (let i = 0; i < count; i++) { const a = b.angle + (Math.random() - 0.5) * b.spread; - const speed = (9 + Math.random() * 12) * dpr; + const speed = (14 + Math.random() * 22) * dpr; particles.push({ x: b.x, y: b.y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, - size: (4 + Math.random() * 7) * dpr, + size: (6 + Math.random() * 10) * dpr, color: COLORS[Math.floor(Math.random() * COLORS.length)], rot: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.3, @@ -2866,7 +2973,7 @@ function getConfettiWindowHtml(): string { } } - const durationMs = 1800; + const durationMs = 2400; const start = performance.now(); function tick(now) { const elapsed = now - start; @@ -2876,7 +2983,7 @@ function getConfettiWindowHtml(): string { p.x += p.vx; p.y += p.vy; p.vy += gravity; - p.vx *= 0.993; + p.vx *= 0.996; p.rot += p.rotSpeed; ctx.save(); ctx.translate(p.x, p.y); @@ -2899,24 +3006,139 @@ function getConfettiWindowHtml(): string { `; } -function closeConfettiWindow(): void { - if (confettiCloseTimer) { - clearTimeout(confettiCloseTimer); - confettiCloseTimer = null; - } - if (confettiWindow && !confettiWindow.isDestroyed()) { - try { confettiWindow.close(); } catch {} - } - confettiWindow = null; +function getFireworksWindowHtml(): string { + return ` + + + + + + + + + +`; } -async function showConfettiBurst(): Promise { +async function showFullScreenOverlayEffect(html: string, durationMs: number, label: string): Promise { + let win: InstanceType | null = null; try { - closeConfettiWindow(); const cursor = screen.getCursorScreenPoint(); const display = screen.getDisplayNearestPoint(cursor) || screen.getPrimaryDisplay(); const { x, y, width, height } = display.bounds; - confettiWindow = new BrowserWindow({ + win = new BrowserWindow({ x, y, width, @@ -2940,30 +3162,253 @@ async function showConfettiBurst(): Promise { sandbox: true, }, }); - disableWindowAnimation(confettiWindow); - try { confettiWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } catch {} - try { confettiWindow.setIgnoreMouseEvents(true, { forward: true }); } catch {} - try { confettiWindow.setAlwaysOnTop(true, 'screen-saver'); } catch {} - const win = confettiWindow; - win.on('closed', () => { - if (confettiWindow === win) confettiWindow = null; - }); - await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(getConfettiWindowHtml())}`); - if (win.isDestroyed()) return; + disableWindowAnimation(win); + try { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } catch {} + try { win.setIgnoreMouseEvents(true, { forward: true }); } catch {} + try { win.setAlwaysOnTop(true, 'screen-saver'); } catch {} + const thisWin = win; + await thisWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + if (thisWin.isDestroyed()) return; try { - if (typeof (win as any).showInactive === 'function') (win as any).showInactive(); - else win.show(); + if (typeof (thisWin as any).showInactive === 'function') (thisWin as any).showInactive(); + else thisWin.show(); } catch {} - confettiCloseTimer = setTimeout(() => { - confettiCloseTimer = null; - closeConfettiWindow(); - }, 2200); + setTimeout(() => { + if (!thisWin.isDestroyed()) { + try { thisWin.close(); } catch {} + } + }, durationMs); } catch (error) { - console.warn('[Confetti] Failed to show confetti burst:', error); - closeConfettiWindow(); + console.warn(`[${label}] Failed to show overlay effect:`, error); + if (win && !win.isDestroyed()) { + try { win.close(); } catch {} + } } } +function getSnowWindowHtml(): string { + return ` + + + + + + + + + +`; +} + +function getRainWindowHtml(): string { + return ` + + + + + + + + + +`; +} + +// Each burst gets its own overlay window rather than replacing a previous +// one — firing the command again while a burst is still animating layers a +// new one on top, matching Raycast's behavior instead of wiping the screen +// clean first. +async function showConfettiBurst(): Promise { + await showFullScreenOverlayEffect(getConfettiWindowHtml(), 2800, 'Confetti'); +} + +async function showFireworksBurst(): Promise { + await showFullScreenOverlayEffect(getFireworksWindowHtml(), 4500, 'Fireworks'); +} + +async function showSnowBurst(): Promise { + await showFullScreenOverlayEffect(getSnowWindowHtml(), 7000, 'Snow'); +} + +async function showRainBurst(): Promise { + await showFullScreenOverlayEffect(getRainWindowHtml(), 7000, 'Rain'); +} + type AppUpdaterState = | 'idle' | 'unsupported' @@ -7493,6 +7938,34 @@ function ensureAppTray(): void { }, }, { type: 'separator' }, + { + label: 'Extension Store...', + click: () => { + openExtensionStoreWindow(); + }, + }, + { + label: 'SuperCmd Settings...', + click: () => { + openSettingsWindow(); + }, + }, + { type: 'separator' }, + { + label: 'Launch at Login', + type: 'checkbox', + checked: Boolean((loadSettings() as any).openAtLogin), + click: (menuItem: any) => { + setOpenAtLogin(menuItem.checked); + }, + }, + { + label: 'Check for Updates...', + click: () => { + void runAppUpdaterCheckAndInstall(); + }, + }, + { type: 'separator' }, { label: 'Quit SuperCmd', click: () => { @@ -7868,7 +8341,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}"`); } @@ -10868,6 +11341,30 @@ async function runCommandById(commandId: string, source: 'launcher' | 'hotkey' | return true; } + if (commandId === 'system-confetti') { + if (source === 'launcher') hideWindow(); + void showConfettiBurst(); + return true; + } + + if (commandId === 'system-fireworks') { + if (source === 'launcher') hideWindow(); + void showFireworksBurst(); + return true; + } + + if (commandId === 'system-snow') { + if (source === 'launcher') hideWindow(); + void showSnowBurst(); + return true; + } + + if (commandId === 'system-rain') { + if (source === 'launcher') hideWindow(); + void showRainBurst(); + return true; + } + if (commandId === 'system-reset-launcher-position') { clearWindowState(); if (mainWindow && !mainWindow.isDestroyed()) { @@ -13344,6 +13841,55 @@ async function restartAndInstallAppUpdate(): Promise { return appUpdaterRestartPromise; } +async function runAppUpdaterCheckAndInstall(): Promise<{ success: boolean; error?: string; message?: string; state?: string }> { + ensureAppUpdaterConfigured(); + if (!appUpdater) { + void showMemoryStatusBar('error', 'Updater not available.'); + return { success: false, error: 'Updater not available' }; + } + + try { + // Step 1: Check for updates + void showMemoryStatusBar('processing', 'Checking for updates...'); + const checkStatus = await checkForAppUpdates(); + if (checkStatus.state === 'not-available') { + void showMemoryStatusBar('success', 'Already on latest version.'); + return { success: true, message: 'Already on latest version', state: checkStatus.state }; + } + if (checkStatus.state === 'error') { + void showMemoryStatusBar('error', checkStatus.message || 'Failed to check for updates'); + return { success: false, error: checkStatus.message || 'Failed to check for updates' }; + } + + // Step 2: Download if available + if (checkStatus.state === 'available') { + void showMemoryStatusBar('processing', 'Downloading update...'); + const downloadStatus = await downloadAppUpdate(); + if (downloadStatus.state === 'error') { + void showMemoryStatusBar('error', downloadStatus.message || 'Failed to download update'); + return { success: false, error: downloadStatus.message || 'Failed to download update' }; + } + } + + // Step 3: Restart if downloaded + if (appUpdaterStatusSnapshot.state === 'downloaded') { + void showMemoryStatusBar('processing', 'Restarting to install update...'); + const installed = await restartAndInstallAppUpdate(); + if (installed) { + return { success: true, message: 'Restarting to install update...', state: 'restarting' }; + } + void showMemoryStatusBar('error', 'Failed to restart for update installation'); + return { success: false, error: 'Failed to restart for update installation' }; + } + + void showMemoryStatusBar('success', checkStatus.message || 'Update check complete'); + return { success: true, message: checkStatus.message || 'Update check complete', state: checkStatus.state }; + } catch (error: any) { + void showMemoryStatusBar('error', String(error?.message || error || 'Update flow failed')); + return { success: false, error: String(error?.message || error || 'Update flow failed') }; + } +} + // ─── Shortcut Management ──────────────────────────────────────────── function applyOpenAtLogin(enabled: boolean): boolean { @@ -13359,6 +13905,14 @@ function applyOpenAtLogin(enabled: boolean): boolean { } } +function setOpenAtLogin(enabled: boolean): boolean { + const applied = applyOpenAtLogin(Boolean(enabled)); + if (applied) { + saveSettings({ openAtLogin: Boolean(enabled) } as Partial); + } + return applied; +} + function disableMacSpotlightShortcuts(): boolean { if (process.platform !== 'darwin') return false; try { @@ -13522,6 +14076,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 +14160,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 +14203,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 @@ -14389,52 +14988,7 @@ app.whenReady().then(async () => { // Full update flow: check → download → restart ipcMain.handle('app-updater-check-and-install', async () => { - ensureAppUpdaterConfigured(); - if (!appUpdater) { - void showMemoryStatusBar('error', 'Updater not available.'); - return { success: false, error: 'Updater not available' }; - } - - try { - // Step 1: Check for updates - void showMemoryStatusBar('processing', 'Checking for updates...'); - const checkStatus = await checkForAppUpdates(); - if (checkStatus.state === 'not-available') { - void showMemoryStatusBar('success', 'Already on latest version.'); - return { success: true, message: 'Already on latest version', state: checkStatus.state }; - } - if (checkStatus.state === 'error') { - void showMemoryStatusBar('error', checkStatus.message || 'Failed to check for updates'); - return { success: false, error: checkStatus.message || 'Failed to check for updates' }; - } - - // Step 2: Download if available - if (checkStatus.state === 'available') { - void showMemoryStatusBar('processing', 'Downloading update...'); - const downloadStatus = await downloadAppUpdate(); - if (downloadStatus.state === 'error') { - void showMemoryStatusBar('error', downloadStatus.message || 'Failed to download update'); - return { success: false, error: downloadStatus.message || 'Failed to download update' }; - } - } - - // Step 3: Restart if downloaded - if (appUpdaterStatusSnapshot.state === 'downloaded') { - void showMemoryStatusBar('processing', 'Restarting to install update...'); - const installed = await restartAndInstallAppUpdate(); - if (installed) { - return { success: true, message: 'Restarting to install update...', state: 'restarting' }; - } - void showMemoryStatusBar('error', 'Failed to restart for update installation'); - return { success: false, error: 'Failed to restart for update installation' }; - } - - void showMemoryStatusBar('success', checkStatus.message || 'Update check complete'); - return { success: true, message: checkStatus.message || 'Update check complete', state: checkStatus.state }; - } catch (error: any) { - void showMemoryStatusBar('error', String(error?.message || error || 'Update flow failed')); - return { success: false, error: String(error?.message || error || 'Update flow failed') }; - } + return await runAppUpdaterCheckAndInstall(); }); function broadcastSettingsToAllWindows(result: AppSettings): void { @@ -14753,11 +15307,7 @@ app.whenReady().then(async () => { ); ipcMain.handle('set-open-at-login', (_event: any, enabled: boolean) => { - const applied = applyOpenAtLogin(Boolean(enabled)); - if (applied) { - saveSettings({ openAtLogin: Boolean(enabled) } as Partial); - } - return applied; + return setOpenAtLogin(Boolean(enabled)); }); ipcMain.handle('replace-spotlight-with-supercmd', async () => { @@ -15050,7 +15600,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 +15660,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 +19524,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 +19801,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 +19819,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 +19894,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', () => {