chore: make closeAllWatchers return promises and app before-quite handler async-aware

This commit is contained in:
Bijin A B
2026-05-14 13:30:02 +05:30
parent 0e762abddf
commit e9bf63da6d
6 changed files with 105 additions and 72 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
// 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<void>` from `app.close()`).
const WITH_TIMEOUT = Symbol('withTimeout/timeout');
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | typeof WITH_TIMEOUT> {
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 */ }
}
}