refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-07-28 23:00:00 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { stat } from 'node:fs/promises'
import { basename, delimiter, dirname } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
return {
@@ -18,6 +21,133 @@ function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): Su
}
describe('LocalSubprocessService', () => {
it('publishes execution-world paths and removes its private runtime directory', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const root = ctx.subprocess.runtimeRoot
expect(ctx.subprocess.cwd).toBe(process.cwd())
expect((await stat(root)).isDirectory()).toBe(true)
await fiber.dispose()
await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
expect(await ctx.subprocess.resolveExecutable(process.execPath)).toBe(process.execPath)
expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
PATH: dirname(process.execPath),
})).toBe(process.execPath)
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
.rejects.toThrow('was not found on PATH')
await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
.rejects.toThrow('is not an executable file')
await expect(ctx.subprocess.resolveExecutable(process.cwd()))
.rejects.toThrow('is not an executable file')
await expect(ctx.subprocess.resolveExecutable(process.execPath, {}, AbortSignal.abort('stop')))
.rejects.toBe('stop')
await fiber.dispose()
})
it('builds Windows executable candidates without empty PATH entries', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const service = ctx.subprocess as LocalSubprocessService
const candidates = (service as unknown as {
executableCandidates(command: string, env: NodeJS.ProcessEnv): string[]
}).executableCandidates.bind(service)
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
expect(candidates('tool', { PATH: `${delimiter}/bin`, PATHEXT: '.EXE;.CMD' }))
.toEqual(['/bin/tool.EXE', '/bin/tool.CMD'])
expect(candidates('tool.exe', {})).toEqual([])
expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
} finally {
platform.mockRestore()
await fiber.dispose()
}
})
it('validates terminal spawn specs before allocating a PTY', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const base: SubprocessTerminalSpawnSpec = {
argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
}
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
await expect(ctx.subprocess.spawnTerminal({ ...base, rows: 1.5 })).rejects.toThrow('rows')
await expect(ctx.subprocess.spawnTerminal({ ...base, cols: 0 })).rejects.toThrow('cols')
await expect(ctx.subprocess.spawnTerminal({ ...base, graceMs: 0 })).rejects.toThrow('graceMs')
await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
await fiber.dispose()
})
it('terminates and joins an owned terminal during disposal', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const terminate = vi.fn()
const waitForExit = vi.fn(async () => true)
const terminal: SubprocessTerminalHandle = {
pid: 1,
output: new PassThrough(),
done: Promise.resolve({ exitCode: 0, signal: null }),
write: async () => {},
inspectForeground: async () => undefined,
signalForeground: async () => 1,
terminate,
waitForExit,
}
;(ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.add(terminal)
await fiber.dispose()
expect(terminate).toHaveBeenCalledOnce()
expect(waitForExit).toHaveBeenCalledOnce()
})
it('contains a terminal release failure after top-level exit', async () => {
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const terminal = {
pid: 123,
onData: () => ({ dispose: () => {} }),
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
exitListener = listener
return { dispose: () => {} }
},
write: () => {},
kill: () => {},
}
vi.resetModules()
vi.doMock('node-pty', () => ({ spawn: () => terminal }))
try {
const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
const ctx = new Context()
const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
const alive = new Set([124])
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 124, started: 'child' }],
isAlive: identity => alive.has(identity.pid),
signalGroup: () => {},
signalProcess: () => {},
}
const handle = await ctx.subprocess.spawnTerminal({
argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
})
exitListener?.({ exitCode: 0 })
await handle.done
await new Promise(resolve => setTimeout(resolve, 10))
alive.clear()
handle.terminate()
await handle.waitForExit()
await fiber.dispose()
} finally {
vi.doUnmock('node-pty')
vi.resetModules()
}
})
it('registers as ctx.subprocess and spawns managed handles', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest'
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
}
function syscall(number: number, ...args: number[]): string {
const six = [...args]
while (six.length < 6) six.push(0)
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
}
function fakeInternals() {
const files = new Map<string, string>()
const dirs = new Map<string, string[]>()
const memories = new Map<string, Buffer>()
const fds = new Map<number, string>()
const kills: Array<[number, NodeJS.Signals]> = []
let nextFd = 10
let ps = ''
let tpgid = '0'
const internals: ProcessInspectorInternals = {
readFile(path) {
const value = files.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
readDir(path) {
const value = dirs.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
open(path) {
if (!memories.has(path)) throw new Error(`missing ${path}`)
const fd = nextFd++
fds.set(fd, path)
return fd
},
read(fd, buffer, length, position) {
const path = fds.get(fd)
if (path === undefined) throw new Error('bad fd')
const source = memories.get(path)
if (source === undefined) throw new Error('missing memory')
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
},
close(fd) { fds.delete(fd) },
exec(_file, args) {
if (args.includes('tpgid=')) return tpgid
return ps
},
kill(pid, signal) { kills.push([pid, signal]) },
}
return {
internals, files, dirs, memories, kills,
setPs(value: string) { ps = value },
setTpgid(value: string) { tpgid = value },
}
}
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).toBeUndefined()
expect(parseProcStat('1 () ')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100', '101'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
fake.dirs.set('/proc/100/task', ['100'])
fake.dirs.set('/proc/101/task', ['101', '102'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
fake.files.set('/proc/100/task/100/syscall', 'running')
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
const fdSet = Buffer.alloc(0x11)
fdSet[0x10] = 1
fake.memories.set('/proc/101/mem', fdSet)
expect(inspector.isStdinWaiting(77)).toBe(true)
const poll = Buffer.alloc(8)
poll.writeInt32LE(0, 0)
poll.writeInt16LE(1, 4)
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
expect(inspector.isStdinWaiting(77)).toBe(true)
})
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(999))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.delete('/proc/100/task')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.set('/proc', ['100', '200'])
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
})
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
const noStdinPoll = Buffer.alloc(0x28)
noStdinPoll.writeInt32LE(2, 0x20)
noStdinPoll.writeInt16LE(1, 0x24)
fake.memories.set('/proc/100/mem', noStdinPoll)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
})
})
describe('macOS process inspector', () => {
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
const fake = fakeInternals()
fake.setTpgid('55\n')
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55)).toBe(false)
expect(inspector.processTree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
fake.internals.exec = () => { throw new Error('gone') }
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32')
})
})

View File

@@ -0,0 +1,275 @@
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
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')
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
members: 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.members }
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(Buffer.from('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' })
expect(await handle.waitForExit()).toBe(true)
expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
})
it('rejects invalid input and unsafe foreground signals', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
await expect(handle.write(Uint8Array.from([0xff]))).rejects.toThrow('valid UTF-8')
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.waitForExit()
await expect(handle.write(Buffer.from('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)
handle.terminate()
const quiescent = handle.waitForExit()
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(pty.kills).toEqual([])
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
expect(await quiescent).toBe(true)
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)
const waiting = handle.waitForExit()
let settled = false
void waiting.then(() => { settled = true })
pty.emitExit()
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
expect(await waiting).toBe(true)
})
it('rescans for descendants forked during TERM', async () => {
const pty = new FakePty()
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 handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
handle.terminate()
await handle.waitForExit()
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
expect(pty.kills).toEqual(['SIGTERM'])
})
it('retains captured descendants after reparenting', async () => {
vi.useFakeTimers()
const pty = new FakePty()
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 handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
handle.terminate()
const quiescent = handle.waitForExit()
await vi.advanceTimersByTimeAsync(25)
expect(await quiescent).toBe(true)
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
})
it('allows cleanup to retry after a surviving descendant leaves', 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, 10)
handle.terminate()
const first = expect(handle.waitForExit(new AbortController().signal)).rejects.toThrow('surviving pids: 124')
await vi.advanceTimersByTimeAsync(25)
await first
inspector.alive.delete(124)
handle.terminate()
expect(await handle.waitForExit()).toBe(true)
expect(pty.kills).toEqual(['SIGTERM'])
})
it('bounds waits and 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)
expect(await handle.waitForExit(AbortSignal.abort())).toBe(false)
const controller = new AbortController()
const bounded = handle.waitForExit(controller.signal)
controller.abort()
expect(await bounded).toBe(false)
handle.terminate()
const failed = expect(handle.waitForExit()).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 })
handle.terminate()
expect(await handle.waitForExit()).toBe(true)
})
it('contains process races and reacts to lifetime cancellation', 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 controller = new AbortController()
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1, controller.signal)
controller.abort()
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
await failed
inspector.alive.delete(124)
pty.throwKill = false
handle.terminate()
await handle.waitForExit()
const preAbortedPty = new FakePty()
const preAborted = new LocalTerminalHandle(
preAbortedPty.asPty(),
new FakeInspector(),
1,
AbortSignal.abort('stop'),
)
await preAborted.waitForExit()
expect(preAbortedPty.kills).toEqual(['SIGTERM'])
})
})