From eed2c148996d0407dbc0bbf812262a17bcc85aa1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:53:42 -0700 Subject: [PATCH] fix(main): schedule the relaunch exit pair once per process Each app.relaunch() registration starts another replacement instance after exit, and renderer-side click guards cannot see each other across surfaces (error-boundary Restart, hydration toast, settings panes). Gate the schedule in the app:relaunch handler so concurrent or repeated invokes commit exactly one relaunch+exit. --- src/main/ipc/app.test.ts | 17 +++++++++++++++++ src/main/ipc/app.ts | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e52668e1ce9..21fe57252fb 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -228,6 +228,23 @@ describe('registerAppHandlers', () => { expect(appExitMock).toHaveBeenCalledWith(0) }) + it('schedules a single relaunch exit no matter how many surfaces invoke it', async () => { + registerAppHandlers({} as never, {}) + + // Two surfaces (error-boundary Restart, hydration toast) can race a relaunch; + // each app.relaunch() registration would start another replacement instance. + const first = Promise.resolve(handlers.get('app:relaunch')?.(null)) + const second = Promise.resolve(handlers.get('app:relaunch')?.(null)) + await first + await second + const third = Promise.resolve(handlers.get('app:relaunch')?.(null)) + await third + await vi.advanceTimersByTimeAsync(150) + + expect(relaunchAppMock).toHaveBeenCalledTimes(1) + expect(appExitMock).toHaveBeenCalledTimes(1) + }) + it('marks restart as expected shutdown before quitting through the normal pipeline', async () => { const onBeforeRelaunch = vi.fn() registerAppHandlers({} as never, { onBeforeRelaunch }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 17b7663e11b..a168d2e351e 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -288,9 +288,19 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('app:getKeyboardLayoutSnapshot', () => readMacKeyboardLayoutSnapshot()) + // Every app.relaunch() registration starts another replacement instance after + // exit, and renderer-side click guards cannot see each other across surfaces + // (error-boundary Restart, hydration toast, settings panes). Once the exit + // pair is scheduled the process is committed, so later invokes must not + // schedule a second one. + let relaunchExitScheduled = false ipcMain.handle('app:relaunch', async () => { // Why: brief delay lets the renderer paint "Restarting…" before the window tears down. await runBeforeRelaunchCleanup(options.onBeforeRelaunch) + if (relaunchExitScheduled) { + return + } + relaunchExitScheduled = true setTimeout(() => { // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. destroySystemTray()