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
+3
View File
@@ -146,6 +146,9 @@ export function renderUri(uri: string, workspaceUri: string): string {
return uri
}
if (workspace.protocol !== 'file:') return uri
// A `file:` URI does not carry its world's OS, so a leading `/X:` segment is
// read as a Windows drive. A POSIX workspace literally rooted at `/c:/...`
// would mis-render (display only; edits and reads use the exact URI).
const drivePath = /^\/[a-z](?::|%3A)/iu
const windowsWorld = workspace.hostname.length > 0 || drivePath.test(workspace.pathname)
const targetWindowsWorld = windowsWorld && (target.hostname.length > 0 || drivePath.test(target.pathname))
+1 -1
View File
@@ -50,8 +50,8 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
@@ -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
}
@@ -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: () => {},
@@ -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)