From 74a8d0486e29b1c66e3828e61baade85ae9c66b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 30 Oct 2026 12:03:18 +0800 Subject: [PATCH] fix(subprocess): recheck a known live group member instead of rescanning /proc The Linux fallback owner polls its process group every 15 ms after the direct child exits. Remember the live member found by the last scan and read only its stat on later polls, so a long-lived background member no longer costs a full synchronous /proc enumeration per poll. Fixes #7600 --- .../subprocess-local/src/process-inspector.ts | 23 +++++++++----- .../subprocess/subprocess-local/src/spawn.ts | 17 ++++++---- .../tests/process-inspector.spec.ts | 31 ++++++++++++++++--- .../subprocess-local/tests/spawn.spec.ts | 25 +++++++++++++-- 4 files changed, 76 insertions(+), 20 deletions(-) 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