diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 297843584a..f525d45bc0 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -196,18 +196,27 @@ function readLinuxTerminalDevice( } } +function isLiveMember(stat: ProcStat | undefined, processGroupId: number): boolean { + return stat?.pgrp === processGroupId && !/^[ZXx]$/.test(stat.state) +} + /** - * Report whether a Linux process group has an executing member. `false` - * means the group contains only zombie/dead entries; `undefined` means the - * process table could not prove either outcome. + * Find an executing member of a Linux process group. `false` means the group + * contains only zombie/dead entries; `undefined` means the process table could + * not prove either outcome. A still-executing `knownMember` is returned from + * its own stat entry without enumerating `/proc`, so repeated polls of a + * long-lived group cost one file read instead of one per host process. * @param processGroupId - POSIX process-group id to inspect. + * @param knownMember - member PID returned by an earlier call, if any. * @param internals - injectable process-table operations. - * @returns Live-member presence, or `undefined` when unavailable/absent. + * @returns An executing member's PID, `false`, or `undefined` when unavailable/absent. */ -export function linuxProcessGroupHasLiveMembers( +export function linuxProcessGroupLiveMember( processGroupId: number, + knownMember?: number, internals: ProcessInspectorInternals = DEFAULT_INTERNALS, -): boolean | undefined { +): number | false | undefined { + if (knownMember !== undefined && isLiveMember(readLinuxStat(internals, knownMember), processGroupId)) return knownMember let entries: string[] try { entries = internals.readDir('/proc') @@ -220,7 +229,7 @@ export function linuxProcessGroupHasLiveMembers( const stat = readLinuxStat(internals, Number(entry)) if (stat?.pgrp !== processGroupId) continue matched = true - if (!/^[ZXx]$/.test(stat.state)) return true + if (isLiveMember(stat, processGroupId)) return stat.pid } return matched ? false : undefined } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index ed9baa2020..19015e1593 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -24,7 +24,7 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts import { waitWithAbort } from './managed-owner.ts' import { controlEnvironment, controlPipe } from './control-spawn.ts' import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control' -import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts' +import { linuxProcessGroupLiveMember } from './process-inspector.ts' import { OutputCollector, prepareManagedProcessBinding, type SpillFailureReporter } from './output.ts' type SpawnProcess = ( @@ -65,8 +65,8 @@ export interface SpawnInternals { taskkill?: (pid: number) => void /** Host platform override for signalling decisions. */ platform?: NodeJS.Platform - /** Linux process-group member probe (defaults to `/proc` inspection). */ - linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined + /** Linux process-group live-member probe (defaults to `/proc` inspection); receives the member PID it last returned. */ + linuxProcessGroupLiveMember?: (processGroupId: number, knownMember: number | undefined) => number | false | undefined } /** @@ -204,12 +204,13 @@ function fallbackOwner( pid: number | undefined, child: ChildProcess, taskkill: (pid: number) => void, - linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined, + linuxGroupLiveMember: (processGroupId: number, knownMember: number | undefined) => number | false | undefined, direct: Promise, ): BoundProcessOwner { let stopped = false let directSettled = false let observation: Promise | undefined + let liveMember: number | undefined void direct.then( () => { directSettled = true }, () => { directSettled = true }, @@ -220,7 +221,11 @@ function fallbackOwner( if (platform === 'win32') return child.exitCode === null && child.signalCode === null try { process.kill(-pid, 0) - if (directSettled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false + if (directSettled && platform === 'linux') { + const member = linuxGroupLiveMember(pid, liveMember) + if (member === false) return false + liveMember = member + } return true } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -478,7 +483,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter pid, child, internals.taskkill ?? taskkillProcessTree, - internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, + internals.linuxProcessGroupLiveMember ?? linuxProcessGroupLiveMember, direct, ) return bindManagedProcess(spec, { diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 039db91da4..95e8b9c55a 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { createProcessInspector, - linuxProcessGroupHasLiveMembers, + linuxProcessGroupLiveMember, parseProcStat, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' @@ -115,17 +115,38 @@ describe('Linux process inspector', () => { it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => { const fake = fakeInternals() - expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined() + expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBeUndefined() fake.dirs.set('/proc', ['self', '10', '11', '12']) fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z')) fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'X')) fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502')) - expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(false) - expect(linuxProcessGroupHasLiveMembers(99, fake.internals)).toBeUndefined() + expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBe(false) + expect(linuxProcessGroupLiveMember(99, undefined, fake.internals)).toBeUndefined() fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501')) - expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(true) + expect(linuxProcessGroupLiveMember(77, undefined, fake.internals)).toBe(11) + }) + + it('rechecks a known live group member without enumerating /proc', () => { + const fake = fakeInternals() + let scans = 0 + fake.internals.readDir = (path) => { + scans += 1 + return fake.dirs.get(path) ?? [] + } + fake.dirs.set('/proc', ['10', '11', '12']) + fake.files.set('/proc/10/stat', stat(10, 77, 10, -1, '500', 1, 'Z')) + fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501')) + fake.files.set('/proc/12/stat', stat(12, 77, 10, -1, '502')) + expect(linuxProcessGroupLiveMember(77, 11, fake.internals)).toBe(11) + expect(scans).toBe(0) + + fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501', 1, 'Z')) + expect(linuxProcessGroupLiveMember(77, 11, fake.internals)).toBe(12) + fake.files.set('/proc/12/stat', stat(12, 88, 12, -1, '502')) + expect(linuxProcessGroupLiveMember(77, 12, fake.internals)).toBe(false) + expect(scans).toBe(2) }) it('parses stat safely, captures only the rooted process tree, and signals identities', () => { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index dc77e0b429..2162239335 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -390,7 +390,7 @@ describe('spawnSubprocess', () => { const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { platform: 'linux', - linuxProcessGroupHasLiveMembers: () => false, + linuxProcessGroupLiveMember: () => false, }) const descendant = await waitForPidFile(pidFile) try { @@ -404,6 +404,27 @@ describe('spawnSubprocess', () => { } }) + it.skipIf(process.platform === 'win32')('passes the last live Linux group member back to the next probe', async () => { + const pidFile = join(spillDir, `known-member-${Date.now()}.pid`) + const known: Array = [] + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { + platform: 'linux', + linuxProcessGroupLiveMember: (_group, knownMember) => { + known.push(knownMember) + return known.length < 3 ? 4242 : false + }, + }) + const descendant = await waitForPidFile(pidFile) + try { + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + expect(known).toEqual([undefined, 4242, 4242]) + } finally { + process.kill(descendant, 'SIGKILL') + await waitGone(descendant) + } + }) + it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() @@ -1170,7 +1191,7 @@ describe('coverage seams', () => { // terminates the direct child when the escalation tier delivers SIGKILL. const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { platform: 'linux', - linuxProcessGroupHasLiveMembers: () => false, + linuxProcessGroupLiveMember: () => false, }) const realKill = process.kill.bind(process) let rootPid: number | undefined