Files
deepseek-harness/packages/subprocess/subprocess-local/tests/local.spec.ts
Tianyi Cui 12a7e38417 feat(subprocess): reshape the seam Node-ward for multi-consumer use
Review direction (tianyicui, PR #660): make the interface closer to Node's
API so the other process-running places can adopt it. The spec gains
per-stream stdio dispositions — 'pipe' (raw Readable/Writable for protocol
streams), 'inherit' (diagnostics to the parent), and collect mode ({maxBytes,
spill?} — the old bounded tail-keep shape, now with spill optional for
diagnostic tails). SubprocessOutcome carries exit facts only; collected
output stays readable through handle.collected after settlement (spill fds
are sealed at the settle boundary). The handle grows Node-style kill(signal)
(single signal, tree-scoped, no-op after settlement), terminate() (the
SIGTERM→grace→SIGKILL escalation, also driven by the spec signal),
waitForExit() (tree liveness, not just the direct child), and dispose()
(the cooperative stdin-EOF→SIGTERM→SIGKILL ladder from subagent-subprocess,
graces caller-supplied). Tree semantics are platform-correct: POSIX detached
groups with direct-child fallback; Windows taskkill /T with an injectable
runner. scrubbedParentEnv/SENSITIVE_ENV_PATTERN move to the seam as the one
shared scrub definition.

bash-local maps its config onto collect modes and batch stdin and reads
results through the collected readers; its kill() maps to terminate() so
task_kill keeps escalation semantics.
2026-07-26 14:07:42 +08:00

74 lines
2.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
return {
argv: ['bash', '-c', command],
cwd: process.cwd(),
stdio: {
stdin: 'ignore',
stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } },
},
graceMs: 200,
...overrides,
}
}
describe('LocalSubprocessService', () => {
it('registers as ctx.subprocess and spawns managed handles', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('echo managed'))
const result = await handle.done
expect(result.exitCode).toBe(0)
expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n')
await fiber.dispose()
})
it('disposal kills still-running processes and awaits their exit', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('sleep 60'))
await fiber.dispose()
const outcome = await handle.done
expect(outcome.signal).toBe('SIGTERM')
})
it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('true'))
const outcome = await handle.done
expect(outcome.exitCode).toBe(0)
await fiber.dispose()
})
it('disposal tolerates a handle whose spawn already failed', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
await expect(handle.done).rejects.toThrow()
await fiber.dispose()
})
it('disposal contains a spawn-failure rejection that races teardown', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
// Dispose before the rejection continuation removes the handle from the
// live set, so teardown itself must swallow the rejected done.
const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' }))
await fiber.dispose()
await expect(handle.done).rejects.toThrow()
})
it('loading a second implementation throws (one processes service per context — cordis standard)', async () => {
const ctx = new Context()
await ctx.plugin(LocalSubprocessService)
class SecondManager extends LocalSubprocessService {}
await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/)
})
})