fix(subprocess-local): fence descendant adoption on the shell's start identity

A recycled shell pid could donate an unrelated process's children to the
terminal session's cleanup signalling: post-exit rescans queried
processTree/processSession by numeric pid alone. The handle now captures
the spawned shell's start identity at construction and adopts newly
scanned members only while the root pid still carries it; already-adopted
members keep their own identities, which every signal already rechecks.
Regressions cover a recycled root donating an imposter child and a shell
whose identity was never observable; the terminal fakes now model the
root row the real /proc and ps scans include.

Also from the review round: tool-pty's dependency list is re-sorted, and
the LSP renderer documents the deliberate drive-letter reading of
ambiguous file: URIs (display-only blast radius).
This commit is contained in:
Tianyi Cui
2026-08-08 22:17:54 +08:00
parent d47df8ff9d
commit b9b25f81cb
5 changed files with 60 additions and 11 deletions
@@ -43,6 +43,8 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private cleanup: Promise<void> | undefined
private exited = false
private trackedDescendants: ProcessIdentity[] = []
/** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
private readonly rootIdentity: ProcessIdentity | undefined
/**
* @param terminal - allocated node-pty process.
@@ -55,6 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
private readonly graceMs: number,
) {
this.pid = terminal.pid
this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
this.done = this.outcome.promise
this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
@@ -112,10 +115,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
}
private descendants(): ProcessIdentity[] {
// Adopt newly scanned members only while the numeric root pid provably
// still carries the spawned shell's start identity: after the shell dies,
// a recycled pid's tree and session must not donate an unrelated
// process's children to this session's signalling. Already-adopted
// members keep their own start identities, which every signal rechecks.
const tree = this.inspector.processTree(this.pid)
const root = tree.find(member => member.pid === this.pid)
const rootVerified = this.rootIdentity !== undefined
&& root !== undefined
&& root.started === this.rootIdentity.started
this.trackedDescendants = this.survivors(this.unionMembers(
this.trackedDescendants,
this.inspector.processTree(this.pid),
this.inspector.processSession(this.pid),
...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [],
).filter(member => member.pid !== this.pid))
return this.trackedDescendants
}