From d29c73ddd6cbd4d5f4e8feab980f06961f5414f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:04:36 +0800 Subject: [PATCH] fix(subprocess): treat zombie-only groups as quiescent --- .../subprocess-local/src/process-inspector.ts | 29 +++++++++++++++++++ .../subprocess/subprocess-local/src/spawn.ts | 9 ++++++ .../tests/process-inspector.spec.ts | 21 +++++++++++++- .../subprocess-local/tests/spawn.spec.ts | 18 ++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 48b01e18e3..f31de010de 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -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[] { try { return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index feb0ed0767..9fa70250dc 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -23,6 +23,7 @@ import type { SubprocessOutputMode, SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts' /** * Build a child environment: explicit caller entries override the scrubbed @@ -52,6 +53,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 } /** @@ -312,6 +315,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform const taskkill = internals.taskkill ?? taskkillProcessTree + const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) @@ -366,6 +370,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } try { 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 } catch (error) { const code = (error as NodeJS.ErrnoException).code diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index bcc3ddb091..c90a7b3490 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -1,5 +1,9 @@ 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' 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', () => { + 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', () => { expect(parseProcStat('bad')).toBeUndefined() expect(parseProcStat('1 () ')).toBeUndefined() diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 08b6b1dbf8..7d92447794 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -268,6 +268,24 @@ describe('spawnSubprocess', () => { 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 () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now()