From f43bf4f90e337e3468bcef7216dee12f5d3ad504 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:50:21 -0400 Subject: [PATCH] fix: close orphaned dev Electron app after Ctrl+C (#498) --- src/main/startup/configure-process.test.ts | 11 +++++++++++ src/main/startup/configure-process.ts | 21 +++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 00e79120347..3967068c93b 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -10,6 +10,7 @@ vi.mock('electron', () => { paths.set(name, value) }), quit: vi.fn(), + exit: vi.fn(), commandLine: { appendSwitch: vi.fn() } @@ -50,6 +51,7 @@ describe('installDevParentDisconnectQuit', () => { const { app } = await import('electron') const { installDevParentDisconnectQuit } = await import('./configure-process') + vi.useFakeTimers() const originalSend = process.send const originalOnce = process.once.bind(process) const disconnectHandlers: (() => void)[] = [] @@ -74,6 +76,10 @@ describe('installDevParentDisconnectQuit', () => { expect(disconnectHandlers).toHaveLength(1) disconnectHandlers[0]() expect(app.quit).toHaveBeenCalledTimes(1) + expect(app.exit).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(3000) + expect(app.exit).toHaveBeenCalledWith(0) }) it('does not register the disconnect hook outside dev ipc launches', async () => { @@ -104,6 +110,7 @@ describe('installDevParentWatchdog', () => { vi.useFakeTimers() vi.mocked(app.quit).mockClear() + vi.mocked(app.exit).mockClear() let parentExists = true vi.spyOn(process, 'kill').mockImplementation((( @@ -132,6 +139,10 @@ describe('installDevParentWatchdog', () => { parentExists = false await vi.advanceTimersByTimeAsync(1000) expect(app.quit).toHaveBeenCalledTimes(1) + expect(app.exit).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(3000) + expect(app.exit).toHaveBeenCalledWith(0) } finally { if (originalPpid) { Object.defineProperty(process, 'ppid', originalPpid) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 5f55fa2caf9..c4c6029a3f2 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,6 +1,23 @@ import { app } from 'electron' import { join } from 'path' +const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 + +function requestDevParentShutdown(): void { + app.quit() + + const forceExitTimer = setTimeout(() => { + // Why: in dev, losing the supervising parent means this Electron process is + // already orphaned from the terminal session. We try app.quit() first so + // normal cleanup still runs, but fall back to app.exit() when macOS quit + // handlers or window-close guards stall and would otherwise leave Orca + // hanging after Ctrl+C ends `pnpm dev`. + app.exit(0) + }, DEV_PARENT_SHUTDOWN_GRACE_MS) + + forceExitTimer.unref() +} + export function installUncaughtPipeErrorGuard(): void { process.on('uncaughtException', (error) => { if ( @@ -66,7 +83,7 @@ export function installDevParentDisconnectQuit(isDev: boolean): void { // without terminating the app window, so in dev we quit explicitly when the // supervising IPC channel disconnects instead of leaving a stray Electron app. process.once('disconnect', () => { - app.quit() + requestDevParentShutdown() }) } @@ -106,7 +123,7 @@ export function installDevParentWatchdog(isDev: boolean): void { // the dev runner while leaving Orca open. Watching the original parent PID // keeps dev shutdown coupled to the terminal session without affecting the // packaged app, which is not supervised by electron-vite. - app.quit() + requestDevParentShutdown() } }, 1000)