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() 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') { if (this.throwProcess) throw new Error('process raced') this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } } afterEach(() => { vi.useRealTimers() }) describe('LocalTerminalHandle', () => { it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() inspector.waiting = true const handle = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), 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 = new LocalTerminalHandle(pty.asPty(), inspector, 1) await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') }) })