| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import type { IDisposable, IPty } from 'node-pty'
- import { LocalTerminalHandle } from '@deepseek-ai/dsh-subprocess-local/src/terminal.ts'
- import type {
- ProcessIdentity,
- ProcessInspector,
- } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
- import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
- class FakePty {
- pid = 123
- readonly writes: string[] = []
- readonly kills: string[] = []
- autoExitOnKill = true
- throwKill = false
- onKill?: () => void
- private readonly dataListeners = new Set<(data: string) => void>()
- private readonly 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 { this.writes.push(data) }
- kill(signal?: string): void {
- if (this.throwKill) throw new Error('process raced')
- this.kills.push(signal ?? 'SIGHUP')
- this.onKill?.()
- if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
- }
- asPty(): IPty {
- return this as unknown as IPty
- }
- }
- class FakeInspector implements ProcessInspector {
- pgid: number | undefined = 456
- waiting = false
- /** The shell's own row, present like the real /proc- and ps-backed scans; tests recycle or drop it. */
- root: ProcessIdentity | undefined = { pid: 123, started: 'shell' }
- members: ProcessIdentity[] = []
- sessionMembers: ProcessIdentity[] = []
- readonly alive = new Set<number>()
- readonly groups: Array<[number, SubprocessTerminalSignal]> = []
- readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
- throwGroup = false
- throwProcess = false
- removeOnSignal = true
- foregroundPgid() { return this.pgid }
- isStdinWaiting() { return this.waiting }
- processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] }
- processSession() { return this.sessionMembers }
- isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
- signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
- if (this.throwGroup) throw new Error('group failed')
- this.groups.push([pgid, signal])
- }
- signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
- // Mirrors the real inspectors' alive-gated signalling.
- if (!this.alive.has(identity.pid)) return
- if (this.throwProcess) throw new Error('process raced')
- if (!this.isAlive(identity)) return
- this.processes.push([identity.pid, signal])
- if (this.removeOnSignal) this.alive.delete(identity.pid)
- }
- }
- afterEach(() => { vi.useRealTimers() })
- function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle {
- // The suite pins POSIX signalling semantics deterministically on every host;
- // the win32 branches get their own platform-explicit tests below.
- return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux')
- }
- describe('LocalTerminalHandle', () => {
- it('force-kills descendants around the shell during synchronous host exit', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const first = { pid: 124, started: 'first' }
- const late = { pid: 125, started: 'late' }
- inspector.members = [first]
- inspector.alive.add(pty.pid)
- inspector.alive.add(first.pid)
- const signalProcess = inspector.signalProcess.bind(inspector)
- inspector.signalProcess = (identity, signal) => {
- signalProcess(identity, signal)
- if (identity.pid === pty.pid) {
- inspector.members = [first, late]
- inspector.alive.add(late.pid)
- }
- }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- handle.terminateForHostExit()
- expect(inspector.processes).toEqual([
- [first.pid, 'SIGKILL'],
- [pty.pid, 'SIGKILL'],
- [late.pid, 'SIGKILL'],
- ])
- expect(pty.kills).toEqual([])
- pty.emitExit()
- handle.terminateForHostExit()
- expect(pty.kills).toEqual([])
- })
- it('uses captured identities and contains shell races when final inspection fails', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const captured = { pid: 124, started: 'captured' }
- inspector.members = [captured]
- inspector.alive.add(pty.pid)
- inspector.alive.add(captured.pid)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- await handle.inspectForeground()
- inspector.processTree = () => { throw new Error('process table unavailable') }
- inspector.throwProcess = true
- expect(() => { handle.terminateForHostExit() }).not.toThrow()
- expect(inspector.processes).toEqual([])
- expect(pty.kills).toEqual([])
- })
- it('uses node-pty only when the shell start identity was unavailable', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.root = undefined
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- handle.terminateForHostExit()
- expect(pty.kills).toEqual(['SIGKILL'])
- const racingPty = new FakePty()
- const racingInspector = new FakeInspector()
- racingInspector.root = undefined
- racingPty.throwKill = true
- const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10)
- expect(() => { racingHandle.terminateForHostExit() }).not.toThrow()
- })
- it('does not signal a recycled terminal root before its delayed exit callback', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(pty.pid)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- inspector.root = { pid: pty.pid, started: 'recycled' }
- inspector.isAlive = identity => identity.started === 'recycled'
- handle.terminateForHostExit()
- expect(inspector.processes).toEqual([])
- expect(pty.kills).toEqual([])
- })
- it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.waiting = true
- const handle = makeHandle(pty, inspector, 10)
- const chunks: Buffer[] = []
- handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
- pty.emitData('hello €')
- await handle.write('input\r')
- expect(pty.writes).toEqual(['input\r'])
- expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
- expect(await handle.signalForeground('SIGINT')).toBe(456)
- expect(inspector.groups).toEqual([[456, 'SIGINT']])
- pty.emitExit(7, 9)
- pty.emitExit(0)
- expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
- await handle.terminate()
- expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
- })
- it('rejects unsafe foreground signals and writes after exit', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = makeHandle(pty, inspector, 10)
- inspector.pgid = handle.pid
- await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
- inspector.pgid = undefined
- expect(await handle.inspectForeground()).toBeUndefined()
- await expect(handle.signalForeground('SIGTERM')).rejects.toThrow('cannot resolve')
- pty.emitExit(3)
- expect(await handle.done).toEqual({ exitCode: 3, signal: null })
- await handle.terminate()
- await expect(handle.write('late')).rejects.toThrow('has exited')
- })
- it('keeps the shell alive until forced descendants leave', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.members = [{ pid: 124, started: 'child' }]
- inspector.alive.add(124)
- inspector.removeOnSignal = false
- const handle = makeHandle(pty, inspector, 20)
- const quiescent = handle.terminate()
- expect(handle.terminate()).toBe(quiescent)
- await vi.advanceTimersByTimeAsync(20)
- expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
- expect(pty.kills).toEqual([])
- inspector.alive.delete(124)
- await vi.advanceTimersByTimeAsync(20)
- await quiescent
- expect(pty.kills).toEqual(['SIGTERM'])
- })
- it('keeps an early exit wait pending through descendant cleanup', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.members = [{ pid: 124, started: 'child' }]
- inspector.alive.add(124)
- inspector.removeOnSignal = false
- const handle = makeHandle(pty, inspector, 20)
- pty.emitExit()
- const waiting = handle.terminate()
- let settled = false
- void waiting.then(() => { settled = true })
- await vi.advanceTimersByTimeAsync(10)
- expect(settled).toBe(false)
- inspector.alive.delete(124)
- await vi.advanceTimersByTimeAsync(20)
- await waiting
- })
- it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const disowned = { pid: 124, started: 'disowned' }
- inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
- inspector.alive.add(124)
- const handle = makeHandle(pty, inspector, 20)
- pty.emitExit()
- await handle.terminate()
- expect(inspector.processes).toEqual([[124, 'SIGTERM']])
- })
- it('retains an inspected descendant after it reparents away from the shell', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const descendant = { pid: 124, started: 'observed' }
- inspector.members = [descendant]
- inspector.alive.add(descendant.pid)
- const handle = makeHandle(pty, inspector, 20)
- await handle.inspectForeground()
- inspector.members = []
- pty.emitExit()
- await handle.terminate()
- expect(inspector.processes).toEqual([[124, 'SIGTERM']])
- })
- it('does not adopt the children of a recycled shell pid', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = makeHandle(pty, inspector, 10)
- pty.emitExit()
- const imposterChild = { pid: 999, started: 'imposter-child' }
- inspector.root = { pid: 123, started: 'imposter' }
- inspector.members = [imposterChild]
- inspector.alive.add(imposterChild.pid)
- await handle.terminate()
- expect(inspector.processes).toEqual([])
- })
- it('adopts nothing when the shell identity was never observable', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.root = undefined
- const orphan = { pid: 321, started: 'unverifiable' }
- inspector.members = [orphan]
- inspector.alive.add(orphan.pid)
- const handle = makeHandle(pty, inspector, 10)
- await handle.terminate()
- expect(inspector.processes).toEqual([])
- expect(pty.kills).toEqual(['SIGTERM'])
- })
- it('rescans for descendants forked during TERM', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const root = { pid: 123, started: 'shell' }
- let reads = 0
- inspector.processTree = () => {
- reads += 1
- if (reads === 1) return [root]
- if (reads === 2) {
- inspector.alive.add(124)
- return [root, { pid: 124, started: 'first' }]
- }
- if (reads === 3) {
- inspector.alive.add(125)
- return [root, { pid: 125, started: 'late' }]
- }
- return []
- }
- const handle = makeHandle(pty, inspector, 10)
- await handle.terminate()
- expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
- expect(pty.kills).toEqual(['SIGTERM'])
- })
- it('sweeps a same-session descendant forked while the shell handles TERM', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const late = { pid: 124, started: 'shell-term-trap' }
- pty.onKill = () => {
- inspector.sessionMembers = [late]
- inspector.alive.add(late.pid)
- }
- const handle = makeHandle(pty, inspector, 10)
- await handle.terminate()
- expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
- expect(pty.kills).toEqual(['SIGTERM'])
- })
- it('retries failed cleanup after a surviving descendant leaves', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const late = { pid: 124, started: 'shell-term-survivor' }
- inspector.removeOnSignal = false
- pty.onKill = () => {
- inspector.sessionMembers = [late]
- inspector.alive.add(late.pid)
- }
- const handle = makeHandle(pty, inspector, 10)
- const first = handle.terminate()
- const failed = expect(first).rejects.toThrow('surviving pids: 124')
- await vi.advanceTimersByTimeAsync(25)
- await failed
- inspector.alive.delete(late.pid)
- const retry = handle.terminate()
- expect(retry).not.toBe(first)
- await retry
- expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
- })
- it('retains captured descendants after reparenting', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const captured = { pid: 124, started: 'captured' }
- const root = { pid: 123, started: 'shell' }
- let reads = 0
- inspector.alive.add(captured.pid)
- inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
- inspector.signalProcess = (identity, signal) => {
- inspector.processes.push([identity.pid, signal])
- if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
- }
- const handle = makeHandle(pty, inspector, 20)
- const quiescent = handle.terminate()
- await vi.advanceTimersByTimeAsync(25)
- await quiescent
- expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
- })
- it('reports a top-level process that ignores escalation', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- pty.autoExitOnKill = false
- const handle = makeHandle(pty, new FakeInspector(), 10)
- const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
- await vi.advanceTimersByTimeAsync(25)
- await failed
- expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
- pty.emitExit(0, 999)
- expect(await handle.done).toEqual({ exitCode: null, signal: null })
- await handle.terminate()
- })
- it('contains process races while reporting surviving descendants', async () => {
- const pty = new FakePty()
- pty.throwKill = true
- const inspector = new FakeInspector()
- inspector.members = [{ pid: 124, started: 'child' }]
- inspector.alive.add(124)
- inspector.throwProcess = true
- const handle = makeHandle(pty, inspector, 1)
- await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
- })
- })
- describe('LocalTerminalHandle on Windows', () => {
- const win32 = 'win32' as NodeJS.Platform
- it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- await expect(handle.signalForeground('SIGINT')).resolves.toBe(456)
- expect(pty.writes).toEqual(['\x03'])
- expect(inspector.groups).toEqual([])
- })
- it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => {
- const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32)
- await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows')
- await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows')
- })
- it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456)
- expect(inspector.groups).toEqual([[456, 'SIGTERM']])
- expect(pty.writes).toEqual([])
- })
- it('still refuses to SIGKILL the terminal shell on Windows', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- inspector.pgid = handle.pid
- await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
- })
- it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(123)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- const quiescent = handle.terminate()
- await vi.advanceTimersByTimeAsync(5)
- expect(inspector.processes).toEqual([[123, 'SIGTERM']])
- expect(pty.kills).toEqual([])
- pty.emitExit()
- await quiescent
- expect(inspector.processes).toEqual([[123, 'SIGTERM']])
- expect(pty.kills).toEqual([])
- })
- it('reports a shell that survives both taskkill tiers', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(123)
- inspector.removeOnSignal = false
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
- await vi.advanceTimersByTimeAsync(25)
- await failed
- expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']])
- expect(pty.kills).toEqual([])
- pty.emitExit()
- await handle.terminate()
- })
- it('skips taskkill escalation entirely when the shell already exited', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(123)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- pty.emitExit()
- await handle.terminate()
- expect(inspector.processes).toEqual([])
- expect(pty.kills).toEqual([])
- })
- it('falls back to the bare node-pty kill when the shell identity was never observable', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.root = undefined
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
- await handle.terminate()
- expect(pty.kills).toHaveLength(1)
- expect(inspector.processes).toEqual([])
- })
- })
|