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
This commit is contained in:
Turtle committed 2026-10-30 12:03:18 +08:00
1 parent 5f01833f80
commit 74a8d0486e
4 files changed
+76 -20

No files matched your search

@@ -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
}
@@ -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<SubprocessOutcome>,
): BoundProcessOwner {
let stopped = false
let directSettled = false
let observation: Promise<void> | 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, {
@@ -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', () => {
@@ -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<number | undefined> = []
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