mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
488 lines
15 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|