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:
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
|
||||
|
||||
Reference in new issue
Block a user