test(daemon): deflake idle-exit alive-session test; pin onSessionCountChange wiring

Seed the session before server.start() arms the countdown so the invariant
is proven by the timer never arming, instead of racing a cancel against a
75ms grace on a loaded machine. Add a deterministic TerminalHost
onSessionCountChange wiring test covering create, natural-exit reap, and
dispose (mutation-verified: deleting the create-side notify fails it).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-07-10 17:36:42 -07:00
co-authored by Orca
parent 4d86b280c6
commit 43e1267a16
2 changed files with 60 additions and 10 deletions
+19 -10
View File
@@ -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<void> } = {}
): Promise<{
idleExited: Promise<void>
onIdleExit: ReturnType<typeof vi.fn>
lastSubprocess: () => ReturnType<typeof createMockSubprocess> | 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)
+41
View File
@@ -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)
})
})
})