| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006 |
- 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 { createProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
- import type {
- ProcessIdentity,
- ProcessInspector,
- ProcessInspectorInternals,
- ProcessSnapshot,
- } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
- import type { BoundProcessOwner } from '@deepseek-ai/dsh-subprocess-local/src/managed-owner.ts'
- import type { SubprocessTerminalActivity, SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
- class FakePty {
- pid = 123
- readonly pause = vi.fn()
- readonly resume = vi.fn()
- readonly resize = vi.fn()
- 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']> = []
- readonly stdinChecks: Array<[number, number]> = []
- throwGroup = false
- throwProcess = false
- removeOnSignal = true
- foregroundPgid() { return this.pgid }
- isStdinWaiting(pgid: number, shellPid: number) {
- this.stdinChecks.push([pgid, shellPid])
- return this.waiting
- }
- /** Per-question table reads; tests replace one to stage a scan without rebuilding the fake. */
- readTree: () => ProcessIdentity[] = () => this.root === undefined ? this.members : [this.root, ...this.members]
- readSession: () => ProcessIdentity[] = () => this.sessionMembers
- readAlive: (identity: ProcessIdentity) => boolean = identity => this.alive.has(identity.pid)
- /** Liveness as of right now; tests diverge it from readAlive to stage an exit between scan and signal. */
- readCurrentAlive: (identity: ProcessIdentity) => boolean = identity => this.readAlive(identity)
- /** Counts process-table captures so read-amplification cases can pin them. */
- captures = 0
- complete = true
- snapshot(): ProcessSnapshot {
- this.captures += 1
- return {
- complete: this.complete,
- tree: () => this.readTree(),
- session: () => this.readSession(),
- alive: identity => this.readAlive(identity),
- }
- }
- isAlive(identity: ProcessIdentity) { return this.readCurrentAlive(identity) }
- 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.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('retains ownership after shell exit when /proc cannot be enumerated', async () => {
- const pty = new FakePty()
- let readable = false
- const inspector = createProcessInspector('linux', 'x64', {
- readDir: () => { if (!readable) throw new Error('EACCES'); return [] },
- } as unknown as ProcessInspectorInternals)
- const released = vi.fn()
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', undefined, undefined, undefined, released, true)
- pty.emitExit()
- await expect(handle.inspectActivity()).resolves.toMatchObject({ state: 'unknown' })
- await expect(handle.terminate()).rejects.toThrow('/proc directory is unreadable')
- expect(released).not.toHaveBeenCalled()
- readable = true
- await expect(handle.inspectActivity()).resolves.toMatchObject({ state: 'idle' })
- await handle.terminate()
- expect(released).toHaveBeenCalledOnce()
- })
- it('pauses native output until the consumer drains and resumes before termination', async () => {
- const pty = new FakePty()
- const handle = makeHandle(pty, new FakeInspector(), 10)
- const chunk = 'x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark)
- pty.emitData(chunk)
- expect(pty.pause).toHaveBeenCalledOnce()
- const chunks: Buffer[] = []
- handle.output.on('data', (data: Buffer) => { chunks.push(data) })
- await vi.waitFor(() => { expect(pty.resume).toHaveBeenCalledOnce() })
- expect(Buffer.concat(chunks).toString()).toBe(chunk)
- handle.output.pause()
- pty.emitData(chunk)
- expect(pty.pause).toHaveBeenCalledTimes(2)
- await handle.terminate()
- handle.output.emit('drain')
- expect(pty.resume).toHaveBeenCalledTimes(2)
- handle.output.destroy()
- })
- it('does not resume native output when a pending drain follows the PTY exit', async () => {
- const pty = new FakePty()
- const handle = makeHandle(pty, new FakeInspector(), 10)
- try {
- pty.emitData('x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark))
- expect(pty.pause).toHaveBeenCalledOnce()
- pty.emitExit(0)
- await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
- handle.output.emit('drain')
- expect(pty.resume).not.toHaveBeenCalled()
- } finally {
- await handle.terminate()
- handle.output.destroy()
- }
- })
- it('terminates a managed range with TERM when it stops within the grace period', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const stopped = Promise.withResolvers<undefined>()
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const owner: BoundProcessOwner = {
- signal(signal) {
- signals.push(signal)
- if (signal === 'SIGTERM') {
- pty.emitExit(0, 15)
- stopped.resolve(undefined)
- }
- },
- waitForExit: () => stopped.promise,
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
- await handle.terminate()
- expect(signals).toEqual(['SIGTERM'])
- await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
- expect(vi.getTimerCount()).toBe(0)
- })
- it('cancels the terminal-exit grace when the pty exits first', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const stopped = Promise.withResolvers<undefined>()
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const owner: BoundProcessOwner = {
- signal(signal) {
- signals.push(signal)
- if (signal === 'SIGTERM') {
- stopped.resolve(undefined)
- setTimeout(() => { pty.emitExit(0, 15) }, 1)
- }
- },
- waitForExit: () => stopped.promise,
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
- const terminating = handle.terminate()
- await vi.advanceTimersByTimeAsync(1)
- await terminating
- expect(signals).toEqual(['SIGTERM'])
- expect(vi.getTimerCount()).toBe(0)
- })
- it('escalates a managed range to KILL after the TERM grace expires', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const stopped = Promise.withResolvers<undefined>()
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const owner: BoundProcessOwner = {
- signal(signal) {
- signals.push(signal)
- if (signal === 'SIGKILL') {
- pty.emitExit(0, 9)
- stopped.resolve(undefined)
- }
- },
- waitForExit: () => stopped.promise,
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
- const terminating = handle.terminate()
- await vi.advanceTimersByTimeAsync(10)
- await terminating
- expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
- })
- it('force-kills and retries a managed range when observation first rejects', async () => {
- const pty = new FakePty()
- const failure = new Error('scope became unreadable')
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const waitForExit = vi.fn()
- .mockRejectedValueOnce(failure)
- .mockResolvedValue(undefined)
- const owner: BoundProcessOwner = {
- signal: (signal) => { signals.push(signal) },
- waitForExit,
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
- await expect(handle.terminate()).rejects.toBe(failure)
- expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
- expect(waitForExit).toHaveBeenCalledTimes(2)
- })
- it('preserves both failed managed-range observations after force-kill', async () => {
- const pty = new FakePty()
- const firstFailure = new Error('scope became unreadable')
- const finalFailure = new Error('scope stayed unreadable')
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const owner: BoundProcessOwner = {
- signal: (signal) => { signals.push(signal) },
- waitForExit: vi.fn()
- .mockRejectedValueOnce(firstFailure)
- .mockRejectedValueOnce(finalFailure),
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
- await expect(handle.terminate()).rejects.toMatchObject({
- errors: [firstFailure, finalFailure],
- message: 'terminal managed-range cleanup failed',
- })
- expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
- })
- it('routes managed terminal host exit directly to KILL', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const signal = vi.fn()
- const terminateForHostExit = vi.fn()
- const owner: BoundProcessOwner = { signal, waitForExit: async () => {}, terminateForHostExit }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner)
- handle.terminateForHostExit()
- expect(signal).not.toHaveBeenCalled()
- expect(terminateForHostExit).toHaveBeenCalledOnce()
- expect(inspector.processes).toEqual([])
- expect(pty.kills).toEqual([])
- })
- it('rejects managed outcome conversion and cleans through its owner exactly once', async () => {
- const pty = new FakePty()
- const failure = new Error('invalid bootstrap outcome')
- const cleanup = vi.fn()
- const owner: BoundProcessOwner = {
- signal: vi.fn(),
- waitForExit: async () => {},
- terminateForHostExit: vi.fn(),
- cleanup,
- }
- const handle = new LocalTerminalHandle(
- pty.asPty(),
- new FakeInspector(),
- 10,
- 'linux',
- owner,
- () => { throw failure },
- )
- pty.emitExit()
- await expect(handle.done).rejects.toBe(failure)
- await expect(handle.terminate()).resolves.toBeUndefined()
- await expect(handle.terminate()).resolves.toBeUndefined()
- await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
- })
- it('runs owner cleanup once after repeated failed managed termination attempts', async () => {
- const pty = new FakePty()
- const failure = new Error('scope stayed unreadable')
- const cleanup = vi.fn()
- const owner: BoundProcessOwner = {
- signal: vi.fn(),
- waitForExit: vi.fn(async () => { throw failure }),
- terminateForHostExit: vi.fn(),
- cleanup,
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
- await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed')
- await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed')
- expect(cleanup).not.toHaveBeenCalled()
- pty.emitExit()
- await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
- await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
- })
- it('waits for the node-pty exit event after the managed range becomes empty', async () => {
- const pty = new FakePty()
- const owner: BoundProcessOwner = {
- signal: vi.fn(),
- waitForExit: async () => {},
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
- let settled = false
- const terminating = handle.terminate().then(() => { settled = true })
- await new Promise(resolve => setImmediate(resolve))
- expect(settled).toBe(false)
- pty.emitExit()
- await terminating
- })
- it('rejects when a managed range stops but node-pty never publishes exit', async () => {
- vi.useFakeTimers()
- const pty = new FakePty()
- const signals: Array<'SIGTERM' | 'SIGKILL'> = []
- const owner: BoundProcessOwner = {
- signal: (signal) => { signals.push(signal) },
- waitForExit: async () => {},
- terminateForHostExit: vi.fn(),
- }
- const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner)
- const terminating = handle.terminate()
- const rejected = expect(terminating).rejects.toThrow('terminal cleanup failed; surviving pid: 123')
- await vi.advanceTimersByTimeAsync(10)
- await rejected
- expect(signals).toEqual(['SIGTERM'])
- })
- 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.readTree = () => { 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.readAlive = 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')
- await handle.resize(100, 30)
- expect(pty.resize).toHaveBeenCalledWith(100, 30)
- expect(pty.writes).toEqual(['input\r'])
- expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
- expect(inspector.stdinChecks).toEqual([[456, 123]])
- expect(await handle.signalForeground('SIGINT')).toBe(456)
- expect(inspector.groups).toEqual([[456, 'SIGINT']])
- pty.emitExit(7, 9)
- pty.emitExit(0)
- await expect(handle.resize(80, 24)).rejects.toThrow('terminal process has exited')
- 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.readSession = () => 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.readTree = () => {
- 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.readTree = () => { 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([])
- })
- })
- describe('signalling freshness and containment', () => {
- it('keeps synchronous host exit going when the process table cannot be captured', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(pty.pid)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- inspector.snapshot = () => { throw new Error('process table unavailable') }
- expect(() => { handle.terminateForHostExit() }).not.toThrow()
- // forceStopShell still runs: a failed scan must not cost the PTY root.
- expect(inspector.processes).toEqual([[pty.pid, 'SIGKILL']])
- })
- it('captures no process table for a signalling round with no members', () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.alive.add(pty.pid)
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
- // Only the shell exists, so every descendant scan yields an empty round.
- inspector.readTree = () => [{ pid: pty.pid, started: 'shell' }]
- inspector.captures = 0
- handle.terminateForHostExit()
- // Two descendant scans and nothing else: no capture for either empty
- // signalling round, and none for the identity-fenced shell kill.
- expect(inspector.captures).toBe(2)
- })
- })
- describe('process-table read amplification', () => {
- // The macOS inspector answers every question by forking `/bin/ps`, so a
- // readiness poll that asks per descendant scales its blocking cost with the
- // command's process tree. These pin the read count, not the wall time.
- function darwinInternals(table: string): { internals: ProcessInspectorInternals; tableReads: string[] } {
- const tableReads: string[] = []
- const unreachable = (): never => { throw new Error('darwin inspection uses exec and kill only') }
- return {
- tableReads,
- internals: {
- readFile: unreachable,
- readDir: unreachable,
- readLink: unreachable,
- stat: unreachable,
- open: unreachable,
- read: unreachable,
- close: unreachable,
- exec(_file, args) {
- if (args.includes('tpgid=')) return '456\n'
- tableReads.push(args.join(' '))
- return table
- },
- kill() {},
- },
- }
- }
- /** A shell at pid 123 with `count` descendants chained beneath it. */
- function shellTable(count: number): string {
- const rows = [' 123 1 Mon Jul 21 10:00:00 2026']
- for (let index = 0; index < count; index += 1) {
- rows.push(` ${String(124 + index)} ${String(123 + index)} Mon Jul 21 10:00:${String(index + 1).padStart(2, '0')} 2026`)
- }
- return `${rows.join('\n')}\n`
- }
- async function tableReadsForOnePoll(descendants: number): Promise<number> {
- const { internals, tableReads } = darwinInternals(shellTable(descendants))
- const inspector = createProcessInspector('darwin', 'arm64', internals)
- const handle = new LocalTerminalHandle(new FakePty().asPty(), inspector, 10, 'darwin')
- tableReads.length = 0
- const foreground = await handle.inspectForeground()
- expect(foreground).toEqual({ processGroupId: 456, inputWaiting: false })
- return tableReads.length
- }
- it('reads the macOS process table once per foreground inspection regardless of descendant count', async () => {
- expect(await tableReadsForOnePoll(0)).toBe(1)
- expect(await tableReadsForOnePoll(2)).toBe(1)
- expect(await tableReadsForOnePoll(10)).toBe(1)
- })
- })
- it('requires matching shell identity, complete process observations, and an idle foreground before reporting idle', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.pgid = pty.pid
- const activity = { inspect: vi.fn((): SubprocessTerminalActivity => ({ state: 'idle', revision: 1 })), invalidate: vi.fn(), dispose: vi.fn() }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20, 'darwin', undefined, undefined, activity)
- try {
- const idle = await handle.inspectActivity()
- expect(idle.state).toBe('idle')
- expect(await handle.inspectActivity()).toEqual(idle)
- await handle.write('partial')
- expect(activity.invalidate).toHaveBeenCalledOnce()
- inspector.complete = false
- expect((await handle.inspectActivity()).state).toBe('unknown')
- inspector.complete = true
- inspector.pgid = undefined
- expect((await handle.inspectActivity()).state).toBe('unknown')
- inspector.pgid = 456
- expect((await handle.inspectActivity()).state).toBe('busy')
- inspector.pgid = pty.pid
- inspector.root = { pid: 123, started: 'reused' }
- expect((await handle.inspectActivity()).state).toBe('unknown')
- inspector.root = { pid: 123, started: 'shell' }
- inspector.members = [{ pid: 789, started: 'background' }]
- inspector.alive.add(789)
- expect((await handle.inspectActivity()).state).toBe('busy')
- inspector.alive.clear()
- inspector.members = []
- inspector.readTree = () => { throw new Error('process table unavailable') }
- expect((await handle.inspectActivity()).state).toBe('unknown')
- inspector.readTree = () => [inspector.root!]
- } finally { await handle.terminate() }
- expect(activity.dispose).toHaveBeenCalledOnce()
- expect((await handle.inspectActivity()).state).toBe('idle')
- })
- it('keeps unverified root identities unknown without leaking a failed PTY allocation', async () => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.pgid = pty.pid
- const read = inspector.readTree
- inspector.readTree = () => { throw new Error('identity unavailable during allocation') }
- const activity = { inspect: () => ({ state: 'idle' as const, revision: 1 }), invalidate() {}, dispose() {} }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20, 'darwin', undefined, undefined, activity)
- inspector.readTree = read
- try { expect((await handle.inspectActivity()).state).toBe('unknown') }
- finally { await handle.terminate() }
- })
- it.each(['darwin', 'linux'] as const)('retains root-exited work and respects %s session observability', async (platform) => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20, platform)
- expect(handle.running).toBe(true)
- pty.emitExit()
- expect(handle.running).toBe(false)
- inspector.root = undefined
- try {
- inspector.complete = false
- expect((await handle.inspectActivity()).state).toBe('unknown')
- inspector.complete = true
- const child = { pid: 789, started: 'orphan' }
- inspector.sessionMembers = [child]
- inspector.alive.add(789)
- expect((await handle.inspectActivity()).state).toBe(platform === 'linux' ? 'busy' : 'unknown')
- inspector.alive.clear()
- inspector.sessionMembers = []
- expect((await handle.inspectActivity()).state).toBe(platform === 'linux' ? 'idle' : 'unknown')
- expect(pty.kills).toEqual([])
- } finally { await handle.terminate() }
- })
- it.each([false, true])('waits for a root-exited native range, with failed observation=%s remaining unknown', async (fails) => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.pgid = pty.pid
- const empty = Promise.withResolvers<undefined>()
- const owner: BoundProcessOwner = {
- signal: () => { empty.resolve(undefined); pty.emitExit() },
- waitForExit: vi.fn().mockReturnValueOnce(empty.promise).mockResolvedValue(undefined),
- terminateForHostExit() {},
- }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20, 'linux', owner, undefined, undefined, undefined, true)
- pty.emitExit()
- inspector.root = undefined
- try {
- expect((await handle.inspectActivity()).state).toBe('unknown')
- if (fails) empty.reject(new Error('native range unavailable'))
- else empty.resolve(undefined)
- await Promise.resolve()
- await Promise.resolve()
- expect((await handle.inspectActivity()).state).toBe(fails ? 'unknown' : 'idle')
- } finally { await handle.terminate() }
- })
- it.each([null, undefined, 0, 1, 2])('checks native range task count %s before trusting a prompt with no visible children', async (tasks) => {
- const pty = new FakePty()
- const inspector = new FakeInspector()
- inspector.pgid = pty.pid
- const owner: BoundProcessOwner = {
- signal: () => { pty.emitExit() }, waitForExit: async () => {}, terminateForHostExit() {},
- ...tasks === null ? {} : { inspectTaskCount: () => tasks },
- }
- const activity = { inspect: () => ({ state: 'idle' as const, revision: 1 }), invalidate() {}, dispose() {} }
- const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20, 'linux', owner, undefined, activity)
- try {
- expect((await handle.inspectActivity()).state).toBe(tasks === 1 ? 'idle' : tasks === 2 ? 'busy' : 'unknown')
- } finally { await handle.terminate() }
- })
|