fix(subprocess): treat zombie-only groups as quiescent
This commit is contained in:
@@ -87,6 +87,35 @@ function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @param processGroupId - POSIX process-group id to inspect.
|
||||||
|
* @param internals - injectable process-table operations.
|
||||||
|
* @returns Live-member presence, or `undefined` when unavailable/absent.
|
||||||
|
*/
|
||||||
|
export function linuxProcessGroupHasLiveMembers(
|
||||||
|
processGroupId: number,
|
||||||
|
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
|
||||||
|
): boolean | undefined {
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = internals.readDir('/proc')
|
||||||
|
} catch (_unreadableProcDirectory) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
let matched = false
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!/^\d+$/.test(entry)) continue
|
||||||
|
const stat = readLinuxStat(internals, Number(entry))
|
||||||
|
if (stat?.pgrp !== processGroupId) continue
|
||||||
|
matched = true
|
||||||
|
if (!/^[ZXx]$/.test(stat.state)) return true
|
||||||
|
}
|
||||||
|
return matched ? false : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
|
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
|
||||||
try {
|
try {
|
||||||
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
|
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
SubprocessOutputMode,
|
SubprocessOutputMode,
|
||||||
SubprocessSpawnSpec,
|
SubprocessSpawnSpec,
|
||||||
} from '@deepseek-ai/dsh-subprocess'
|
} from '@deepseek-ai/dsh-subprocess'
|
||||||
|
import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a child environment: explicit caller entries override the scrubbed
|
* Build a child environment: explicit caller entries override the scrubbed
|
||||||
@@ -52,6 +53,8 @@ export interface SpawnInternals {
|
|||||||
taskkill?: (pid: number) => void
|
taskkill?: (pid: number) => void
|
||||||
/** Host platform override for signalling decisions. */
|
/** Host platform override for signalling decisions. */
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
|
/** Linux process-group member probe (defaults to `/proc` inspection). */
|
||||||
|
linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -312,6 +315,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||||
const platform = internals.platform ?? process.platform
|
const platform = internals.platform ?? process.platform
|
||||||
const taskkill = internals.taskkill ?? taskkillProcessTree
|
const taskkill = internals.taskkill ?? taskkillProcessTree
|
||||||
|
const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers
|
||||||
|
|
||||||
if (spec.signal?.aborted) {
|
if (spec.signal?.aborted) {
|
||||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
||||||
@@ -366,6 +370,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, 0)
|
process.kill(-pid, 0)
|
||||||
|
// A group containing only unreaped zombies still answers kill(0), but
|
||||||
|
// it can execute no work and cannot be signalled into quiescence. Only
|
||||||
|
// inspect after direct-child settlement so live-process polls remain a
|
||||||
|
// syscall rather than repeated process-table scans.
|
||||||
|
if (settled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException).code
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
import {
|
||||||
|
createProcessInspector,
|
||||||
|
linuxProcessGroupHasLiveMembers,
|
||||||
|
parseProcStat,
|
||||||
|
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||||
|
|
||||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
|
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
|
||||||
@@ -63,6 +67,21 @@ function fakeInternals() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Linux process inspector', () => {
|
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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
fake.files.set('/proc/11/stat', stat(11, 77, 10, -1, '501'))
|
||||||
|
expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
|
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
|
||||||
expect(parseProcStat('bad')).toBeUndefined()
|
expect(parseProcStat('bad')).toBeUndefined()
|
||||||
expect(parseProcStat('1 () ')).toBeUndefined()
|
expect(parseProcStat('1 () ')).toBeUndefined()
|
||||||
|
|||||||
@@ -268,6 +268,24 @@ describe('spawnSubprocess', () => {
|
|||||||
expect(result.signal).toBe('SIGTERM')
|
expect(result.signal).toBe('SIGTERM')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not wait for a Linux group that has only zombie members', async () => {
|
||||||
|
const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`)
|
||||||
|
let hasLiveMembers = false
|
||||||
|
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), {
|
||||||
|
platform: 'linux',
|
||||||
|
linuxProcessGroupHasLiveMembers: () => hasLiveMembers,
|
||||||
|
})
|
||||||
|
const descendant = await waitForPidFile(pidFile)
|
||||||
|
try {
|
||||||
|
await running.done
|
||||||
|
await expect(running.waitForExit()).resolves.toBe(true)
|
||||||
|
} finally {
|
||||||
|
hasLiveMembers = true
|
||||||
|
running.terminate()
|
||||||
|
await waitGone(descendant)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
|
|||||||
Reference in New Issue
Block a user