refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
275
packages/subprocess/subprocess-local/tests/terminal.spec.ts
Normal file
275
packages/subprocess/subprocess-local/tests/terminal.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user