diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index 490441225..1aee5c35b 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -143,13 +143,16 @@ class ApiSpecWatcher { } closeAllWatchers() { + const pending = []; for (const [watchPath, watcher] of Object.entries(this.watchers)) { try { - watcher?.close(); + const result = watcher?.close(); + if (result && typeof result.then === 'function') pending.push(result); } catch (err) {} } this.watchers = {}; this.watcherWorkspaces = {}; + return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 23aef361b..308e825b5 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -967,12 +967,15 @@ class CollectionWatcher { } closeAllWatchers() { + const pending = []; for (const [watchPath, watcher] of Object.entries(this.watchers)) { try { - watcher?.close(); + const result = watcher?.close(); + if (result && typeof result.then === 'function') pending.push(result); } catch (err) {} } this.watchers = {}; + return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/app/dotenv-watcher.js b/packages/bruno-electron/src/app/dotenv-watcher.js index e504b75d2..760f0b2d5 100644 --- a/packages/bruno-electron/src/app/dotenv-watcher.js +++ b/packages/bruno-electron/src/app/dotenv-watcher.js @@ -195,15 +195,21 @@ class DotEnvWatcher { } closeAll() { - for (const [path, watcher] of this.collectionWatchers) { - watcher.close(); - } + const pending = []; + const collect = (watcher) => { + try { + const result = watcher?.close(); + if (result && typeof result.then === 'function') pending.push(result); + } catch (err) {} + }; + + for (const [path, watcher] of this.collectionWatchers) collect(watcher); this.collectionWatchers.clear(); - for (const [path, watcher] of this.workspaceWatchers) { - watcher.close(); - } + for (const [path, watcher] of this.workspaceWatchers) collect(watcher); this.workspaceWatchers.clear(); + + return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index ebeca7e2c..fc782f38d 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -226,21 +226,24 @@ class WorkspaceWatcher { } closeAllWatchers() { - for (const [watchPath, watcher] of Object.entries(this.watchers)) { + const pending = []; + const collect = (watcher) => { try { - watcher?.close(); + const result = watcher?.close(); + if (result && typeof result.then === 'function') pending.push(result); } catch (err) {} - } + }; + + for (const [watchPath, watcher] of Object.entries(this.watchers)) collect(watcher); this.watchers = {}; - for (const [watchPath, watcher] of Object.entries(this.environmentWatchers)) { - try { - watcher?.close(); - } catch (err) {} - } + for (const [watchPath, watcher] of Object.entries(this.environmentWatchers)) collect(watcher); this.environmentWatchers = {}; - dotEnvWatcher.closeAll(); + const dotEnvResult = dotEnvWatcher.closeAll(); + if (dotEnvResult && typeof dotEnvResult.then === 'function') pending.push(dotEnvResult); + + return Promise.allSettled(pending); } } diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 7ebb0ee0f..db1c7d509 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -123,11 +123,11 @@ const focusMainWindow = () => { } }; -const closeAllWatchers = () => { - collectionWatcher.closeAllWatchers(); - workspaceWatcher.closeAllWatchers(); - apiSpecWatcher.closeAllWatchers(); -}; +const closeAllWatchers = () => Promise.allSettled([ + collectionWatcher.closeAllWatchers(), + workspaceWatcher.closeAllWatchers(), + apiSpecWatcher.closeAllWatchers() +]); // Parse protocol URL from command line arguments (if any) appProtocolUrl = getAppProtocolUrlFromArgv(process.argv); @@ -473,28 +473,47 @@ app.on('ready', async () => { registerOpenAPISyncIpc(mainWindow); }); -// Quit the app once all windows are closed -app.on('before-quit', () => { - closeAllWatchers(); - // Release single instance lock to allow other instances to take over - if (useSingleInstance && gotTheLock) { - app.releaseSingleInstanceLock(); - } +// Quit the app once all windows are closed. +// +// We defer the actual exit until async cleanup (chokidar fsevents handles) +// finishes — otherwise the main process exits while native watcher cleanup +// is mid-flight, and Chromium helper processes can detect the broken IPC +// channel and abort(), producing the macOS "quit unexpectedly" dialog. +let quitInProgress = false; +app.on('before-quit', (event) => { + if (quitInProgress) return; + quitInProgress = true; + event.preventDefault(); - try { - cookiesStore.saveCookieJar(true); - } catch (err) { - console.warn('Failed to flush cookies on quit', err); - } + (async () => { + try { + await Promise.race([ + closeAllWatchers(), + // Cap the wait so a stuck watcher can't block exit indefinitely. + new Promise((resolve) => setTimeout(resolve, 2000)) + ]); + } catch {} - // Stop system monitoring - systemMonitor.stop(); + if (useSingleInstance && gotTheLock) { + try { app.releaseSingleInstanceLock(); } catch {} + } - try { - terminalManager.killAll(); - } catch (err) { - console.error('Failed to kill all terminals on quit', err); - } + try { + cookiesStore.saveCookieJar(true); + } catch (err) { + console.warn('Failed to flush cookies on quit', err); + } + + systemMonitor.stop(); + + try { + terminalManager.killAll(); + } catch (err) { + console.error('Failed to kill all terminals on quit', err); + } + + app.exit(0); + })(); }); app.on('window-all-closed', app.quit); diff --git a/playwright/index.ts b/playwright/index.ts index 0bb47afff..456e2b375 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -87,57 +87,56 @@ async function usePageWithTracing( try { await testInfo.attach('trace', { path: tracePath }); } catch { } } -/** - * Gracefully close an Electron app by telling it to exit with code 0. - * This avoids the macOS "quit unexpectedly" crash dialog that appears when - * app.context().close() kills subprocesses (renderer/GPU) abruptly before - * the main process can shut down cleanly. - * - * Emits 'before-quit' first so cleanup handlers run (e.g., saving cookies to disk), - * since app.exit() bypasses all lifecycle events. - */ -/** - * Bound a promise so a hung Electron process can't burn the worker teardown - * budget. Resolves to `undefined` on timeout — the caller is expected to - * fall back to `app.close()` (which itself is bounded below) or SIGKILL. - */ -function withTimeout(promise: Promise, ms: number): Promise { +// Sentinel returned by `withTimeout` when the deadline fires before the wrapped +// promise resolves. Using a unique symbol lets callers distinguish a real +// timeout from a promise that legitimately resolved with `undefined` +// (e.g. `Promise` from `app.close()`). +const WITH_TIMEOUT = Symbol('withTimeout/timeout'); + +function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve) => { - const timer = setTimeout(() => resolve(undefined), ms); + const timer = setTimeout(() => resolve(WITH_TIMEOUT), ms); promise.then( (v) => { clearTimeout(timer); resolve(v); }, () => { - clearTimeout(timer); resolve(undefined); + clearTimeout(timer); resolve(undefined as T); } ); }); } +/** + * Close an Electron app gracefully so macOS Crash Reporter doesn't fire. + * + * Strategy: close all BrowserWindows from inside the main process. The + * default `window-all-closed` handler then triggers `app.quit()` → + * `before-quit` → `will-quit` → clean exit. Helper processes (renderer/GPU) + * shut down via the normal IPC handshake instead of detecting a broken + * channel and aborting — that abort is what produced the "Electron quit + * unexpectedly" dialog under the previous `app.exit(0)` approach. + * + * Each step is bounded so a wedged process can't burn the worker teardown + * budget. SIGKILL is only sent if the process is genuinely still alive + * after the graceful path has timed out. + */ export async function closeElectronApp(app: ElectronApplication) { - // Bound the graceful-quit roundtrip: if the renderer is unresponsive, - // `app.evaluate` can hang indefinitely (the inner `setTimeout(250)` runs in - // the renderer, which we can't observe externally). await withTimeout( - app.evaluate(async ({ app }) => { - app.emit('before-quit'); - await new Promise((resolve) => setTimeout(resolve, 250)); - app.exit(0); - }).catch(() => { /* process may have exited before CDP responded */ }), + app.evaluate(({ BrowserWindow }) => { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.close(); + } + }).catch(() => { /* CDP may have closed already */ }), 3000 ); - // Bound `app.close()` too — it waits for the process to exit, and a wedged - // main process would otherwise hold the worker teardown open until the - // 30s default fires. const closed = await withTimeout( app.close().catch(() => { /* already exited */ }), - 3000 + 5000 ); - if (closed === undefined) { - // Last resort: kill the underlying process so the worker can move on. + if (closed === WITH_TIMEOUT) { try { app.process()?.kill('SIGKILL'); } catch { /* already dead */ } } }