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

View File

@@ -249,7 +249,7 @@ describe('LocalSubprocessService', () => {
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 124, started: 'child' }],
processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }],
processSession: () => [],
isAlive: identity => alive.has(identity.pid),
signalGroup: () => {},

View File

@@ -52,6 +52,8 @@ class FakePty {
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
/** The shell's own row, present like the real /proc- and ps-backed scans; tests recycle or drop it. */
root: ProcessIdentity | undefined = { pid: 123, started: 'shell' }
members: ProcessIdentity[] = []
sessionMembers: ProcessIdentity[] = []
readonly alive = new Set<number>()
@@ -63,7 +65,7 @@ class FakeInspector implements ProcessInspector {
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] }
processSession() { return this.sessionMembers }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
@@ -189,19 +191,50 @@ describe('LocalTerminalHandle', () => {
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
})
it('does not adopt the children of a recycled shell pid', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
pty.emitExit()
const imposterChild = { pid: 999, started: 'imposter-child' }
inspector.root = { pid: 123, started: 'imposter' }
inspector.members = [imposterChild]
inspector.alive.add(imposterChild.pid)
await handle.terminate()
expect(inspector.processes).toEqual([])
})
it('adopts nothing when the shell identity was never observable', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.root = undefined
const orphan = { pid: 321, started: 'unverifiable' }
inspector.members = [orphan]
inspector.alive.add(orphan.pid)
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
await handle.terminate()
expect(inspector.processes).toEqual([])
expect(pty.kills).toEqual(['SIGTERM'])
})
it('rescans for descendants forked during TERM', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const root = { pid: 123, started: 'shell' }
let reads = 0
inspector.processTree = () => {
reads += 1
if (reads === 1) {
inspector.alive.add(124)
return [{ pid: 124, started: 'first' }]
}
if (reads === 1) return [root]
if (reads === 2) {
inspector.alive.add(124)
return [root, { pid: 124, started: 'first' }]
}
if (reads === 3) {
inspector.alive.add(125)
return [{ pid: 125, started: 'late' }]
return [root, { pid: 125, started: 'late' }]
}
return []
}
@@ -256,9 +289,10 @@ describe('LocalTerminalHandle', () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const captured = { pid: 124, started: 'captured' }
const root = { pid: 123, started: 'shell' }
let reads = 0
inspector.alive.add(captured.pid)
inspector.processTree = () => reads++ === 0 ? [captured] : []
inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] }
inspector.signalProcess = (identity, signal) => {
inspector.processes.push([identity.pid, signal])
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)