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.
This commit is contained in:
Tianyi Cui
2026-07-26 14:07:42 +08:00
parent 5e6edce4ed
commit 12a7e38417
8 changed files with 786 additions and 261 deletions

View File

@@ -1,36 +1,38 @@
/**
* Local-subprocess implementation of the subprocess seam. Each spawn is
* a detached process group with bounded, spill-backed output; disposal kills
* and joins live groups. It has no config: every limit arrives on the spec,
* so the deployment-varying choices stay with the calling seam's config (the
* bash executor's, today).
* Local implementation of the subprocess seam. Each spawn is a detached
* process tree with the spec's per-stream stdio dispositions; disposal
* terminates and joins live trees. It has no config: every disposition and
* limit arrives on the spec, so the deployment-varying choices stay with the
* calling seam's config (the bash executor's, the LSP host's, …).
* @module @deepseek-ai/dsh-subprocess-local
*/
import { Context } from 'cordis'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { spawnProcess } from './spawn.ts'
import { spawnSubprocess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
/**
* Local subprocess service: detached process groups, tail-keep truncation with
* bounded spill files, credential-scrubbed environment, and group
* SIGTERM→grace→SIGKILL escalation.
* Local subprocess service: detached process trees, Node-shaped stdio
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill
* files), credential-scrubbed environment, tree-scoped signalling with
* SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder.
*/
export class LocalSubprocessService extends SubprocessService {
/** Live handles retained only so disposal can kill and join them. */
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Test seam: spill knobs forwarded to spawnProcess. */
/** Test seam: spill and platform knobs forwarded to spawnSubprocess. */
internals: SpawnInternals = {}
constructor(ctx: Context) {
super(ctx)
ctx.effect(() => async () => {
// Await closure so even a TERM-trapping child cannot outlive the fiber.
// Terminate (escalating), then await closure so even a TERM-trapping
// child cannot outlive the fiber.
const pending: Promise<unknown>[] = []
for (const handle of this.live) {
handle.kill()
handle.terminate()
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}))
}
@@ -40,7 +42,7 @@ export class LocalSubprocessService extends SubprocessService {
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const handle = spawnProcess(spec, this.internals)
const handle = spawnSubprocess(spec, this.internals)
this.live.add(handle)
handle.done.then(
() => { this.live.delete(handle) },