Files
orca/src/main/daemon/session.test.ts
T

971 lines
34 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session } from './session'
import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller'
import { HeadlessEmulator } from './headless-emulator'
import type { SessionState, ShellReadyState } from './types'
import type { TuiAgent } from '../../shared/tui-agent'
const killWithDescendantSweepMock = vi.hoisted(() => vi.fn())
vi.mock('../pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// Stub the subprocess — Session talks to it via an interface, not child_process directly.
function createMockSubprocess() {
const written: string[] = []
const signals: string[] = []
let onData: ((data: string) => void) | null = null
let onExit: ((code: number) => void) | null = null
let killed = false
let clearCalls = 0
let pid = 12345
let pauseCalls = 0
let resumeCalls = 0
return {
written,
signals,
get killed() {
return killed
},
get pid() {
return pid
},
get pauseCalls() {
return pauseCalls
},
get resumeCalls() {
return resumeCalls
},
foregroundProcess: null as string | null,
getForegroundProcess(): string | null {
return this.foregroundProcess
},
confirmShellForeground: vi.fn(async () => true),
write(data: string) {
written.push(data)
},
resize(_cols: number, _rows: number) {},
pause() {
pauseCalls++
},
resume() {
resumeCalls++
},
get clearCalls() {
return clearCalls
},
clear() {
clearCalls++
},
kill() {
killed = true
// Simulate async exit
setTimeout(() => onExit?.(0), 5)
},
terminateOwnedTree: () => 'terminated' as const,
forceKill() {
killed = true
},
signal(sig: string) {
signals.push(sig)
},
onData(cb: (data: string) => void) {
onData = cb
},
onExit(cb: (code: number) => void) {
onExit = cb
},
dispose() {},
// Helpers for tests to simulate subprocess events
simulateData(data: string) {
onData?.(data)
},
simulateExit(code: number) {
onExit?.(code)
}
}
}
type MockSubprocess = ReturnType<typeof createMockSubprocess>
describe('Session', () => {
let session: Session
let subprocess: MockSubprocess
beforeEach(() => {
vi.useFakeTimers()
subprocess = createMockSubprocess()
killWithDescendantSweepMock.mockReset()
})
afterEach(() => {
session?.dispose()
vi.useRealTimers()
})
function createSession(opts?: {
shellReadySupported?: boolean
shellReadyTimeoutMs?: number
cols?: number
rows?: number
launchAgent?: TuiAgent
startupIngress?: {
colors: { foreground: string; background: string }
deadlineMs: number
}
ownerBackend?: 'posix-pty' | 'windows-conpty' | 'windows-wsl'
wslDistro?: string
reportReadinessEvent?: (event: string, details: Record<string, unknown>) => void
historySeedChunks?: readonly string[]
}): Session {
session = new Session({
sessionId: 'test-session',
...(opts?.reportReadinessEvent ? { reportReadinessEvent: opts.reportReadinessEvent } : {}),
cols: opts?.cols ?? 80,
rows: opts?.rows ?? 24,
...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}),
wslDistro: opts?.wslDistro,
subprocess,
historySeedChunks: opts?.historySeedChunks,
...(opts?.ownerBackend ? { ownerBackend: opts.ownerBackend } : {}),
shellReadySupported: opts?.shellReadySupported ?? false,
...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}),
...(opts?.shellReadyTimeoutMs !== undefined
? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs }
: {})
})
return session
}
describe('state machine', () => {
it('starts in running state when shell readiness is not supported', () => {
createSession({ shellReadySupported: false })
expect(session.state).toBe('running' satisfies SessionState)
expect(session.shellState).toBe('unsupported' satisfies ShellReadyState)
})
it('starts in running state with pending shell when readiness is supported', () => {
createSession({ shellReadySupported: true })
expect(session.state).toBe('running')
expect(session.shellState).toBe('pending' satisfies ShellReadyState)
})
it('transitions to exited when subprocess exits', () => {
createSession()
subprocess.simulateExit(0)
expect(session.state).toBe('exited' satisfies SessionState)
expect(session.isAlive).toBe(false)
})
it('tracks exit code', () => {
createSession()
subprocess.simulateExit(42)
expect(session.exitCode).toBe(42)
})
})
describe('data flow', () => {
it('captures and strips the complete WSL shell anchor from live output', () => {
createSession({ wslDistro: 'Ubuntu' })
const marker =
'\x1b]777;orca-shell-start:v2:Ubuntu:01234567-89ab-cdef-0123-456789abcdef:123:456:/dev/pts/8\x07'
const received: string[] = []
session.attachClient({ onData: (data) => received.push(data), onExit: () => {} })
subprocess.simulateData(`before${marker}after`)
expect(received.join('')).toContain('beforeafter')
expect(received.join('')).not.toContain('orca-shell-start')
expect(session.getWslShellAnchor()).toEqual({
distro: 'Ubuntu',
bootId: '01234567-89ab-cdef-0123-456789abcdef',
shellPid: 123,
shellStartTime: 456,
tty: '/dev/pts/8'
})
})
it('drains a partial WSL identity marker when the subprocess exits', () => {
createSession({ wslDistro: 'Ubuntu' })
const received: string[] = []
session.attachClient({ onData: (data) => received.push(data), onExit: () => {} })
const partial = '\x1b]777;orca-shell-start:v2:Ubuntu:01234567'
subprocess.simulateData(partial)
subprocess.simulateExit(0)
expect(received.join('')).toContain(partial)
})
it('does not confirm shell ownership from historical replay bytes', () => {
createSession({
historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker']
})
expect(subprocess.confirmShellForeground).not.toHaveBeenCalled()
expect(session.getSnapshot()?.terminalOwner).toBeUndefined()
})
it('answers concurrent runtime confirmations from one episode inspection', async () => {
let resolveConfirmation: ((confirmed: boolean) => void) | undefined
subprocess.confirmShellForeground.mockImplementation(
() => new Promise((resolve) => void (resolveConfirmation = resolve))
)
createSession()
// Why no inspection without a candidate: the RPC reads the barrier's
// settled verdict; it must never mint proof the byte stream didn't ask for.
await expect(session.confirmShellForeground()).resolves.toBe(false)
expect(subprocess.confirmShellForeground).not.toHaveBeenCalled()
subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07')
const first = session.confirmShellForeground()
const second = session.confirmShellForeground()
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
resolveConfirmation?.(true)
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
})
it('reuses the parser confirmation for a concurrent runtime request', async () => {
let resolveConfirmation: ((confirmed: boolean) => void) | undefined
subprocess.confirmShellForeground.mockImplementation(
() => new Promise((resolve) => void (resolveConfirmation = resolve))
)
createSession()
subprocess.simulateData('\x1b[?1049hTUI\x1b]133;D;137\x07shell-marker')
const runtimeConfirmation = session.confirmShellForeground()
expect(subprocess.confirmShellForeground).toHaveBeenCalledTimes(1)
resolveConfirmation?.(true)
await expect(runtimeConfirmation).resolves.toBe(true)
await vi.waitFor(() => expect(session.getSnapshot()?.terminalOwner).toBe('shell'))
})
it('forwards subprocess data to attached clients', () => {
createSession()
const received: string[] = []
session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
subprocess.simulateData('hello')
expect(received).toEqual(['hello'])
})
it('does not deliver data to detached clients', () => {
createSession()
const received: string[] = []
const token = session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
session.detachClient(token)
subprocess.simulateData('should not arrive')
expect(received).toEqual([])
})
it('supports multiple attached clients', () => {
createSession()
const received1: string[] = []
const received2: string[] = []
session.attachClient({ onData: (d) => received1.push(d), onExit: () => {} })
session.attachClient({ onData: (d) => received2.push(d), onExit: () => {} })
subprocess.simulateData('broadcast')
expect(received1).toEqual(['broadcast'])
expect(received2).toEqual(['broadcast'])
})
it('classifies startup queries and cooked echoes before model, persistence, and fanout', () => {
createSession({
ownerBackend: 'windows-conpty',
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
const onData = vi.fn()
session.attachClient({ onData, onExit: () => {} })
const query = '\x1b]10;?\x07'
const echo = ']10;rgb:2e2e/3434/3434\\'
subprocess.simulateData(query)
subprocess.simulateData(echo)
subprocess.simulateData('prompt')
expect(subprocess.written).toEqual(['\x1b]10;rgb:2e2e/3434/3434\x1b\\'])
expect(onData.mock.calls).toEqual([
['', query.length, true, query.length],
['', echo.length, true, query.length + echo.length],
['prompt']
])
expect(session.takePendingOutput(false)?.records).toEqual([
{ kind: 'output', data: 'prompt' }
])
expect(session.getSnapshot()).toMatchObject({
outputSequence: query.length + echo.length + 'prompt'.length
})
expect(session.getSnapshot()?.snapshotAnsi).toContain('prompt')
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
})
it('releases a held cooked-echo prefix before taking a snapshot', () => {
createSession({
ownerBackend: 'windows-conpty',
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000
}
})
subprocess.simulateData('\x1b]10;?\x07')
subprocess.simulateData(']10;rgb:2e2e/')
const snapshot = session.getSnapshot()
expect(snapshot?.snapshotAnsi).toContain(']10;rgb:2e2e/')
expect(snapshot?.outputSequence).toBe('\x1b]10;?\x07]10;rgb:2e2e/'.length)
})
it('contains legacy paired-runtime reply echoes and removes their downstream producer', async () => {
const query = '\x1b]10;?\x07'
const reply = '\x1b]10;rgb:2e2e/3434/3434\x1b\\'
const projectedEcho = reply.replaceAll('\x1b', '^[')
createSession({ ownerBackend: 'posix-pty' })
session.closeStartupQueryAuthority()
const legacyReplyProducers: string[] = []
const legacyOnData = vi.fn((data: string) => {
if (data === query) {
legacyReplyProducers.push('remote-visible-renderer')
session.write(reply)
}
})
session.attachClient({ onData: legacyOnData, onExit: () => {} })
subprocess.simulateData(query)
expect(legacyReplyProducers).toEqual(['remote-visible-renderer'])
// Written in the calling turn — the echo is contained on the output side below,
// not by withholding the write.
expect(subprocess.written).toEqual([reply])
subprocess.simulateData(projectedEcho)
expect(legacyOnData.mock.calls).toEqual([
[query],
['', projectedEcho.length, true, query.length + projectedEcho.length]
])
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
session.dispose()
subprocess = createMockSubprocess()
createSession({ ownerBackend: 'windows-conpty' })
session.closeStartupQueryAuthority()
const fixedReplyProducers: string[] = []
const fixedOnData = vi.fn((data: string) => {
if (data === query) {
fixedReplyProducers.push('remote-visible-renderer')
session.write(reply)
}
})
session.attachClient({ onData: fixedOnData, onExit: () => {} })
subprocess.simulateData(query)
subprocess.simulateData('prompt')
expect(fixedReplyProducers).toEqual([])
expect(subprocess.written).toEqual([])
expect(fixedOnData.mock.calls).toEqual([['', query.length, true, query.length], ['prompt']])
expect(session.getSnapshot()?.snapshotAnsi).not.toContain(']10;rgb')
})
})
describe('write', () => {
it('forwards writes to subprocess when running', () => {
createSession({ shellReadySupported: false })
session.write('ls\n')
expect(subprocess.written).toEqual(['ls\n'])
})
})
describe('emulator does not reply to terminal queries', () => {
// Why: daemon emulator parses in-process synchronously — before
// handleSubprocessData forwards bytes onward — so any auto-reply it
// emits races ahead of the live answerer and clobbers it with
// default-xterm values (no theme, stale cursor). Query authority is
// structural (terminal-query-authority.md): a delivered chunk is
// answered by the consuming view's xterm, a hidden-dropped chunk by
// MAIN's runtime model responder. The daemon emulator is neither — it
// stays write-only forever, and these pins are permanent.
it.each([
['OSC 10 foreground-color', '\x1b]10;?\x07'],
['OSC 11 background-color', '\x1b]11;?\x07'],
['OSC 12 cursor-color', '\x1b]12;?\x1b\\'],
['DA1 device-attributes', '\x1b[c'],
['DA2 secondary device-attributes', '\x1b[>c'],
['DSR terminal status', '\x1b[5n'],
['DSR cursor-position', '\x1b[6n'],
['DECRPM bracketed-paste mode', '\x1b[?2004$p']
])('does not reply to %s query', async (_label, query) => {
createSession({ shellReadySupported: false })
subprocess.simulateData(query)
// xterm.js fires terminal.write's completion callback via a microtask;
// two resolved-promise awaits flush any nested scheduling.
await Promise.resolve()
await Promise.resolve()
expect(subprocess.written).toEqual([])
})
})
describe('shell readiness gating', () => {
// Why: the renderer's DA1 reply would be queued here, and a shell that withholds its
// first prompt until DA1 is answered never emits the marker that would release it.
it('answers DA1 once without forwarding it to a renderer', () => {
createSession({ shellReadySupported: true })
const onData = vi.fn((d: string) => d === '\x1b[0c' && session.write('\x1b[?1;2c'))
session.attachClient({ onData, onExit: () => {} })
subprocess.simulateData('\x1b[0c')
expect(onData).toHaveBeenCalledWith('', '\x1b[0c'.length, true, '\x1b[0c'.length)
expect(session.takePendingOutput(false)?.records).toEqual([])
expect(session.getSnapshot()?.outputSequence).toBe('\x1b[0c'.length)
subprocess.simulateData('\x1b]777;orca-shell-ready\x07prompt')
vi.advanceTimersByTime(30)
expect(subprocess.written).toEqual(['\x1b[?1;2c'])
})
// Why: regression guard for "claude claude" double-echo. The marker fires
// from precmd before readline switches the PTY into raw mode; flushing
// then lets the kernel re-echo the command under the prompt. Detailed
// timing behavior is covered by post-ready-flush-gate.test.ts.
// Also checks writes that arrive during the gate window keep their order
// — the gate continues to queue even though shellState is already 'ready'.
it('defers flush past the shell-ready marker and preserves write order', () => {
createSession({ shellReadySupported: true })
expect(session.shellState).toBe('pending')
session.write('first\n')
subprocess.simulateData('\x1b]777;orca-shell-ready\x07')
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
session.write('second\n')
expect(subprocess.written).toEqual([])
subprocess.simulateData('\r\nuser@host $ ')
vi.advanceTimersByTime(30)
expect(subprocess.written).toEqual(['first\n', 'second\n'])
})
it('contains live color replies without releasing queued startup input', async () => {
const reply = '\x1b[?997;1n'
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
session.write('codex\n')
session.write(reply)
await vi.advanceTimersByTimeAsync(0)
expect(subprocess.written).toEqual([reply])
await vi.advanceTimersByTimeAsync(100)
expect(subprocess.written).toEqual([reply, 'codex\n'])
})
it('uses the short settle path when marker and prompt bytes arrive together', () => {
createSession({ shellReadySupported: true })
session.write('codex\n')
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
vi.advanceTimersByTime(29)
expect(subprocess.written).toEqual([])
vi.advanceTimersByTime(1)
expect(subprocess.written).toEqual(['codex\n'])
})
it('does not treat bytes before the marker as post-marker prompt output', () => {
createSession({ shellReadySupported: true })
session.write('codex\n')
subprocess.simulateData('last login\r\n\x1b]777;orca-shell-ready\x07')
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
vi.advanceTimersByTime(30)
expect(subprocess.written).toEqual([])
subprocess.simulateData('\r\nuser@host $ ')
vi.advanceTimersByTime(30)
expect(subprocess.written).toEqual(['codex\n'])
})
it('strips shell-ready marker bytes before client and pending-output fan-out', () => {
createSession({ shellReadySupported: true })
const received: string[] = []
session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
subprocess.simulateData('hello \x1b]777;orca-shell-ready\x07% ')
expect(received).toEqual(['hello % '])
expect(session.takePendingOutput(false)?.records).toEqual([
{ kind: 'output', data: 'hello % ' }
])
expect(session.getSnapshot()?.snapshotAnsi).toContain('hello % ')
expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready')
})
it.each([
['after the ready marker', ['\x1b]777;orca-shell-ready\x07', '\x1b[?2004hfish> ']],
['after the ESC introducer', ['\x1b]777;orca-shell-ready\x07\x1b', '[?2004hfish> ']]
])('preserves Fish bracketed-paste output split %s', (_boundary, chunks) => {
createSession({ shellReadySupported: true })
const received: string[] = []
session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
for (const chunk of chunks) {
subprocess.simulateData(chunk)
}
const output = received.join('')
expect(output).toBe('\x1b[?2004hfish> ')
const rendered = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(rendered.writeSync(output)).toBe(true)
expect(rendered.getVisibleLines().join('\n')).not.toContain('[?2004h')
rendered.dispose()
})
it('publishes an absolute output sequence with live snapshots', () => {
createSession()
subprocess.simulateData('first')
subprocess.simulateData('🟢second')
expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length)
expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length)
})
it('releases held marker-prefix bytes before flushing queued input on timeout', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
const received: string[] = []
session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
subprocess.simulateData('\x1b]777;orca-shell-ready')
session.write('codex\n')
vi.advanceTimersByTime(100)
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
expect(received).toEqual(['\x1b]777;orca-shell-ready'])
expect(session.takePendingOutput(false)?.records).toEqual([
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
])
expect(subprocess.written).toEqual(['codex\n'])
})
it('releases held marker-prefix bytes when the subprocess exits before readiness', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
const received: string[] = []
session.attachClient({
onData: (data) => received.push(data),
onExit: () => {}
})
subprocess.simulateData('\x1b]777;orca-shell-ready')
subprocess.simulateExit(0)
expect(received).toEqual(['\x1b]777;orca-shell-ready'])
expect(session.takePendingOutput(false)?.records).toEqual([
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
])
})
it('exposes drained output beside an includeSnapshot take without changing records', () => {
createSession()
subprocess.simulateData('kept-for-durable-history\r\n')
const taken = session.takePendingOutput(true)
expect(taken?.records).toEqual([])
expect(taken?.drainedRecords).toEqual([
{ kind: 'output', data: 'kept-for-durable-history\r\n' }
])
expect(taken?.snapshot).toBeTruthy()
})
it('keeps held marker-prefix bytes during live take-with-snapshot', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
session.write('codex\n')
subprocess.simulateData('\x1b]777;orca-shell-ready')
const taken = session.takePendingOutput(true)
subprocess.simulateData('\x07\r\nuser@host $ ')
vi.advanceTimersByTime(30)
expect(taken?.records).toEqual([])
expect(taken?.drainedRecords).toEqual([])
expect(taken?.snapshot).toBeTruthy()
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
expect(subprocess.written).toEqual(['codex\n'])
})
it('releases held marker-prefix bytes before final take-with-snapshot', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
subprocess.simulateData('\x1b]777;orca-shell-ready')
const taken = session.takePendingOutput(true, { teardownSnapshot: true })
expect(taken?.records).toEqual([{ kind: 'output', data: '\x1b]777;orca-shell-ready' }])
expect(taken?.snapshot).toBeTruthy()
})
it('cancels the post-ready flush gate when force-disposing the subprocess', async () => {
createSession({ shellReadySupported: true })
session.write('codex\n')
subprocess.simulateData('\x1b]777;orca-shell-ready\x07')
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
const dispose = session.forceKillAndDisposeSubprocess()
subprocess.simulateExit(137)
await dispose
vi.advanceTimersByTime(500)
expect(subprocess.written).toEqual([])
})
it('transitions to timed_out after 15 seconds', () => {
createSession({ shellReadySupported: true })
session.write('waiting input')
vi.advanceTimersByTime(15_000)
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
expect(subprocess.written).toEqual(['waiting input'])
})
// Why this matters: the detached daemon runs with stdio 'ignore', so a
// console.warn here reaches nobody. This path costs every startup command
// the full timeout, and diagnosing it from a silent log is what made the
// original report expensive -- so it has to reach the daemon's file log.
it('reports the timeout to the daemon log rather than the void', () => {
const events: { event: string; details: Record<string, unknown> }[] = []
createSession({
shellReadySupported: true,
reportReadinessEvent: (event, details) => events.push({ event, details })
})
vi.advanceTimersByTime(15_000)
expect(events).toHaveLength(1)
expect(events[0]?.event).toBe('shell-ready-timeout')
expect(events[0]?.details).toMatchObject({ sessionId: 'test-session', timeoutMs: 15_000 })
// Why a basename: the shell path can carry a home dir, and the basename is
// all a diagnosis needs.
expect(String(events[0]?.details.shell)).not.toContain('/')
})
// Why: the report runs before the transition that releases held PTY bytes and
// flushes queued stdin, and the ready timer is already cleared by then. A
// throwing sink must not leave the barrier stuck in `pending` forever.
it('still releases the barrier when the diagnostic sink throws', () => {
createSession({
shellReadySupported: true,
reportReadinessEvent: () => {
throw new Error('log sink unavailable')
}
})
session.write('waiting input')
vi.advanceTimersByTime(15_000)
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
expect(subprocess.written).toEqual(['waiting input'])
})
it('honors a shorter shell-ready timeout for Codex startup sessions', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 300 })
session.write('codex\n')
vi.advanceTimersByTime(299)
expect(subprocess.written).toEqual([])
vi.advanceTimersByTime(1)
expect(session.shellState).toBe('timed_out' satisfies ShellReadyState)
expect(subprocess.written).toEqual(['codex\n'])
})
it('detects marker split across data chunks', () => {
createSession({ shellReadySupported: true })
subprocess.simulateData('\x1b]777;orca-sh')
expect(session.shellState).toBe('pending')
subprocess.simulateData('ell-ready\x07')
expect(session.shellState).toBe('ready')
})
})
describe('kill', () => {
it('kills the subprocess', () => {
createSession()
session.kill()
expect(subprocess.killed).toBe(true)
expect(session.isTerminating).toBe(true)
})
it('allows a graceful retry when the first kill is rejected', () => {
let attempts = 0
subprocess.kill = () => {
attempts++
if (attempts === 1) {
throw new Error('graceful kill rejected')
}
}
createSession()
expect(() => session.kill()).toThrow('graceful kill rejected')
expect(session.isTerminating).toBe(false)
expect(() => session.kill()).not.toThrow()
expect(attempts).toBe(2)
expect(session.isTerminating).toBe(true)
})
it('non-agent kill stays synchronous and never routes through the descendant sweep', () => {
createSession()
session.kill()
expect(subprocess.killed).toBe(true)
expect(killWithDescendantSweepMock).not.toHaveBeenCalled()
})
it('agent kill routes through the descendant sweep with the subprocess as root', () => {
createSession({ launchAgent: 'claude' })
session.kill()
expect(killWithDescendantSweepMock).toHaveBeenCalledWith(
subprocess.pid,
expect.any(Function),
expect.objectContaining({ ownsRoot: expect.any(Function) })
)
// The root kill is deferred to the sweep's snapshot-first sequencing.
expect(subprocess.killed).toBe(false)
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
killRoot()
expect(subprocess.killed).toBe(true)
})
it('agent kill hands the sweep the pty job, which outlives a reparented child', () => {
// A grandchild that detached leaves the shell's console and reparents, so
// the pid walk behind the sweep's fallback cannot see it. Only the job can.
createSession({ launchAgent: 'claude' })
session.kill()
const deps = killWithDescendantSweepMock.mock.calls[0][2] as {
terminateOwnedTree?: () => string
}
expect(deps.terminateOwnedTree?.()).toBe('terminated')
})
it('agent kill root callback is a no-op after the session already exited', () => {
createSession({ launchAgent: 'claude' })
session.kill()
const killRoot = killWithDescendantSweepMock.mock.calls[0][1] as () => void
subprocess.simulateExit(0)
killRoot()
expect(subprocess.killed).toBe(false)
})
it('notifies attached clients on exit after kill', async () => {
vi.useRealTimers()
createSession()
const exitCodes: number[] = []
session.attachClient({
onData: () => {},
onExit: (code) => exitCodes.push(code)
})
session.kill()
// Wait for the simulated async exit
await new Promise((r) => setTimeout(r, 20))
expect(exitCodes).toEqual([0])
})
it('force-kills after 5s but retains ownership until subprocess exit', () => {
createSession()
// Override kill to NOT trigger exit
subprocess.kill = () => {}
const forceKillSpy = vi.spyOn(subprocess, 'forceKill')
session.kill()
expect(session.state).not.toBe('exited')
vi.advanceTimersByTime(5_000)
expect(forceKillSpy).toHaveBeenCalled()
expect(session.state).toBe('running')
expect(session.isTerminating).toBe(true)
subprocess.simulateExit(137)
expect(session.state).toBe('exited')
})
it('retries a rejected destructive force kill while retaining ownership', async () => {
let forceKillAttempts = 0
subprocess.forceKill = () => {
forceKillAttempts++
if (forceKillAttempts === 1) {
throw new Error('force kill rejected')
}
}
createSession()
const shutdown = session.forceKillAndWaitForExit()
expect(forceKillAttempts).toBe(1)
await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS)
expect(forceKillAttempts).toBe(2)
subprocess.simulateExit(137)
await shutdown
expect(session.state).toBe('exited')
})
it('retries a rejected graceful-deadline force kill', async () => {
subprocess.kill = () => {}
let forceKillAttempts = 0
subprocess.forceKill = () => {
forceKillAttempts++
if (forceKillAttempts === 1) {
throw new Error('transient graceful fallback failure')
}
}
createSession()
session.kill()
await vi.advanceTimersByTimeAsync(5_000)
expect(forceKillAttempts).toBe(1)
expect(session.isTerminating).toBe(true)
await vi.advanceTimersByTimeAsync(SESSION_FORCE_KILL_RETRY_MS)
expect(forceKillAttempts).toBe(2)
subprocess.simulateExit(137)
expect(session.state).toBe('exited')
expect(vi.getTimerCount()).toBe(0)
})
it('keeps late data and the real exit code until physical exit', () => {
createSession()
subprocess.kill = () => {}
const onData = vi.fn()
const onExit = vi.fn()
session.attachClient({ onData, onExit })
session.kill()
vi.advanceTimersByTime(5_000)
subprocess.simulateData('late output')
subprocess.simulateExit(23)
expect(onData).toHaveBeenCalledWith('late output')
expect(onExit).toHaveBeenCalledTimes(1)
expect(onExit).toHaveBeenCalledWith(23, session.incarnationId, {
kind: 'exited',
exitCode: 23
})
expect(session.exitCode).toBe(23)
})
})
describe('signal', () => {
it('forwards signal to subprocess without entering terminating state', () => {
createSession()
session.signal('SIGINT')
expect(subprocess.signals).toEqual(['SIGINT'])
expect(session.isTerminating).toBe(false)
})
})
describe('snapshot', () => {
it('parses live OSC-7 output in the session WSL distro', () => {
createSession({ wslDistro: 'Ubuntu' })
subprocess.simulateData('\x1b]7;file://DESKTOP-ORCA/home/jin/repo\x07')
expect(session.getCwd()).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')
})
it('returns a terminal snapshot', async () => {
createSession()
subprocess.simulateData('$ hello\r\n')
// Give emulator time to process
await vi.advanceTimersByTimeAsync(10)
const snapshot = session.getSnapshot()
expect(snapshot).toBeDefined()
expect(snapshot!.cols).toBe(80)
expect(snapshot!.rows).toBe(24)
})
it('returns null after session is disposed', () => {
createSession()
session.dispose()
expect(session.getSnapshot()).toBeNull()
})
})
describe('resize', () => {
it('resizes the emulator and subprocess', () => {
createSession()
const resizeSpy = vi.spyOn(subprocess, 'resize')
session.resize(120, 40)
expect(resizeSpy).toHaveBeenCalledWith(120, 40)
})
it('same-dim resize passes through without tricks', () => {
createSession({ cols: 80, rows: 24 })
const resizeSpy = vi.spyOn(subprocess, 'resize')
session.resize(80, 24)
expect(resizeSpy).toHaveBeenCalledTimes(1)
expect(resizeSpy).toHaveBeenCalledWith(80, 24)
})
})
describe('detach token guard', () => {
it('ignores stale detach with wrong token', () => {
createSession()
const received: string[] = []
const token1 = session.attachClient({
onData: (d) => received.push(d),
onExit: () => {}
})
// Attach a second client (same conceptual slot but new token)
session.attachClient({
onData: (d) => received.push(d),
onExit: () => {}
})
// Try detaching with the old token — should only remove token1's client
session.detachClient(token1)
received.length = 0
subprocess.simulateData('after detach')
// token2's client should still receive data
expect(received).toEqual(['after detach'])
})
})
describe('dispose', () => {
it('cleans up without throwing', () => {
createSession()
expect(() => session.dispose()).not.toThrow()
})
it('marks session as exited', () => {
createSession()
session.dispose()
expect(session.state).toBe('exited')
})
})
})