Procházet zdrojové kódy

fix(subprocess): treat zombie-only groups as quiescent

Tianyi Cui před 1 měsícem
rodič
revize
d29c73ddd6

+ 29 - 0
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)

+ 9 - 0
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

+ 20 - 1
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()

+ 18 - 0
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()