Files
deepseek-harness/packages/subprocess/subprocess-local/tests/spawn.spec.ts
T
Tianyi Cui 79a28ad6d9 fix(subprocess): tree-scoped escalation and byte-exact tails (Codex round 1)
(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.
2026-07-26 16:50:36 +08:00

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')
})
})