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.
This commit is contained in:
Tianyi Cui
2026-07-27 04:41:04 +08:00
parent cb1864795e
commit f81fcccd93
27 changed files with 87 additions and 155 deletions

View File

@@ -392,11 +392,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
}
}
const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => {
// Guard on TREE liveness, not outcome settlement: a TERM-trapping helper
// can outlive the settled direct child and must stay signalable, while a
// fully-dead tree (possible pid reuse) must not be re-signalled from a
// caller's finally block.
// The dispose ladder's tier primitive (not on the handle — terminate() is
// the only consumer-facing termination verb). Guards on TREE liveness, not
// outcome settlement: a TERM-trapping helper can outlive the settled direct
// child and must stay signalable, while a fully-dead tree (possible pid
// reuse) must not be re-signalled by a later tier.
const kill = (sig: NodeJS.Signals): void => {
if (!treeAlive()) return
signalTree(platform, pid, sig, child, taskkill)
}
@@ -404,15 +405,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
const terminate = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
if (!treeAlive()) return
signalTree(platform, pid, 'SIGTERM', child, taskkill)
kill('SIGTERM')
// The escalation must survive direct-child settlement — the leader dying
// does not mean the tree died — so settle does not clear this timer, and
// it re-probes tree liveness before force-killing. It stays ref'd: the
// pending SIGKILL is a commitment, and a parent exiting before it fires
// would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = setTimeout(() => {
if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill)
}, spec.graceMs)
// kill() re-probes tree liveness before force-killing. It stays ref'd:
// the pending SIGKILL is a commitment, and a parent exiting before it
// fires would orphan a trapped survivor. Self-bounds at graceMs.
graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)
}
// The caller owns timeout classification; this layer only reacts to abort.
@@ -514,7 +513,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
...stderrCollector !== undefined ? { stderr: stderrCollector } : {},
},
done,
kill,
terminate,
waitForExit,
dispose,