Files
orca/src/main/daemon/terminal-host.test.ts
T
2026-06-21 18:51:44 -07:00

488 lines
15 KiB
TypeScript

/* oxlint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TerminalHost } from './terminal-host'
import type { SubprocessHandle } from './session'
function createMockSubprocess(
options: { startupCommandDeliveredInShellArgs?: boolean } = {}
): SubprocessHandle {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
return {
pid: 99999,
...(options.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExitCb?.(0), 5)
}),
forceKill: vi.fn(),
signal: vi.fn(),
onData(cb) {
onDataCb = cb
},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn(),
// Test helpers
get _onDataCb() {
return onDataCb
},
get _onExitCb() {
return onExitCb
}
} as SubprocessHandle & { _onDataCb: typeof onDataCb; _onExitCb: typeof onExitCb }
}
type MockSpawnFn = (opts: {
sessionId: string
cols: number
rows: number
cwd?: string
env?: Record<string, string>
command?: string
}) => SubprocessHandle
describe('TerminalHost', () => {
let host: TerminalHost
let spawnFn: MockSpawnFn
let lastSubprocess: ReturnType<typeof createMockSubprocess> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
beforeEach(() => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess() as ReturnType<typeof createMockSubprocess> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
lastSubprocess = sub
return sub
})
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn })
})
afterEach(() => {
host.dispose()
})
describe('createOrAttach', () => {
it('creates a new session when none exists', async () => {
const result = await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(result.isNew).toBe(true)
expect(result.pid).toBe(99999)
expect(spawnFn).toHaveBeenCalledOnce()
})
it('attaches to existing session', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const result = await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(result.isNew).toBe(false)
// Should not spawn a second subprocess
expect(spawnFn).toHaveBeenCalledOnce()
})
it('returns snapshot when attaching to existing session', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const result = await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(result.snapshot).toBeDefined()
expect(result.snapshot?.cols).toBe(80)
})
it('passes cwd and env to spawn', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
cwd: '/home/user',
env: { FOO: 'bar' },
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(spawnFn).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'session-1',
cwd: '/home/user',
env: { FOO: 'bar' }
})
)
})
it('queues startup commands through the session shell-ready barrier', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'echo hello',
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(lastSubprocess.write).not.toHaveBeenCalled()
// Why: the marker alone no longer flushes — the kernel can still have
// ECHO enabled when it arrives. The flush waits for the prompt draw
// plus a short delay so readline has switched the PTY into raw mode
// first. Otherwise the command would be visibly double-echoed.
lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready\x07')
expect(lastSubprocess.write).not.toHaveBeenCalled()
lastSubprocess._onDataCb?.('\r\nuser@host $ ')
await new Promise((r) => setTimeout(r, 40))
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
})
it('uses the short daemon settle path when marker and prompt arrive together', async () => {
vi.useFakeTimers()
try {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'echo hello',
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready\x07\r\nuser@host $ ')
vi.advanceTimersByTime(29)
expect(lastSubprocess.write).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
} finally {
vi.useRealTimers()
}
})
it('does not write startup commands already embedded in shell args', async () => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess({
startupCommandDeliveredInShellArgs: true
}) as ReturnType<typeof createMockSubprocess> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
lastSubprocess = sub
return sub
})
host.dispose()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn })
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'codex --no-alt-screen',
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(lastSubprocess.write).not.toHaveBeenCalled()
})
})
describe('write', () => {
it('forwards write to the session', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.write('session-1', 'hello')
expect(lastSubprocess.write).toHaveBeenCalledWith('hello')
})
it('throws for non-existent session', () => {
expect(() => host.write('missing', 'data')).toThrow('Session not found')
})
})
describe('resize', () => {
it('normalizes invalid initial dimensions before spawning a session', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 0,
rows: -1,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(spawnFn).toHaveBeenCalledWith(expect.objectContaining({ cols: 80, rows: 24 }))
expect(host.listSessions()[0]).toMatchObject({ cols: 80, rows: 24 })
})
it('forwards resize to the session', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.resize('session-1', 120, 40)
expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40)
})
it('ignores transient zero-size resize events', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.resize('session-1', 0, 0)
expect(lastSubprocess.resize).not.toHaveBeenCalled()
expect(host.listSessions()[0]).toMatchObject({ cols: 80, rows: 24 })
})
})
describe('kill', () => {
it('kills the session and tombstones it', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill('session-1')
expect(lastSubprocess.kill).toHaveBeenCalled()
expect(host.isKilled('session-1')).toBe(true)
})
it('force-kills immediately when requested', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill('session-1', { immediate: true })
expect(lastSubprocess.kill).not.toHaveBeenCalled()
expect(lastSubprocess.forceKill).toHaveBeenCalled()
expect(lastSubprocess.dispose).toHaveBeenCalled()
expect(host.isKilled('session-1')).toBe(true)
})
it('throws for non-existent session', () => {
expect(() => host.kill('missing')).toThrow('Session not found')
})
})
describe('signal', () => {
it('sends signal without entering kill state', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.signal('session-1', 'SIGINT')
expect(lastSubprocess.signal).toHaveBeenCalledWith('SIGINT')
expect(host.isKilled('session-1')).toBe(false)
})
})
describe('listSessions', () => {
it('returns empty list initially', () => {
expect(host.listSessions()).toEqual([])
})
it('lists created sessions', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
await host.createOrAttach({
sessionId: 'session-2',
cols: 120,
rows: 40,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const sessions = host.listSessions()
expect(sessions).toHaveLength(2)
expect(sessions.map((s) => s.sessionId).sort()).toEqual(['session-1', 'session-2'])
})
})
describe('detach', () => {
it('detaches a client from a session', async () => {
const onData = vi.fn()
const result = await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData, onExit: vi.fn() }
})
host.detach('session-1', result.attachToken)
// Data after detach should not be received
lastSubprocess._onDataCb?.('after detach')
expect(onData).not.toHaveBeenCalled()
})
})
describe('tombstones', () => {
it('caps tombstones at limit', async () => {
host.dispose()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, maxTombstones: 3 })
for (let i = 0; i < 5; i++) {
await host.createOrAttach({
sessionId: `session-${i}`,
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.kill(`session-${i}`)
}
// Oldest tombstones should be evicted
expect(host.isKilled('session-0')).toBe(false)
expect(host.isKilled('session-4')).toBe(true)
})
})
describe('dispose', () => {
it('force-kills live subprocesses and releases PTY fds on dispose', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.dispose()
// Why: for LIVE sessions, dispose() calls session.forceKillAndDisposeSubprocess()
// which sends SIGKILL (forceKill) and releases the ptmx fd (subprocess.dispose)
// synchronously — no longer relies on the 5s KILL_TIMEOUT_MS fallback.
// Exited sessions take the disposeSubprocess() path instead (see the test
// below). See docs/fix-pty-fd-leak.md.
expect(lastSubprocess.forceKill).toHaveBeenCalled()
expect(lastSubprocess.dispose).toHaveBeenCalled()
})
it('releases held shell-ready marker prefixes before final checkpoint', async () => {
host.dispose()
const onFinalCheckpoint = vi.fn()
host = new TerminalHost({
spawnSubprocess: spawnFn as MockSpawnFn,
onFinalCheckpoint
})
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
lastSubprocess._onDataCb?.('\x1b]777;orca-shell-ready')
host.dispose()
expect(onFinalCheckpoint).toHaveBeenCalledWith('session-1', expect.any(Object), [
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
])
})
it('does not list exited sessions', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
lastSubprocess._onExitCb?.(0)
expect(host.listSessions()).toEqual([])
})
it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => {
// Why: after a session's subprocess has exited (onExit fired), proc.pid
// refers to a reaped child whose pid may have been recycled. Force-killing
// it would process.kill(recycled_pid, 'SIGKILL') — killing a stranger.
// The exit now reaps the session via session.dispose(), which skips
// forceKill once _state==='exited' (only the fd is released). host.dispose
// then only ever sees live sessions.
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
// Natural exit reaps session-1 synchronously: its subprocess fd is
// released (dispose) but it is never force-killed, and it is dropped from
// the map (so it is not listed and not touched by host.dispose below).
const exitedSub = lastSubprocess
lastSubprocess._onExitCb?.(0)
expect(host.listSessions()).toEqual([])
// A second, live session remains in the map for host.dispose to reap.
await host.createOrAttach({
sessionId: 'session-2',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const liveSub = lastSubprocess
host.dispose()
expect(exitedSub.forceKill).not.toHaveBeenCalled()
expect(exitedSub.dispose).toHaveBeenCalled()
expect(liveSub.forceKill).toHaveBeenCalled()
expect(liveSub.dispose).toHaveBeenCalled()
})
})
})