diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index 761f5a7d34..ad77b2a6e2 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -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)) diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index 9027169941..f443bbbdf4 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -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:^", diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 2d3d0bb78e..2a763948bb 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -43,6 +43,8 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private cleanup: Promise | 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 } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 347b9c9330..c4e3f91522 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -249,7 +249,7 @@ describe('LocalSubprocessService', () => { ;(ctx.subprocess as InstanceType).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: () => {}, diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 8a6270883f..79501c7dc4 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -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() @@ -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)