diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 70536e64986..33e15964677 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -585,7 +585,10 @@ describe('DaemonServer', () => { }) describe('idle self-exit', () => { - async function startServerWithIdleExit(graceMs: number): Promise<{ + async function startServerWithIdleExit( + graceMs: number, + opts: { beforeStart?: (srv: DaemonServer) => Promise } = {} + ): Promise<{ idleExited: Promise onIdleExit: ReturnType lastSubprocess: () => ReturnType | null @@ -606,6 +609,7 @@ describe('DaemonServer', () => { onIdleExit, idleExitGraceMs: graceMs }) + await opts.beforeStart?.(server) await server.start() return { idleExited, onIdleExit, lastSubprocess: () => subprocess } } @@ -684,15 +688,20 @@ describe('DaemonServer', () => { }) it('never fires while a session is alive and exits after the last session ends', async () => { - const { idleExited, onIdleExit, lastSubprocess } = await startServerWithIdleExit(75) - const daemon = server as unknown as DaemonServerPrivate - - // Create a session with zero connected clients — the exact state the - // daemon exists for (holding sessions across app restarts). - await daemon.routeRequest('ghost-client', { - id: 'req-idle-1', - type: 'createOrAttach', - payload: { sessionId: 'idle-session', cols: 80, rows: 24 } + // Why seed the session before start(): start() is what arms the + // countdown, so with a session already present the timer never arms at + // all. The invariant is proven deterministically instead of racing a + // cancel against a short grace on a loaded machine. + const { idleExited, onIdleExit, lastSubprocess } = await startServerWithIdleExit(75, { + beforeStart: async (srv) => { + // A session with zero connected clients — the exact state the + // daemon exists for (holding sessions across app restarts). + await (srv as unknown as DaemonServerPrivate).routeRequest('ghost-client', { + id: 'req-idle-1', + type: 'createOrAttach', + payload: { sessionId: 'idle-session', cols: 80, rows: 24 } + }) + } }) expect(idleArmed()).toBe(false) diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 0f575f34003..b0fe2797a1d 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -516,4 +516,45 @@ describe('TerminalHost', () => { expect(liveSub.dispose).toHaveBeenCalled() }) }) + + describe('onSessionCountChange', () => { + // Why: the daemon's idle-exit countdown arms/cancels purely off this hook; + // a missed notification on any transition silently disables idle exit (or + // worse, lets it fire with a session alive). Pin every transition. + it('notifies on create, natural-exit reap, and dispose', async () => { + const onSessionCountChange = vi.fn() + host.dispose() + host = new TerminalHost({ + spawnSubprocess: spawnFn as MockSpawnFn, + onSessionCountChange + }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(host.sessionCount()).toBe(1) + expect(onSessionCountChange).toHaveBeenCalledTimes(1) + + // Natural exit reaps synchronously — this is the transition that lets + // an otherwise-empty daemon start its idle countdown. + lastSubprocess._onExitCb?.(0) + expect(host.sessionCount()).toBe(0) + expect(onSessionCountChange).toHaveBeenCalledTimes(2) + + await host.createOrAttach({ + sessionId: 'session-2', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(onSessionCountChange).toHaveBeenCalledTimes(3) + + host.dispose() + expect(host.sessionCount()).toBe(0) + expect(onSessionCountChange).toHaveBeenCalledTimes(4) + }) + }) })