492 lines
20 KiB
TypeScript
492 lines
20 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { IDisposable, IPty } from 'node-pty'
|
|
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
|
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
|
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
|
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
|
|
|
|
class FakeTerminal {
|
|
pid = 123
|
|
cols = 80
|
|
rows = 24
|
|
process = 'bash'
|
|
handleFlowControl = false
|
|
writes: string[] = []
|
|
kills: string[] = []
|
|
throwWrite = false
|
|
throwKill = false
|
|
autoExitOnKill = true
|
|
private dataListeners = new Set<(data: string) => void>()
|
|
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
|
|
|
|
readonly onData = (listener: (data: string) => void): IDisposable => {
|
|
this.dataListeners.add(listener)
|
|
return { dispose: () => this.dataListeners.delete(listener) }
|
|
}
|
|
|
|
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
|
|
this.exitListeners.add(listener)
|
|
return { dispose: () => this.exitListeners.delete(listener) }
|
|
}
|
|
|
|
emitData(data: string): void {
|
|
for (const listener of this.dataListeners) listener(data)
|
|
}
|
|
|
|
emitExit(exitCode = 0, signal?: number): void {
|
|
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
|
|
}
|
|
|
|
write(data: string): void {
|
|
if (this.throwWrite) throw new Error('write failed')
|
|
this.writes.push(data)
|
|
}
|
|
|
|
kill(signal?: string): void {
|
|
if (this.throwKill) throw new Error('kill failed')
|
|
this.kills.push(signal ?? 'SIGHUP')
|
|
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
|
}
|
|
|
|
resize() {}
|
|
clear() {}
|
|
pause() {}
|
|
resume() {}
|
|
|
|
asPty(): IPty {
|
|
return this
|
|
}
|
|
}
|
|
|
|
class FakeInspector implements ProcessInspector {
|
|
pgid: number | undefined = 456
|
|
waiting = false
|
|
members: ProcessIdentity[] = []
|
|
alive = new Set<number>()
|
|
groups: Array<[number, PtySignal]> = []
|
|
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
|
|
throwGroup = false
|
|
throwProcess = false
|
|
removeOnSignal = true
|
|
|
|
foregroundPgid() { return this.pgid }
|
|
isStdinWaiting() { return this.waiting }
|
|
processTree() { return this.members }
|
|
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
|
|
signalGroup(pgid: number, signal: PtySignal) {
|
|
if (this.throwGroup) throw new Error('group failed')
|
|
this.groups.push([pgid, signal])
|
|
}
|
|
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
|
|
if (this.throwProcess) throw new Error('process raced')
|
|
this.processes.push([identity.pid, signal])
|
|
if (this.removeOnSignal) this.alive.delete(identity.pid)
|
|
}
|
|
}
|
|
|
|
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
|
return {
|
|
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
|
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
|
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
|
|
disposeGraceMs: 20,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
afterEach(() => { vi.useRealTimers() })
|
|
|
|
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
|
|
const pending = session.initialize()
|
|
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await pending
|
|
}
|
|
|
|
describe('LocalPtySession readiness and output', () => {
|
|
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
await initialize(session, terminal)
|
|
expect(session.motd).toBe('dsh> ')
|
|
|
|
inspector.waiting = true
|
|
const operation = session.startSend({ text: 'python3', submit: true })
|
|
expect(terminal.writes).toEqual(['python3', '\r'])
|
|
terminal.emitData('Python\r\n>>> ')
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
|
|
expect(operation.cancel()).toBe(false)
|
|
})
|
|
|
|
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
await initialize(session, terminal)
|
|
inspector.pgid = undefined
|
|
|
|
const inferred = session.startSend({ text: 'sleep', submit: false })
|
|
terminal.emitData('working')
|
|
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
|
|
await vi.advanceTimersByTimeAsync(60)
|
|
expect((await inferred.done).waitReason).toBe('inferred_idle')
|
|
|
|
const timeout = session.startSend({ text: 'blocked', submit: false })
|
|
await vi.advanceTimersByTimeAsync(40)
|
|
terminal.emitData('.')
|
|
await vi.advanceTimersByTimeAsync(40)
|
|
terminal.emitData('.')
|
|
await vi.advanceTimersByTimeAsync(30)
|
|
expect((await timeout.done).waitReason).toBe('timeout')
|
|
|
|
const exiting = session.startSend({ text: 'exit', submit: true })
|
|
terminal.emitExit(7, 9)
|
|
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
|
|
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
|
})
|
|
|
|
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
await initialize(session, terminal)
|
|
|
|
const controller = new AbortController()
|
|
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
|
|
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
|
|
controller.abort()
|
|
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
|
expect(terminal.writes).not.toContain('\x03')
|
|
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await operation.done
|
|
|
|
const aborted = new AbortController()
|
|
aborted.abort()
|
|
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
|
|
|
|
terminal.throwWrite = true
|
|
const failed = session.startSend({ text: 'x', submit: false })
|
|
await expect(failed.done).rejects.toThrow('write failed')
|
|
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
|
|
failedInternal.append('ignored')
|
|
failedInternal.fail(new Error('ignored'))
|
|
})
|
|
|
|
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
|
vi.useFakeTimers()
|
|
const startupTerminal = new FakeTerminal()
|
|
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
|
|
const initializing = startup.initialize(new AbortController().signal)
|
|
startupTerminal.emitExit(1)
|
|
await expect(initializing).rejects.toThrow('exited during startup')
|
|
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
|
|
|
const terminal = new FakeTerminal()
|
|
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
|
await initialize(session, terminal)
|
|
const operation = session.startSend({ text: '', submit: false })
|
|
const operationInternal = operation as unknown as {
|
|
append(text: string): void
|
|
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
|
|
}
|
|
operationInternal.append('')
|
|
const sessionInternal = session as unknown as {
|
|
pollReadiness(operation: PtySendOperation): void
|
|
interrupt(operation: PtySendOperation): void
|
|
statusValue: PtySessionStatus
|
|
appendOutput(text: string): void
|
|
}
|
|
sessionInternal.appendOutput('')
|
|
sessionInternal.pollReadiness({} as PtySendOperation)
|
|
sessionInternal.interrupt({} as PtySendOperation)
|
|
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
|
sessionInternal.pollReadiness(operation)
|
|
await operation.done
|
|
operationInternal.settle('timeout', { kind: 'running' }, false)
|
|
|
|
const unknownTerminal = new FakeTerminal()
|
|
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
|
|
unknownTerminal.emitExit(1, 999)
|
|
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
|
|
|
const cancelTerminal = new FakeTerminal()
|
|
const cancelInspector = new FakeInspector()
|
|
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
|
|
await initialize(cancel, cancelTerminal)
|
|
const cancellable = cancel.startSend({ text: '', submit: false })
|
|
cancelInspector.throwGroup = true
|
|
expect(cancellable.cancel()).toBe(true)
|
|
await expect(cancellable.done).rejects.toThrow('group failed')
|
|
expect(cancellable.cancel()).toBe(false)
|
|
|
|
const missingGroupTerminal = new FakeTerminal()
|
|
const missingGroupInspector = new FakeInspector()
|
|
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
|
|
await initialize(missingGroup, missingGroupTerminal)
|
|
missingGroupInspector.pgid = undefined
|
|
const unresolved = missingGroup.startSend({ text: '', submit: false })
|
|
expect(unresolved.cancel()).toBe(true)
|
|
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
|
|
})
|
|
|
|
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
|
let settled = false
|
|
const initializing = session.initialize().then(() => { settled = true })
|
|
await vi.advanceTimersByTimeAsync(60)
|
|
expect(settled).toBe(false)
|
|
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await initializing
|
|
|
|
const timeoutTerminal = new FakeTerminal()
|
|
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
|
|
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
await timedOut
|
|
})
|
|
|
|
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
inspector.pgid = undefined
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
const controller = new AbortController()
|
|
const reason = new Error('startup cancelled')
|
|
|
|
const initializing = session.initialize(controller.signal)
|
|
const rejected = expect(initializing).rejects.toBe(reason)
|
|
controller.abort(reason)
|
|
|
|
await rejected
|
|
})
|
|
|
|
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
|
let settled = false
|
|
const initializing = session.initialize().then(() => { settled = true })
|
|
|
|
terminal.emitData('\x1b]133;D;0\x07')
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
expect(settled).toBe(false)
|
|
|
|
terminal.emitData('dsh> ')
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
await initializing
|
|
expect(session.motd).toBe('dsh> ')
|
|
})
|
|
|
|
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
await initialize(session, terminal)
|
|
|
|
const operation = session.startSend({ text: 'run', submit: true })
|
|
let settled = false
|
|
void operation.done.then(() => { settled = true })
|
|
inspector.pgid = 789
|
|
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
|
await vi.advanceTimersByTimeAsync(50)
|
|
expect(settled).toBe(false)
|
|
|
|
inspector.pgid = 456
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
expect(settled).toBe(true)
|
|
expect((await operation.done).waitReason).toBe('stdin_read')
|
|
})
|
|
|
|
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
await initialize(session, terminal)
|
|
|
|
const operation = session.startSend({ text: 'bash -i', submit: true })
|
|
inspector.pgid = 789
|
|
terminal.emitData('\x1b]133;D;0\x07child> ')
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
|
|
expect((await operation.done).waitReason).toBe('inferred_idle')
|
|
})
|
|
})
|
|
|
|
describe('LocalPtySession bounds, signals, and teardown', () => {
|
|
it('validates pagination and enforces line/UTF-8 bounds', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const session = new LocalPtySession(
|
|
terminal.asPty(),
|
|
new FakeInspector(),
|
|
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
|
|
)
|
|
expect(session.read({})).toMatchObject({ text: '' })
|
|
await initialize(session, terminal)
|
|
const operation = session.startSend({ text: '', submit: false })
|
|
terminal.emitData('一\n二\n三\n四')
|
|
await vi.advanceTimersByTimeAsync(60)
|
|
expect((await operation.done).truncated).toBe(true)
|
|
const page = session.read({ offset: 0, count: 3 })
|
|
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
|
|
expect(page.truncated).toBe(true)
|
|
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
|
|
expect(() => session.read({ offset: -1 })).toThrow('offset')
|
|
expect(() => session.read({ count: 0 })).toThrow('count')
|
|
|
|
const tinyTerminal = new FakeTerminal()
|
|
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
|
|
await initialize(tiny, tinyTerminal)
|
|
const tinyOperation = tiny.startSend({ text: '', submit: false })
|
|
tinyTerminal.emitData('一')
|
|
await vi.advanceTimersByTimeAsync(60)
|
|
await tinyOperation.done
|
|
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
|
|
})
|
|
|
|
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
|
|
inspector.pgid = terminal.pid
|
|
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
|
|
inspector.pgid = undefined
|
|
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
|
})
|
|
|
|
it('closes idempotently, contains signal races, and reports survivors', async () => {
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
inspector.members = [{ pid: 123, started: 'a' }]
|
|
inspector.alive.add(123)
|
|
inspector.throwProcess = true
|
|
terminal.throwKill = true
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
|
|
const closing = session.close('test')
|
|
expect(session.close('other')).toBe(closing)
|
|
await expect(closing).rejects.toThrow('surviving pids: 123')
|
|
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
|
})
|
|
|
|
it('settles an active send as session_exit when closed mid-operation', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
|
|
await initialize(session, terminal)
|
|
const operation = session.startSend({ text: 'run', submit: true })
|
|
// The shell returns to its prompt while the send is active; a running
|
|
// readiness poll would otherwise mis-settle this as stdin_read once close
|
|
// begins, so teardown must stop polling before its grace period.
|
|
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
|
terminal.autoExitOnKill = false
|
|
const closing = session.close('mid-send')
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
terminal.emitExit(0, 15)
|
|
expect((await operation.done).waitReason).toBe('session_exit')
|
|
await closing
|
|
})
|
|
|
|
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
inspector.members = [{ pid: 124, started: 'child' }]
|
|
inspector.alive.add(124)
|
|
inspector.removeOnSignal = false
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
|
|
|
let settled = false
|
|
const closing = session.close('test').then(() => { settled = true })
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
|
expect(terminal.kills).toEqual([])
|
|
expect(settled).toBe(false)
|
|
|
|
inspector.alive.delete(124)
|
|
await vi.advanceTimersByTimeAsync(20)
|
|
await closing
|
|
expect(terminal.kills).toEqual(['SIGTERM'])
|
|
expect(settled).toBe(true)
|
|
})
|
|
|
|
it('rescans for descendants forked during TERM before stopping the shell', async () => {
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
let reads = 0
|
|
inspector.processTree = () => {
|
|
reads += 1
|
|
if (reads === 1) {
|
|
inspector.alive.add(124)
|
|
return [{ pid: 124, started: 'first' }]
|
|
}
|
|
if (reads === 2) {
|
|
inspector.alive.add(125)
|
|
return [{ pid: 125, started: 'late' }]
|
|
}
|
|
return []
|
|
}
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
|
|
|
await session.close('test')
|
|
|
|
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
|
expect(terminal.kills).toEqual(['SIGTERM'])
|
|
})
|
|
|
|
it('retains captured survivors that are reparented out of the teardown rescan', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
const captured = { pid: 124, started: 'captured' }
|
|
let reads = 0
|
|
inspector.alive.add(captured.pid)
|
|
inspector.processTree = () => reads++ === 0 ? [captured] : []
|
|
inspector.signalProcess = (identity, signal) => {
|
|
inspector.processes.push([identity.pid, signal])
|
|
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
|
}
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
|
|
|
const closing = session.close('test')
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
await closing
|
|
|
|
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
|
expect(terminal.kills).toEqual(['SIGTERM'])
|
|
})
|
|
|
|
it('allows teardown to retry after a descendant-survivor failure', async () => {
|
|
vi.useFakeTimers()
|
|
const terminal = new FakeTerminal()
|
|
const inspector = new FakeInspector()
|
|
inspector.members = [{ pid: 124, started: 'child' }]
|
|
inspector.alive.add(124)
|
|
inspector.removeOnSignal = false
|
|
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
|
|
|
|
const first = session.close('first')
|
|
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
|
|
await vi.advanceTimersByTimeAsync(25)
|
|
await rejected
|
|
expect(terminal.kills).toEqual([])
|
|
|
|
inspector.alive.delete(124)
|
|
const second = session.close('retry')
|
|
expect(second).not.toBe(first)
|
|
await second
|
|
expect(terminal.kills).toEqual(['SIGTERM'])
|
|
})
|
|
})
|