mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
(A1) terminate()/dispose()/service teardown keyed on direct-child settlement could leak a TERM-trapping descendant that outlived the leader (Codex reproduced it with a disowned trap-SIGTERM helper). kill()/terminate() now gate on tree liveness instead of outcome settlement; the SIGKILL escalation timer survives settle (unref'd, re-probing the tree); dispose's tier quiescence is whole-tree exit via a bounded waitForExit; the service's live set releases handles only when their tree is gone, and its teardown awaits tree exit. Three new suites pin the survivor scenarios end to end (terminate, dispose, service teardown). (A2) the escalation branch is now real tested behavior — its ignore is gone; the one remaining signalTree guard ignore states why it is unreachable through the handle verbs. (A3) docs contradictions fixed: the impl README's stale POSIX-only bullet now states the contained best-effort Windows tree story; the lsp-local README no longer claims taskkill failures stay visible (containment + the tree-liveness wait is the actual contract); the architecture tables (en+zh) list all three consumer families. (B1) OutputCollector keeps a byte-exact tail across uneven chunk boundaries (trim the head chunk instead of dropping it whole) — the LSP diagnostic-tail contract; pinned by a cross-chunk test. (B2) the subagent-acp coverage ignore is narrowed to exactly the never-settling success arm.
906 lines
36 KiB
TypeScript
906 lines
36 KiB
TypeScript
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess'
|
|
import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts'
|
|
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
|
|
|
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
|
failNextClose: { value: false },
|
|
failNextUnlink: { value: false },
|
|
}))
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs')>()
|
|
return {
|
|
...actual,
|
|
closeSync(fd: number): void {
|
|
if (failNextClose.value) {
|
|
failNextClose.value = false
|
|
throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
|
|
}
|
|
actual.closeSync(fd)
|
|
},
|
|
unlinkSync(path: Parameters<typeof actual.unlinkSync>[0]): void {
|
|
if (failNextUnlink.value) {
|
|
failNextUnlink.value = false
|
|
throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' })
|
|
}
|
|
actual.unlinkSync(path)
|
|
},
|
|
}
|
|
})
|
|
|
|
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
|
|
|
|
type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
|
|
stdoutMaxBytes?: number
|
|
stderrMaxBytes?: number
|
|
maxSpillBytes?: number
|
|
stdin?: string
|
|
}
|
|
|
|
function spec(command: string, overrides: SpecOverrides = {}) {
|
|
const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides
|
|
return {
|
|
argv: ['bash', '-c', command],
|
|
cwd: process.cwd(),
|
|
stdio: {
|
|
stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const,
|
|
stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } },
|
|
stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } },
|
|
},
|
|
graceMs: 3_000,
|
|
...rest,
|
|
}
|
|
}
|
|
|
|
/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */
|
|
async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
} catch {
|
|
return
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`)
|
|
}
|
|
|
|
async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (running.collected.stdout!.readFrom(0).text.includes(expected)) return
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
|
|
}
|
|
|
|
/** Await settlement and project both collected streams like a batch outcome. */
|
|
async function finish(running: SubprocessHandle) {
|
|
const outcome = await running.done
|
|
const final = (reader: SubprocessOutputReader | undefined) => {
|
|
const read = reader!.readFrom(0)
|
|
return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} }
|
|
}
|
|
return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) }
|
|
}
|
|
|
|
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const pid = Number(readFileSync(path, 'utf8').trim())
|
|
if (Number.isSafeInteger(pid) && pid > 0) return pid
|
|
} catch {
|
|
// The child shell has not written the pid file yet.
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
|
|
}
|
|
|
|
describe('spawnSubprocess', () => {
|
|
it('captures stdout on success', async () => {
|
|
const result = await finish(spawnSubprocess(spec('echo hello')))
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.signal).toBeNull()
|
|
expect(result.stdout.text).toBe('hello\n')
|
|
expect(result.stdout.truncated).toBe(false)
|
|
expect(result.stderr.text).toBe('')
|
|
})
|
|
|
|
it('captures stderr separately', async () => {
|
|
const result = await finish(spawnSubprocess(spec('echo oops >&2')))
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.text).toBe('')
|
|
expect(result.stderr.text).toBe('oops\n')
|
|
})
|
|
|
|
it('captures both streams', async () => {
|
|
const result = await finish(spawnSubprocess(spec('echo out; echo err >&2')))
|
|
expect(result.stdout.text).toBe('out\n')
|
|
expect(result.stderr.text).toBe('err\n')
|
|
})
|
|
|
|
it('reports non-zero exit codes', async () => {
|
|
const result = await finish(spawnSubprocess(spec('exit 42')))
|
|
expect(result.exitCode).toBe(42)
|
|
expect(result.signal).toBeNull()
|
|
})
|
|
|
|
it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => {
|
|
const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', {
|
|
env: { TERM: 'callers-choice' },
|
|
})))
|
|
expect(result.stdout.text).toBe('callers-choice\n')
|
|
})
|
|
|
|
it('runs in the requested cwd', async () => {
|
|
const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' })))
|
|
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
|
})
|
|
|
|
it('kills the process group with SIGTERM when the signal fires', async () => {
|
|
// spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout
|
|
// by firing this signal via a deadline (see executor.spec.ts); here we
|
|
// assert the kill itself lands as SIGTERM.
|
|
const controller = new AbortController()
|
|
const start = Date.now()
|
|
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
|
setTimeout(() => { controller.abort('deadline') }, 100)
|
|
const result = await running.done
|
|
expect(Date.now() - start).toBeLessThan(5_000)
|
|
expect(result.signal).toBe('SIGTERM')
|
|
expect(result.exitCode).toBeNull()
|
|
})
|
|
|
|
it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
|
|
const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
|
await waitForStdout(running, 'ready\n')
|
|
running.terminate()
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGKILL')
|
|
})
|
|
|
|
it('kill() sends one signal Node-style, without escalation', async () => {
|
|
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 }))
|
|
await waitForStdout(running, 'armed\n')
|
|
running.kill() // trapped SIGTERM, no SIGKILL follow-up
|
|
await new Promise(resolve => setTimeout(resolve, 400))
|
|
expect(running.collected.stdout).toBeDefined()
|
|
running.kill('SIGKILL') // explicit signal choice, still no timers
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGKILL')
|
|
})
|
|
|
|
it('kills the whole process group (grandchildren die too)', async () => {
|
|
// The subshell writes the sleep's pid then waits on it; killing the
|
|
// group must take the sleep down with bash.
|
|
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
|
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
|
const grandchild = await waitForPidFile(pidFile)
|
|
expect(grandchild).toBeGreaterThan(0)
|
|
|
|
running.kill()
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGTERM')
|
|
await waitGone(grandchild)
|
|
})
|
|
|
|
it('aborts via AbortSignal mid-run', async () => {
|
|
const controller = new AbortController()
|
|
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
|
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGTERM')
|
|
})
|
|
|
|
it('throws when the signal is already aborted before spawn', () => {
|
|
const controller = new AbortController()
|
|
controller.abort('too late')
|
|
expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal })))
|
|
.toThrow(/aborted before spawn: too late/)
|
|
})
|
|
|
|
it('rejects with a spawn error for a nonexistent cwd', async () => {
|
|
await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done)
|
|
.rejects.toThrow(/ENOENT/)
|
|
})
|
|
|
|
it('terminate() is idempotent (second call does not restart escalation)', async () => {
|
|
const running = spawnSubprocess(spec('sleep 60'))
|
|
running.terminate()
|
|
running.terminate()
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGTERM')
|
|
})
|
|
|
|
it('bounds inherited-pipe draining after the shell exits', async () => {
|
|
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
|
const started = Date.now()
|
|
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
|
const descendant = await waitForPidFile(pidFile)
|
|
try {
|
|
const result = await finish(running)
|
|
expect(Date.now() - started).toBeLessThan(1_000)
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.text).toBe('shell-done\n')
|
|
} finally {
|
|
process.kill(descendant, 'SIGKILL')
|
|
await waitGone(descendant)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('stdin and extra env (set by in-process plugins)', () => {
|
|
it('writes stdin to the command and closes it', async () => {
|
|
const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' })))
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.text).toBe('hello from stdin\n')
|
|
})
|
|
|
|
it('a command that reads stdin sees EOF when none is supplied', async () => {
|
|
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
|
|
// output (it does NOT block).
|
|
const result = await finish(spawnSubprocess(spec('cat')))
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.text).toBe('')
|
|
})
|
|
|
|
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
|
// With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device).
|
|
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
|
const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other')))
|
|
expect(none.stdout.text).toBe('char\n')
|
|
const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })))
|
|
expect(piped.stdout.text).toBe('socket\n')
|
|
})
|
|
|
|
it('merges ordinary extra env entries onto the scrubbed environment', async () => {
|
|
const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', {
|
|
env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' },
|
|
})))
|
|
expect(result.stdout.text).toBe('alpha/beta\n')
|
|
})
|
|
|
|
it('an explicit extra env entry overrides the credential scrub', async () => {
|
|
// EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
|
|
// entry is still honored — the scrub only drops AMBIENT process.env creds.
|
|
const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', {
|
|
env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' },
|
|
})))
|
|
expect(result.stdout.text).toBe('explicit-wins\n')
|
|
})
|
|
|
|
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
|
|
// The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE.
|
|
// The handler swallows that write error and `done` reports the child's real exit.
|
|
const big = 'x'.repeat(1024 * 1024)
|
|
const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big })))
|
|
expect(result.exitCode).toBe(7)
|
|
})
|
|
})
|
|
|
|
describe('output truncation and spill', () => {
|
|
it('applies stdout and stderr caps independently', async () => {
|
|
const result = await finish(spawnSubprocess(
|
|
spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', {
|
|
stdoutMaxBytes: 500,
|
|
stderrMaxBytes: 100,
|
|
}),
|
|
{ spillDir },
|
|
))
|
|
expect(result.stdout.truncated).toBe(false)
|
|
expect(result.stdout.text).toBe('x'.repeat(500))
|
|
expect(result.stderr.truncated).toBe(true)
|
|
expect(result.stderr.text.length).toBeLessThanOrEqual(100)
|
|
})
|
|
|
|
it('keeps the tail and spills the full stream to disk', async () => {
|
|
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.
|
|
const result = await finish(spawnSubprocess(
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
{ spillDir },
|
|
))
|
|
expect(result.stdout.truncated).toBe(true)
|
|
expect(result.stdout.text.length).toBeLessThanOrEqual(500)
|
|
expect(result.stdout.text).toContain('line-0200')
|
|
expect(result.stdout.text).not.toContain('line-0001')
|
|
expect(result.stdout.spillPath).toBeDefined()
|
|
const full = readFileSync(result.stdout.spillPath!, 'utf8')
|
|
expect(full).toContain('line-0001')
|
|
expect(full).toContain('line-0200')
|
|
})
|
|
|
|
it('does not truncate output exactly at the cap', async () => {
|
|
const result = await finish(spawnSubprocess(
|
|
spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
{ spillDir },
|
|
))
|
|
expect(result.stdout.truncated).toBe(false)
|
|
expect(result.stdout.text.length).toBe(500)
|
|
expect(result.stdout.spillPath).toBeUndefined()
|
|
})
|
|
|
|
it('settles with the tail and no spill path when final spill close fails', async () => {
|
|
failNextClose.value = true
|
|
const result = await finish(spawnSubprocess(
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
{ spillDir },
|
|
))
|
|
expect(failNextClose.value).toBe(false)
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.stdout.truncated).toBe(true)
|
|
expect(result.stdout.text).toContain('line-0200')
|
|
expect(result.stdout.spillPath).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('OutputCollector', () => {
|
|
it('keeps the tail of a single oversized chunk', () => {
|
|
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
|
collector.push(Buffer.from('0123456789abcdef'))
|
|
const out = collector.finalize()
|
|
expect(out.text).toBe('6789abcdef')
|
|
expect(out.truncated).toBe(true)
|
|
expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef')
|
|
})
|
|
|
|
it('retains a byte-exact tail across uneven chunk boundaries', () => {
|
|
// The old whole-chunk drop could under-retain; a diagnostic tail must be
|
|
// exactly the LAST maxBytes regardless of chunking.
|
|
const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir)
|
|
collector.push(Buffer.from('aaaa'))
|
|
collector.push(Buffer.from('bbbbbb'))
|
|
collector.push(Buffer.from('cc'))
|
|
const out = collector.finalize()
|
|
expect(out.text).toBe('aabbbbbbcc')
|
|
expect(Buffer.byteLength(out.text)).toBe(10)
|
|
expect(out.truncated).toBe(true)
|
|
})
|
|
|
|
it('readFrom returns increments and flags lossy reads', () => {
|
|
const collector = new OutputCollector(10, 100, 'test', spillDir)
|
|
collector.push(Buffer.from('aaaaa'))
|
|
const first = collector.readFrom(0)
|
|
expect(first.text).toBe('aaaaa')
|
|
expect(first.lossy).toBe(false)
|
|
expect(first.nextOffset).toBe(5)
|
|
|
|
collector.push(Buffer.from('bbbbb'))
|
|
const second = collector.readFrom(first.nextOffset)
|
|
expect(second.text).toBe('bbbbb')
|
|
expect(second.lossy).toBe(false)
|
|
|
|
// Push enough to slide the window past the last offset.
|
|
collector.push(Buffer.from('c'.repeat(20)))
|
|
const third = collector.readFrom(second.nextOffset)
|
|
expect(third.lossy).toBe(true)
|
|
expect(third.text).toBe('c'.repeat(10))
|
|
expect(third.spillPath).toBeDefined()
|
|
})
|
|
|
|
it('contains close failures and drops the spill path', () => {
|
|
const collector = new OutputCollector(4, 100, 'closefail', spillDir)
|
|
collector.push(Buffer.from('aaaa'))
|
|
collector.push(Buffer.from('bbbb'))
|
|
expect(collector.readFrom(0).spillPath).toBeDefined()
|
|
|
|
failNextClose.value = true
|
|
let out: ReturnType<typeof collector.finalize>
|
|
expect(() => { out = collector.finalize() }).not.toThrow()
|
|
|
|
expect(failNextClose.value).toBe(false)
|
|
expect(out!.text).toBe('bbbb')
|
|
expect(out!.truncated).toBe(true)
|
|
expect(out!.spillPath).toBeUndefined()
|
|
})
|
|
|
|
it('discards a spill that exceeds its configured cap', () => {
|
|
const collector = new OutputCollector(4, 8, 'bounded', spillDir)
|
|
collector.push(Buffer.from('aaaa'))
|
|
collector.push(Buffer.from('bbbb'))
|
|
const spillPath = collector.readFrom(0).spillPath!
|
|
expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb')
|
|
|
|
collector.push(Buffer.from('c'))
|
|
collector.push(Buffer.from('dddd'))
|
|
const out = collector.finalize()
|
|
expect(out.text).toBe('dddd')
|
|
expect(out.truncated).toBe(true)
|
|
expect(out.spillPath).toBeUndefined()
|
|
expect(() => readFileSync(spillPath)).toThrow()
|
|
})
|
|
|
|
it('does not create a spill when the first overflowing chunk exceeds the cap', () => {
|
|
const collector = new OutputCollector(4, 4, 'no-spill', spillDir)
|
|
collector.push(Buffer.from('abcdefgh'))
|
|
const out = collector.finalize()
|
|
expect(out.text).toBe('efgh')
|
|
expect(out.truncated).toBe(true)
|
|
expect(out.spillPath).toBeUndefined()
|
|
})
|
|
|
|
it('contains cleanup failures while disabling an oversize spill', () => {
|
|
const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir)
|
|
collector.push(Buffer.from('aaaa'))
|
|
collector.push(Buffer.from('bbbb'))
|
|
const spillPath = collector.readFrom(0).spillPath!
|
|
|
|
failNextClose.value = true
|
|
failNextUnlink.value = true
|
|
expect(() => { collector.push(Buffer.from('c')) }).not.toThrow()
|
|
expect(failNextClose.value).toBe(false)
|
|
expect(failNextUnlink.value).toBe(false)
|
|
expect(collector.finalize().spillPath).toBeUndefined()
|
|
unlinkSync(spillPath)
|
|
})
|
|
})
|
|
|
|
describe('killGroup', () => {
|
|
it('ignores non-positive pids', () => {
|
|
expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow()
|
|
expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow()
|
|
})
|
|
|
|
it('swallows ESRCH for vanished groups', async () => {
|
|
const running = spawnSubprocess(spec('true'))
|
|
await running.done
|
|
expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow()
|
|
})
|
|
|
|
it('handle.kill() after the tree died delivers no termination signal', async () => {
|
|
// Cleanup code commonly kills handles in a finally; once the tree is gone
|
|
// the pid may be reused, so a late kill must deliver nothing (the
|
|
// liveness PROBE — signal 0 — is the only process.kill allowed).
|
|
const running = spawnSubprocess(spec('true'))
|
|
await running.done
|
|
await running.waitForExit()
|
|
const spy = vi.spyOn(process, 'kill')
|
|
try {
|
|
running.kill()
|
|
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
|
|
expect(delivered).toEqual([])
|
|
} finally {
|
|
spy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('stdio dispositions', () => {
|
|
it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('cat'),
|
|
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } },
|
|
})
|
|
expect(running.stdin).toBeDefined()
|
|
expect(running.stdout).toBeDefined()
|
|
expect(running.stderr).toBeUndefined()
|
|
expect(running.collected.stdout).toBeUndefined()
|
|
expect(running.collected.stderr).toBeDefined()
|
|
|
|
const echoed = new Promise<string>((resolve) => {
|
|
let text = ''
|
|
running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') })
|
|
running.stdout!.on('end', () => { resolve(text) })
|
|
})
|
|
running.stdin!.end('through the pipe\n')
|
|
const outcome = await running.done
|
|
expect(outcome.exitCode).toBe(0)
|
|
expect(await echoed).toBe('through the pipe\n')
|
|
})
|
|
|
|
it('a collect mode without spill keeps only the in-memory tail (no file)', async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'),
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } },
|
|
}, { spillDir })
|
|
await running.done
|
|
const read = running.collected.stdout!.readFrom(0)
|
|
expect(read.lossy).toBe(true)
|
|
expect(read.text).toContain('line-0200')
|
|
expect(read.spillPath).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('dispose ladder', () => {
|
|
it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('read -r line; exit 0'),
|
|
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
})
|
|
await running.dispose({ eofGraceMs: 5_000, graceMs: 200 })
|
|
const outcome = await running.done
|
|
expect(outcome.exitCode).toBe(0)
|
|
expect(outcome.signal).toBeNull()
|
|
})
|
|
|
|
it('tier 2: an EOF-deaf child dies by SIGTERM', async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('sleep 60'),
|
|
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
})
|
|
await running.dispose({ eofGraceMs: 100, graceMs: 5_000 })
|
|
const outcome = await running.done
|
|
expect(outcome.signal).toBe('SIGTERM')
|
|
})
|
|
|
|
it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => {
|
|
const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60'))
|
|
await waitForStdout(running, 'armed\n')
|
|
const first = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
|
const second = running.dispose({ eofGraceMs: 50, graceMs: 200 })
|
|
expect(second).toBe(first)
|
|
await first
|
|
const outcome = await running.done
|
|
expect(outcome.signal).toBe('SIGKILL')
|
|
})
|
|
})
|
|
|
|
describe('windows tree semantics (injected platform)', () => {
|
|
it('kill and terminate route through taskkill by root pid', async () => {
|
|
const killed: number[] = []
|
|
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
|
|
spillDir,
|
|
platform: 'win32',
|
|
taskkill: (pid) => {
|
|
killed.push(pid)
|
|
// Simulate the forced tree termination taskkill performs.
|
|
try {
|
|
process.kill(pid, 'SIGKILL')
|
|
} catch {
|
|
// Already gone — matches taskkill's tolerated not-found status.
|
|
}
|
|
},
|
|
})
|
|
running.terminate()
|
|
const outcome = await running.done
|
|
expect(killed).toContain(running.pid)
|
|
expect(outcome.signal).toBe('SIGKILL')
|
|
})
|
|
|
|
it('waitForExit falls back to direct-child liveness where groups do not exist', async () => {
|
|
const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} })
|
|
await running.done
|
|
await expect(running.waitForExit()).resolves.toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('waitForExit', () => {
|
|
it('waits for the whole detached tree, not just the shell', async () => {
|
|
const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`)
|
|
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
|
const grandchild = await waitForPidFile(pidFile)
|
|
running.terminate()
|
|
await running.done
|
|
await expect(running.waitForExit()).resolves.toBe(true)
|
|
await expect(waitGone(grandchild, 100)).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('an aborted wait reports false while the tree lives', async () => {
|
|
const running = spawnSubprocess(spec('sleep 60'))
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
await expect(running.waitForExit(controller.signal)).resolves.toBe(false)
|
|
running.terminate()
|
|
await running.done
|
|
})
|
|
})
|
|
|
|
describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => {
|
|
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
|
|
// The leader spawns a TERM-trapping helper with all stdio detached from
|
|
// the collected pipes, then exits: the helper holds the GROUP alive while
|
|
// the direct child settles. The escalation must still reach it.
|
|
const pidFile = join(spillDir, `survivor-${Date.now()}.pid`)
|
|
const running = spawnSubprocess(spec(
|
|
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`,
|
|
{ graceMs: 300 },
|
|
))
|
|
const helper = await waitForPidFile(pidFile)
|
|
await running.done // direct child settled; helper survives in the group
|
|
expect(() => process.kill(helper, 0)).not.toThrow()
|
|
|
|
running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group
|
|
await expect(running.waitForExit()).resolves.toBe(true)
|
|
await waitGone(helper)
|
|
})
|
|
|
|
it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => {
|
|
const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`)
|
|
const running = spawnSubprocess(spec(
|
|
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
|
|
{ graceMs: 200 },
|
|
))
|
|
const helper = await waitForPidFile(pidFile)
|
|
await running.done
|
|
expect(() => process.kill(helper, 0)).not.toThrow()
|
|
|
|
await running.dispose({ eofGraceMs: 100, graceMs: 300 })
|
|
// The ladder only returns once the WHOLE tree is gone.
|
|
expect(() => process.kill(helper, 0)).toThrow()
|
|
})
|
|
|
|
it('service teardown awaits tree survivors, not just handle settlement', async () => {
|
|
const { Context } = await import('cordis')
|
|
const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local')
|
|
const ctx = new Context()
|
|
const fiber = await ctx.plugin(LocalSubprocessService)
|
|
;(ctx.subprocess as InstanceType<typeof LocalSubprocessService>).internals = { spillDir }
|
|
const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`)
|
|
const running = ctx.subprocess.spawn(spec(
|
|
`bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`,
|
|
{ graceMs: 200 },
|
|
))
|
|
const helper = await waitForPidFile(pidFile)
|
|
await running.done
|
|
await fiber.dispose()
|
|
// Teardown itself waited for the survivor to die.
|
|
expect(() => process.kill(helper, 0)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('coverage seams', () => {
|
|
it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
|
|
expect(() => { taskkillProcessTree(-1) }).not.toThrow()
|
|
expect(() => { taskkillProcessTree(0) }).not.toThrow()
|
|
// On POSIX there is no taskkill; spawnSync reports the failure in its
|
|
// result and the function stays silent — the same containment Windows
|
|
// relies on for an already-absent tree.
|
|
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
|
|
})
|
|
|
|
it('dispose on a spawn-failed handle observes the rejection and returns', async () => {
|
|
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
|
|
const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 })
|
|
await expect(running.done).rejects.toThrow()
|
|
await expect(disposal).resolves.toBeUndefined()
|
|
})
|
|
|
|
it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('echo to-parent; echo err >&2'),
|
|
stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } },
|
|
})
|
|
const outcome = await running.done
|
|
expect(outcome.exitCode).toBe(0)
|
|
expect(running.stdout).toBeUndefined()
|
|
expect(running.collected.stdout).toBeUndefined()
|
|
expect(running.collected.stderr!.readFrom(0).text).toBe('err\n')
|
|
})
|
|
|
|
it('terminate() after the tree died delivers no termination signal', async () => {
|
|
const running = spawnSubprocess(spec('true'))
|
|
await running.done
|
|
await running.waitForExit()
|
|
const spy = vi.spyOn(process, 'kill')
|
|
try {
|
|
running.terminate()
|
|
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
|
|
expect(delivered).toEqual([])
|
|
} finally {
|
|
spy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('waitForExit on a failed spawn reports exited immediately', async () => {
|
|
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' }))
|
|
await expect(running.done).rejects.toThrow()
|
|
await expect(running.waitForExit()).resolves.toBe(true)
|
|
})
|
|
|
|
it('dispose() on an already-exited tree returns without delivering a signal', async () => {
|
|
const running = spawnSubprocess(spec('true'))
|
|
await running.done
|
|
await running.waitForExit()
|
|
const spy = vi.spyOn(process, 'kill')
|
|
try {
|
|
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
|
const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0)
|
|
expect(delivered).toEqual([])
|
|
} finally {
|
|
spy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => {
|
|
const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' }))
|
|
expect(running.stdin).toBeUndefined()
|
|
await running.done
|
|
await running.dispose({ eofGraceMs: 50, graceMs: 50 })
|
|
expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n')
|
|
})
|
|
})
|
|
|
|
describe('coverage seams 2', () => {
|
|
it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => {
|
|
let killedPid = 0
|
|
const running = spawnSubprocess(spec('sleep 60'), {
|
|
spillDir,
|
|
platform: 'win32',
|
|
taskkill: (pid) => {
|
|
killedPid = pid
|
|
try {
|
|
process.kill(pid, 'SIGKILL')
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
},
|
|
})
|
|
const aborted = new AbortController()
|
|
aborted.abort()
|
|
await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch
|
|
running.terminate()
|
|
await running.done
|
|
expect(killedPid).toBe(running.pid)
|
|
await expect(running.waitForExit()).resolves.toBe(true)
|
|
})
|
|
|
|
it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => {
|
|
const kills: number[] = []
|
|
const running = spawnSubprocess({
|
|
...spec('sleep 60'),
|
|
stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } },
|
|
}, {
|
|
spillDir,
|
|
platform: 'win32',
|
|
taskkill: (pid) => {
|
|
kills.push(pid)
|
|
try {
|
|
process.kill(pid, 'SIGKILL')
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
},
|
|
})
|
|
await running.dispose({ eofGraceMs: 50, graceMs: 5_000 })
|
|
// Exactly one forced tree termination: no POSIX SIGTERM tier ran.
|
|
expect(kills).toEqual([running.pid])
|
|
})
|
|
|
|
it('dispose throws when even SIGKILL produces no exit within the grace', async () => {
|
|
// An inert taskkill simulates a tree that never reports exit.
|
|
const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} })
|
|
await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 }))
|
|
.rejects.toThrow(/did not exit within 40ms after forced termination/)
|
|
// Real cleanup: the injected platform spawned without detachment, so the
|
|
// child is a plain (group-less) POSIX process — kill it directly.
|
|
process.kill(running.pid, 'SIGKILL')
|
|
await running.done
|
|
})
|
|
|
|
it("stderr: 'pipe' exposes the raw stream", async () => {
|
|
const running = spawnSubprocess({
|
|
...spec('echo err >&2'),
|
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' },
|
|
})
|
|
expect(running.stderr).toBeDefined()
|
|
const text = new Promise<string>((resolve) => {
|
|
let out = ''
|
|
running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') })
|
|
running.stderr!.on('end', () => { resolve(out) })
|
|
})
|
|
await running.done
|
|
expect(await text).toBe('err\n')
|
|
})
|
|
})
|
|
|
|
describe('argv validation', () => {
|
|
it('rejects an empty argv before spawning', () => {
|
|
expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/)
|
|
})
|
|
|
|
it('rejects an empty program name before spawning', () => {
|
|
expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
|
|
})
|
|
|
|
it('spawns argv verbatim without shell interpretation', async () => {
|
|
const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }))
|
|
expect(result.stdout.text).toBe('$HOME')
|
|
})
|
|
})
|
|
|
|
describe('abort edge cases', () => {
|
|
it('reports a fallback reason for reason-less pre-aborted signals', () => {
|
|
// Real AbortControllers always set a DOMException reason; signal-like
|
|
// objects from other libraries may not — the fallback covers them.
|
|
const bare = {
|
|
aborted: true,
|
|
reason: undefined,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
} as unknown as AbortSignal
|
|
expect(() => spawnSubprocess(spec('echo hi', { signal: bare })))
|
|
.toThrow(/aborted before spawn: aborted/)
|
|
})
|
|
|
|
it('reports the terminating signal of an externally self-killed command', async () => {
|
|
// spawnProcess reports the raw signal; whether it counts as timeout/cancel is the
|
|
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
|
const result = await finish(spawnSubprocess(spec('kill -TERM $$')))
|
|
expect(result.signal).toBe('SIGTERM')
|
|
})
|
|
})
|
|
|
|
describe('environment and spill-file hardening', () => {
|
|
it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => {
|
|
process.env.DSH_TEST_API_KEY = 'super-secret'
|
|
process.env.DSH_TEST_TOKEN = 'also-secret'
|
|
process.env.DSH_TEST_PLAIN = 'visible'
|
|
try {
|
|
const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')))
|
|
expect(result.stdout.text.trim()).toBe('[absent|absent|absent]')
|
|
} finally {
|
|
delete process.env.DSH_TEST_API_KEY
|
|
delete process.env.DSH_TEST_TOKEN
|
|
delete process.env.DSH_TEST_PLAIN
|
|
}
|
|
})
|
|
|
|
it('injects only the current trusted DSH environment after scrubbing ambient values', async () => {
|
|
process.env.DSH_STALE = 'old-value'
|
|
try {
|
|
const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', {
|
|
dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' },
|
|
})))
|
|
expect(result.stdout.text.trim()).toBe('[absent|1|current-session]')
|
|
} finally {
|
|
delete process.env.DSH_STALE
|
|
}
|
|
})
|
|
|
|
it('rejects DSH variables on the ordinary env channel', () => {
|
|
expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } })))
|
|
.toThrow(/DSH_WRONG_CHANNEL.*dshEnv/)
|
|
})
|
|
|
|
it('rejects ordinary variables on the managed env channel', () => {
|
|
const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment
|
|
expect(() => spawnSubprocess(spec('true', { dshEnv: invalid })))
|
|
.toThrow(/managed child env.*PATH.*use env/)
|
|
})
|
|
|
|
it('creates spill files with owner-only permissions and random names', async () => {
|
|
const result = await finish(spawnSubprocess(
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
{ spillDir },
|
|
))
|
|
const path = result.stdout.spillPath!
|
|
expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/)
|
|
const mode = statSync(path).mode & 0o777
|
|
expect(mode).toBe(0o600)
|
|
})
|
|
|
|
it('defaults spills into a private per-process directory', async () => {
|
|
const result = await finish(spawnSubprocess(
|
|
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
|
))
|
|
const dir = dirname(result.stdout.spillPath!)
|
|
expect(dir).toMatch(/dsh-subprocess-/)
|
|
const mode = statSync(dir).mode & 0o777
|
|
expect(mode).toBe(0o700)
|
|
})
|
|
|
|
it('killGroup never throws, even for EPERM-style failures', () => {
|
|
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
throw Object.assign(new Error('EPERM'), { code: 'EPERM' })
|
|
})
|
|
try {
|
|
expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow()
|
|
} finally {
|
|
spy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('honors AbortSignal on background-style runs (no timeout)', async () => {
|
|
const controller = new AbortController()
|
|
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
|
setTimeout(() => { controller.abort() }, 50)
|
|
const result = await running.done
|
|
expect(result.signal).toBe('SIGTERM')
|
|
})
|
|
})
|