diff --git a/src/__tests__/main/app-lifecycle/quit-handler.test.ts b/src/__tests__/main/app-lifecycle/quit-handler.test.ts index f6ed5a3c2..ffd9e4164 100644 --- a/src/__tests__/main/app-lifecycle/quit-handler.test.ts +++ b/src/__tests__/main/app-lifecycle/quit-handler.test.ts @@ -299,8 +299,13 @@ describe('app-lifecycle/quit-handler', () => { expect(mockHistoryManager.stopWatching).toHaveBeenCalled(); expect(deps.stopCliWatcher).toHaveBeenCalled(); expect(deps.stopSessionCleanup).toHaveBeenCalled(); - expect(mockPowerManager.clearAllReasons).toHaveBeenCalled(); expect(mockProcessManager.killAll).toHaveBeenCalled(); + // clearAllReasons must be called AFTER killAll to prevent late process + // output from re-arming the sleep blocker + expect(mockPowerManager.clearAllReasons).toHaveBeenCalled(); + const killOrder = mockProcessManager.killAll.mock.invocationCallOrder[0]; + const clearOrder = mockPowerManager.clearAllReasons.mock.invocationCallOrder[0]; + expect(killOrder).toBeLessThan(clearOrder); expect(mockTunnelManager.stop).toHaveBeenCalled(); expect(mockWebServer.stop).toHaveBeenCalled(); expect(deps.closeStatsDB).toHaveBeenCalled(); @@ -366,6 +371,77 @@ describe('app-lifecycle/quit-handler', () => { expect(() => beforeQuitHandler!(mockEvent)).not.toThrow(); }); + it('should force-quit after safety timeout if renderer never responds', async () => { + vi.useFakeTimers(); + + const { createQuitHandler } = await import('../../../main/app-lifecycle/quit-handler'); + + const quitHandler = createQuitHandler(deps as Parameters[0]); + quitHandler.setup(); + + const mockEvent = { preventDefault: vi.fn() }; + beforeQuitHandler!(mockEvent); + + // Renderer was asked for confirmation + expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('app:requestQuitConfirmation'); + expect(mockQuit).not.toHaveBeenCalled(); + + // Advance past the 5s timeout without renderer responding + vi.advanceTimersByTime(5000); + + expect(mockQuit).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('timed out'), 'Window'); + + vi.useRealTimers(); + }); + + it('should clear safety timeout when renderer confirms quit', async () => { + vi.useFakeTimers(); + + const { createQuitHandler } = await import('../../../main/app-lifecycle/quit-handler'); + + const quitHandler = createQuitHandler(deps as Parameters[0]); + quitHandler.setup(); + + const mockEvent = { preventDefault: vi.fn() }; + beforeQuitHandler!(mockEvent); + + // Renderer confirms before timeout + const confirmHandler = ipcHandlers.get('app:quitConfirmed')!; + confirmHandler(); + + // mockQuit called once from confirmHandler + expect(mockQuit).toHaveBeenCalledTimes(1); + + // Advance past timeout — should NOT trigger a second quit + vi.advanceTimersByTime(5000); + expect(mockQuit).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('should clear safety timeout when renderer cancels quit', async () => { + vi.useFakeTimers(); + + const { createQuitHandler } = await import('../../../main/app-lifecycle/quit-handler'); + + const quitHandler = createQuitHandler(deps as Parameters[0]); + quitHandler.setup(); + + const mockEvent = { preventDefault: vi.fn() }; + beforeQuitHandler!(mockEvent); + + // Renderer cancels + const cancelHandler = ipcHandlers.get('app:quitCancelled')!; + cancelHandler(); + + // Advance past timeout — should NOT force quit + vi.advanceTimersByTime(5000); + expect(mockQuit).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + it('should work without stopCliWatcher dependency', async () => { const depsWithoutCliWatcher = { ...deps }; delete depsWithoutCliWatcher.stopCliWatcher; diff --git a/src/main/app-lifecycle/quit-handler.ts b/src/main/app-lifecycle/quit-handler.ts index 4c59280f7..c51ca0dfa 100644 --- a/src/main/app-lifecycle/quit-handler.ts +++ b/src/main/app-lifecycle/quit-handler.ts @@ -207,9 +207,6 @@ export function createQuitHandler(deps: QuitHandlerDependencies): QuitHandler { stopSessionCleanup(); } - // Clear power save blocker to release OS-level sleep prevention state - powerManager.clearAllReasons(); - // Clean up active grooming sessions (context merge/transfer operations) const processManager = getProcessManager(); const groomingSessionCount = getActiveGroomingSessionCount(); @@ -225,6 +222,10 @@ export function createQuitHandler(deps: QuitHandlerDependencies): QuitHandler { logger.info('Killing all running processes', 'Shutdown'); processManager?.killAll(); + // Clear power save blocker AFTER killAll() to prevent late process output + // from re-arming the blocker via addBlockReason() + powerManager.clearAllReasons(); + // Stop tunnel and web server (fire and forget) logger.info('Stopping tunnel', 'Shutdown'); tunnelManager.stop().catch((err: unknown) => {