Files
deepseek-harness/packages/subprocess/subprocess-local/README.md
T
Tianyi Cui f81fcccd93 refactor(subprocess): keep terminate() as the seam's only termination verb
Delete kill(signal?) from SubprocessHandle: consumers stop a process only
through terminate()'s tree-scoped SIGTERM→graceMs→SIGKILL escalation
(idempotent, also driven by the spec's abort signal, a no-op once the tree
is gone). The single-signal verb had exactly one consumer family —
lsp-local — and what it bought there was a private re-implementation of
the same escalation. The internal kill closure stays in spawn.ts as the
dispose ladder's tier primitive; terminate() now routes through it too.

lsp-local collapses onto the seam's escalation:
- LspConnection replaces its terminate()/kill() pair with one terminate()
  that delegates to handle.terminate(). Behavior change: the
  framing-failure path terminates instead of instant SIGKILL, so a
  misbehaving server now gets SIGTERM plus the killGraceMs window to
  flush before SIGKILL.
- ConnectionSpec.pipeDrainGraceMs becomes killGraceMs: one grace, the
  spawn spec's graceMs, drives both the escalation window and post-exit
  pipe draining (the provider already passed killGraceMs for it).
- LspInstance.forceTerminate() drops its hand-rolled bounded first wait
  (LSP_KILL_GRACE) and escalateProcessTree (deleted with its export and
  unit test): the seam's escalation already commits to SIGKILL after
  killGraceMs, so only the unbounded quiescence awaits stay load-bearing.

Tests: kill()-shaped spawn specs become terminate()-shaped or fold into
the terminate() suites (group-wide delivery; the settled no-op case was
already pinned by 'terminate() after the tree died'); tree-survivor
coverage is intact. A stderr-'inherit' disposition test completes the
stdout/stderr symmetry so the scoped subprocess+lsp coverage gate stands
alone instead of leaning on subagent-acp's cross-package runs.

Docs: SubprocessHandle type-equiv block, seam/impl/group READMEs, and the
consumer-migration Agent Note lose the kill(signal?) vocabulary (zh pairs
re-recorded); cordis api/services catalogs regenerated.
2026-07-27 04:41:04 +08:00

4.8 KiB

@deepseek-ai/dsh-subprocess-local

English | 中文

Local implementation of the @deepseek-ai/dsh-subprocess seam: LocalSubprocessService spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs (dsh-bash-local, dsh-lsp-local, dsh-subagent-acp).

Behavior (and where it came from)

  • Detached process trees with platform-correct signalling — POSIX children are spawned detached (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via taskkill /PID <pid> /T /F (injectable for tests). terminate() — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; dispose(graces) runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools.
  • Per-stream dispositions'pipe' hands the raw stream to the caller untouched (protocol framing stays consumer-owned); 'inherit' passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting spill keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are 0600 with random names under a lazily-created 0700 per-process directory.
  • Credential scrub + managed DSH_* mergeprocess.env minus credential-shaped vars (*KEY*/*SECRET*/*TOKEN*) and all ambient DSH_* names; a spec's ordinary env merges after the scrub but rejects DSH_*; managed dshEnv rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is /dev/null. See the stdin/env Agent Note and managed environment Agent Note.
  • Offset-based reads — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
  • Terminate-and-join disposal — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.

Model Experience

Indirectly, through consumer seams (today the bash executor family behind dsh-tool-bash), which own all model-facing rendering of process output and lifecycle.

KV Cache effect

No direct invalidation; the named consumers own any request-prefix changes.

Known Limitations and Deferred Work

  • Windows tree support is best-effort and untested in CI — termination routes through taskkill /PID <pid> /T /F with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and packages/subprocess/* is excluded from the Windows test matrix.
  • The credential scrub is a name heuristic*KEY*/*SECRET*/*TOKEN* only; differently-named secrets (e.g. *PASSWORD*) pass through, and a whitelist for over-scrubbed vars is noted future work.
  • Completed spill files are not deleted — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind.

The raw process handling lives in src/spawn.ts; src/index.ts is the service wiring.