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

@@ -30,11 +30,11 @@ export interface ConnectionSpec {
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
/**
* Bound (ms) for draining pipes a surviving helper still holds after the
* server exits; the instance passes its kill grace so exit observation is
* never slower than the escalation it feeds.
* The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of
* {@link LspConnection.terminate}'s escalation, and the bound for draining
* pipes a surviving helper still holds after the server exits.
*/
readonly pipeDrainGraceMs: number
readonly killGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
@@ -98,7 +98,7 @@ export class LspConnection {
stdout: 'pipe',
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.pipeDrainGraceMs,
graceMs: spec.killGraceMs,
// spec.env mixes the scrubbed base with explicit config entries; a
// configured DSH_* fact takes the managed channel the seam reserves.
...splitEnvChannels(spec.env),
@@ -210,14 +210,9 @@ export class LspConnection {
return this.nextId
}
/** Request termination of the server's process tree (SIGTERM, no escalation). */
/** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
terminate(): void {
this.handle.kill('SIGTERM')
}
/** Force termination of the server's process tree. */
kill(): void {
this.handle.kill('SIGKILL')
this.handle.terminate()
}
/**
@@ -235,9 +230,10 @@ export class LspConnection {
messages = this.decoder.push(chunk)
} catch (error) {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// SIGKILL the whole group so helper processes don't outlive the leader.
// terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then
// the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window).
this.fail(asError(error))
this.handle.kill('SIGKILL')
this.handle.terminate()
return
}
for (const message of messages) this.dispatch(message)

View File

@@ -285,8 +285,6 @@ class LocalLspProvider implements LspProvider {
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
// Exit observation must never be slower than the escalation it feeds.
pipeDrainGraceMs: this.config.killGraceMs,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}

View File

@@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec {
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
readonly killGraceMs: number
}
/**
* Force-kill a process tree only when graceful termination did not make it exit.
* @param treeExited - whether the tree exited within its grace period.
* @param forceKill - forceful process-tree termination primitive.
*/
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
if (!treeExited) forceKill()
}
/**
@@ -311,17 +300,14 @@ export class LspInstance {
await abortable(this.connection.closed, signal)
}
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
/**
* Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL),
* then await leader and helper exit. The awaits are unbounded on purpose:
* the seam's escalation already committed to SIGKILL, so quiescence — not
* another timer — is the postcondition disposal owes its callers.
*/
private async forceTerminate(): Promise<void> {
this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let treeExited: boolean
try {
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally {
graceDeadline[Symbol.dispose]()
}
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([
this.connection.closed,
this.connection.waitForProcessTreeExit(),