From 79c1e8fc0e1a0a06cc9a6e079eec462ffc7e10dd Mon Sep 17 00:00:00 2001 From: indrajeetor Date: Sat, 5 Sep 2026 18:54:35 -0400 Subject: [PATCH] fix: stop the native run loop on quit, not will-quit, so a vetoed quit keeps the app alive --- src/main/api/app.ts | 4 +- src/main/bootstrap.ts | 5 ++- tests/helpers/safe-app-exit.ts | 13 ++++++ .../integration/macos/browser-window.test.ts | 10 +++-- tests/integration/macos/window-events.test.ts | 10 +++-- tests/unit/main/bootstrap.test.ts | 42 +++++++++++++++++++ website/src/content/docs/api/app.md | 4 +- website/src/content/docs/changelog.md | 1 + 8 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/main/api/app.ts b/src/main/api/app.ts index 871f1da..1140516 100644 --- a/src/main/api/app.ts +++ b/src/main/api/app.ts @@ -311,8 +311,8 @@ export class App extends EventEmitter { * Begin shutting the app down. Emits the cancelable `before-quit` then * `will-quit` events (a listener may call `preventDefault()` on the passed * event to abort the quit); if neither vetoes, emits `quit` with the exit code - * and exits the process. The native bootstrap listens for `will-quit` to stop - * the run loop before the process exits. + * and exits the process. The native bootstrap listens for `quit` to stop the + * run loop before the process exits. */ quit(exitCode = 0): void { if (this.#quitting) { diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index d855774..6019853 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -46,4 +46,7 @@ export const resetBootstrapForTesting = (): void => { }; app.setStartHook(ensureNativeStarted); -app.on('will-quit', () => nativeApp().quit()); +// `quit` fires only after `before-quit` and `will-quit` had their chance to veto, +// so a vetoed quit leaves the run loop pumping (stopping it on `will-quit` killed +// every later native callback in an app that cancelled its own quit). +app.on('quit', () => nativeApp().quit()); diff --git a/tests/helpers/safe-app-exit.ts b/tests/helpers/safe-app-exit.ts index 5dbbd82..e24ba2f 100644 --- a/tests/helpers/safe-app-exit.ts +++ b/tests/helpers/safe-app-exit.ts @@ -38,3 +38,16 @@ export const installSafeAppExit = (): void => { }), ); }; + +/** + * Behave like a real macOS app: a `window-all-closed` listener suppresses the + * default quit, so closing the last window does not stop the native run loop. + * Returns the disposer; call it in `afterAll`. + */ +export const keepAppAlive = (): (() => void) => { + const listener = (): void => undefined; + app.on('window-all-closed', listener); + return () => { + app.removeListener('window-all-closed', listener); + }; +}; diff --git a/tests/integration/macos/browser-window.test.ts b/tests/integration/macos/browser-window.test.ts index 1720c00..8dee2a3 100644 --- a/tests/integration/macos/browser-window.test.ts +++ b/tests/integration/macos/browser-window.test.ts @@ -3,22 +3,26 @@ import { currentPlatform } from '../../../src/common/platform'; import { BrowserWindow } from '../../../src/main/api/browser-window'; import { resetBootstrapForTesting } from '../../../src/main/bootstrap'; import { nativeApp, setNativeAppForTesting } from '../../../src/main/native-app'; -import { installSafeAppExit } from '../../helpers/safe-app-exit'; +import { installSafeAppExit, keepAppAlive } from '../../helpers/safe-app-exit'; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +let stopKeepAlive = (): void => undefined; + if (currentPlatform() === 'macos') { describe('BrowserWindow on the real macOS backend', () => { beforeAll(() => { // Ensure the real native backend is used (other suites may have injected a fake). setNativeAppForTesting(undefined); resetBootstrapForTesting(); - // Closing the last window triggers the window-all-closed default quit; - // keep it from terminating the shared test process. + // A real app keeps running after its last window closes; without the + // listener the default quit would stop the run loop under later tests. installSafeAppExit(); + stopKeepAlive = keepAppAlive(); }); afterAll(() => { + stopKeepAlive(); nativeApp().quit(); }); diff --git a/tests/integration/macos/window-events.test.ts b/tests/integration/macos/window-events.test.ts index 6c6426d..163bb0d 100644 --- a/tests/integration/macos/window-events.test.ts +++ b/tests/integration/macos/window-events.test.ts @@ -3,7 +3,7 @@ import { currentPlatform } from '../../../src/common/platform'; import { BrowserWindow } from '../../../src/main/api/browser-window'; import { resetBootstrapForTesting } from '../../../src/main/bootstrap'; import { nativeApp, setNativeAppForTesting } from '../../../src/main/native-app'; -import { installSafeAppExit } from '../../helpers/safe-app-exit'; +import { installSafeAppExit, keepAppAlive } from '../../helpers/safe-app-exit'; /** * BrowserWindow lifecycle events + the close-path teardown on a REAL NSWindow. @@ -21,17 +21,21 @@ import { installSafeAppExit } from '../../helpers/safe-app-exit'; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +let stopKeepAlive = (): void => undefined; + if (currentPlatform() === 'macos') { describe('BrowserWindow lifecycle events on the real macOS backend', () => { beforeAll(() => { setNativeAppForTesting(undefined); resetBootstrapForTesting(); - // Closing the last window triggers the window-all-closed default quit; - // keep it from terminating the shared test process. + // A real app keeps running after its last window closes; without the + // listener the default quit would stop the run loop under later tests. installSafeAppExit(); + stopKeepAlive = keepAppAlive(); }); afterAll(() => { + stopKeepAlive(); nativeApp().quit(); }); diff --git a/tests/unit/main/bootstrap.test.ts b/tests/unit/main/bootstrap.test.ts index 65d0b95..c1e8de6 100644 --- a/tests/unit/main/bootstrap.test.ts +++ b/tests/unit/main/bootstrap.test.ts @@ -111,3 +111,45 @@ describe('bootstrap native wiring', () => { expect(seen).toBe('/Users/ada/doc.txt'); }); }); + +describe('bootstrap quit wiring', () => { + afterEach(() => { + setNativeAppForTesting(undefined); + app.resetForTesting(); + resetBootstrapForTesting(); + }); + + const withCountingNative = (): { quits: () => number } => { + let quits = 0; + const { native } = makeNative(); + setNativeAppForTesting({ + ...native, + quit: () => { + quits += 1; + }, + }); + installSafeAppExit(); + ensureNativeStarted(); + return { quits: () => quits }; + }; + + test('a will-quit veto leaves the native run loop running', () => { + const counting = withCountingNative(); + const veto = (event: { preventDefault(): void }): void => { + event.preventDefault(); + }; + app.on('will-quit', veto); + try { + app.quit(); + expect(counting.quits()).toBe(0); + } finally { + app.removeListener('will-quit', veto); + } + }); + + test('a completed quit stops the native run loop once', () => { + const counting = withCountingNative(); + app.quit(); + expect(counting.quits()).toBe(1); + }); +}); diff --git a/website/src/content/docs/api/app.md b/website/src/content/docs/api/app.md index 52f7f61..04afa02 100644 --- a/website/src/content/docs/api/app.md +++ b/website/src/content/docs/api/app.md @@ -388,7 +388,7 @@ Returns: * `event` Event -Emitted after `before-quit` is not vetoed, immediately before the app quits. Calling `event.preventDefault()` aborts the quit. The native bootstrap also listens for this to stop the run loop before the process exits. +Emitted after `before-quit` is not vetoed, immediately before the app quits. Calling `event.preventDefault()` aborts the quit and the app keeps running, run loop included. ```ts import { app } from 'bunmaska' @@ -404,7 +404,7 @@ Returns: * `event` Event - the exit code (Integer). -Emitted when the application is quitting, just before the process exits. Unlike most events, the listener receives the numeric exit code as its argument. +Emitted when the application is quitting, just before the process exits. Unlike most events, the listener receives the numeric exit code as its argument. The native bootstrap listens for this event to stop the run loop before the process exits; a vetoed quit never reaches it. ```ts import { app } from 'bunmaska' diff --git a/website/src/content/docs/changelog.md b/website/src/content/docs/changelog.md index b894e74..c59ff68 100644 --- a/website/src/content/docs/changelog.md +++ b/website/src/content/docs/changelog.md @@ -12,6 +12,7 @@ A documentation-versus-reality pass: every claim in the docs was checked against **Fixes** +- A `will-quit` (or `before-quit`) veto keeps the app alive for real: the native run loop used to be stopped on `will-quit` before the veto was honoured, so an app that cancelled its own quit stopped receiving native callbacks. The run loop now stops on `quit`, which only fires once no listener vetoed. - `bunmaska init my-app .` works - `init` takes `[name] [dir]`, so a named project can scaffold into the current directory. - A dev restart no longer steals focus from your editor: the respawned app comes up behind whatever is frontmost (macOS, Linux, Windows). - `bunmaska build` honours `name`, `id` and `icon` from `bunmaska.config.ts` (flag > config > entry file name). It used to read only the flags.