Sfoglia il codice sorgente

fix(subprocess): close native lifecycle gaps

pku-xht 3 settimane fa
parent
commit
71e27e4667

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md
-2026-08-20-subprocess-native-containment.md: 334a5a89056eb4fbc2a22874c81530dc0e6c8a8c
-2026-08-20-subprocess-native-containment.zh.md: fec48b641f286a026fc7e78b03e3a45a24d74c61
+2026-08-20-subprocess-native-containment.md: f1094f42cdec16e8b5d1717975fe108800c65cb8
+2026-08-20-subprocess-native-containment.zh.md: 63b9a4a9691b9d7a964bf4992cea9ec228e499ca

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md

@@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
+Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
+Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 

+ 10 - 10
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -6,10 +6,11 @@ import type { ChildProcess } from 'node:child_process'
 import { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
-import { DirectResultUnavailableError, observeChildClose, waitWithAbort } from './managed-owner.ts'
+import { DirectResultUnavailableError, observeChildLifecycle } from './managed-owner.ts'
 import { childEnv } from './spawn.ts'
 import {
   cleanupAfterRunner,
+  type RunnerInvocation,
   runnerDirectResult,
   runnerFiles,
   runnerStdio,
@@ -23,7 +24,7 @@ export interface LinuxScopeInternals {
   systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
   systemdRun?: string
   systemctl?: string
-  runnerInvocation?: string[]
+  runnerInvocation?: RunnerInvocation
 }
 
 interface SystemctlResult {
@@ -72,7 +73,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
   const runSync = internals.spawnSync ?? spawnSync
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [runnerCommand, ...runnerPrefix] = invocation
-  if (runnerCommand === undefined) return false
   const systemdRun = internals.systemdRun ?? 'systemd-run'
   const systemctl = internals.systemctl ?? 'systemctl'
   const timeout = 5_000
@@ -117,7 +117,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     private readonly onForceKillAttempt: () => void,
   ) {}
 
-  signal(signal: NodeJS.Signals): void {
+  signal(signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped) return
     const result = this.runSync(this.systemctl, [
       '--user',
@@ -165,13 +165,13 @@ class SystemdScopeOwner implements BoundProcessOwner {
     return true
   }
 
-  async waitForExit(signal?: AbortSignal): Promise<boolean> {
-    if (this.stopped) return true
+  async waitForExit(): Promise<void> {
+    if (this.stopped) return
     this.observation ??= (async () => {
       while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
       this.stopped = true
     })()
-    return waitWithAbort(this.observation, signal)
+    await this.observation
   }
 }
 
@@ -212,7 +212,7 @@ export function launchLinuxScope(
     env: childEnv(),
     stdio: runnerStdio(spec),
   })
-  const closed = observeChildClose(child)
+  const lifecycle = observeChildLifecycle(child)
   let forceKillAttempted = false
   const owner = new SystemdScopeOwner(
     `${unitBase}.scope`,
@@ -222,13 +222,13 @@ export function launchLinuxScope(
     child,
     () => { forceKillAttempted = true },
   )
-  const result = runnerDirectResult(child, files, closed)
+  const result = runnerDirectResult(child, files, lifecycle.exited)
   const direct = result.direct.catch(async (error: unknown): Promise<SubprocessOutcome> => {
     if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error
     await owner.waitForExit()
     return { exitCode: null, signal: 'SIGKILL' }
   })
-  cleanupAfterRunner(files, direct, closed)
+  cleanupAfterRunner(files, direct, lifecycle.closed)
   return {
     stdin: child.stdin,
     stdout: child.stdout,

+ 18 - 14
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -12,9 +12,9 @@ export class DirectResultUnavailableError extends Error {
 /** Platform owner used by termination and whole-range settlement. */
 export interface BoundProcessOwner {
   /** Signal the established managed range; a confirmed-stopped owner stays inert. */
-  signal(signal: NodeJS.Signals): void
+  signal(signal: 'SIGTERM' | 'SIGKILL'): void
   /** Wait for the same managed range to become empty; reject when its owner cannot be observed. */
-  waitForExit(signal?: AbortSignal): Promise<boolean>
+  waitForExit(): Promise<void>
 }
 
 /** Platform launch facts consumed by the common stdio and result lifecycle. */
@@ -27,19 +27,23 @@ export interface ManagedProcessLaunch {
   owner: BoundProcessOwner
 }
 
-/**
- * Observe wrapper close from the moment it is spawned and contain its error
- * event while the runner-result path converts launch failures into rejection.
- * @param child - direct child or native wrapper.
- * @returns promise settled by the ChildProcess close event.
- */
-export function observeChildClose(child: ChildProcess): Promise<void> {
-  return new Promise((resolve) => {
-    child.once('error', () => {
-      // runnerDirectResult reports the wrapper failure through the handle.
-    })
-    child.once('close', () => { resolve() })
+/** Observe runner exit separately from inherited stdio closure. */
+export function observeChildLifecycle(child: ChildProcess): {
+  exited: Promise<void>
+  closed: Promise<void>
+} {
+  const exited = Promise.withResolvers<void>()
+  const closed = Promise.withResolvers<void>()
+  child.once('error', () => {
+    // runnerDirectResult reports the wrapper failure through the handle.
+    exited.resolve()
+  })
+  child.once('exit', () => { exited.resolve() })
+  child.once('close', () => {
+    exited.resolve()
+    closed.resolve()
   })
+  return { exited: exited.promise, closed: closed.promise }
 }
 
 /**

+ 14 - 13
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -17,7 +17,7 @@ import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.
 import { DirectResultUnavailableError } from './managed-owner.ts'
 import { childEnv } from './spawn.ts'
 
-let handshakeWait: Int32Array | undefined
+const handshakeWait = new Int32Array(new SharedArrayBuffer(4))
 const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000
 const RUNNER_EVENT_POLL_MS = 100
 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner'
@@ -26,7 +26,9 @@ const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner'
  * Resolve the runner entry from the current module's source or built plane.
  * @returns Node executable and runner argv prefix.
  */
-export function spawnRunnerInvocation(): string[] {
+export type RunnerInvocation = [string, ...string[]]
+
+export function spawnRunnerInvocation(): RunnerInvocation {
   if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG]
   /* v8 ignore start -- source-plane coverage cannot execute the bundled module;
      the required built-runner smoke executes its published entry. */
@@ -106,7 +108,6 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner
     }
     if (child.pid === undefined) throw new Error('native subprocess runner failed to start')
     if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start')
-    handshakeWait ??= new Int32Array(new SharedArrayBuffer(4))
     Atomics.wait(handshakeWait, 0, 0, 5)
   }
   throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`)
@@ -115,23 +116,23 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner
 async function waitForDirectResult(
   files: RunnerFiles,
   initial: RunnerEvent[],
-  closed: Promise<void>,
+  exited: Promise<void>,
 ): Promise<SubprocessOutcome> {
   let seen = 0
-  const wrapperState = { closed: false }
-  void closed.then(() => { wrapperState.closed = true })
+  const wrapperState = { exited: false }
+  void exited.then(() => { wrapperState.exited = true })
   for (;;) {
-    // A read started before close may return a stale snapshot after close has
-    // become visible. Only a read started after close can prove no terminal
+    // A read started before exit may return a stale snapshot after exit has
+    // become visible. Only a read started after exit can prove no terminal
     // event was written before the runner exited.
-    const closedBeforeRead = wrapperState.closed
+    const exitedBeforeRead = wrapperState.exited
     const events = await readRunnerEventsAsync(files.eventsPath)
     for (const event of events.slice(seen)) {
       if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal }
       if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error)
     }
     seen = Math.max(seen, events.length, initial.length)
-    if (closedBeforeRead) {
+    if (exitedBeforeRead) {
       throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result')
     }
     await sleepMs(RUNNER_EVENT_POLL_MS)
@@ -142,13 +143,13 @@ async function waitForDirectResult(
  * Bind runner events into one direct result while preserving the target pid.
  * @param child - native wrapper process.
  * @param files - private request and result paths.
- * @param closed - wrapper close observation attached before the start handshake.
+ * @param exited - wrapper exit/error observation attached before the start handshake.
  * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure.
  */
 export function runnerDirectResult(
   child: ChildProcess,
   files: RunnerFiles,
-  closed: Promise<void>,
+  exited: Promise<void>,
 ): {
   pid: number
   direct: Promise<SubprocessOutcome>
@@ -163,7 +164,7 @@ export function runnerDirectResult(
   }
   return {
     pid: handshake.pid,
-    direct: waitForDirectResult(files, handshake.events, closed),
+    direct: waitForDirectResult(files, handshake.events, exited),
     failureReported: handshake.failureReported,
   }
 }

+ 23 - 8
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -9,6 +9,7 @@ import {
   pollProcessExit,
   spawnOrdinaryJobProcess,
   terminateJob,
+  waitForProcessExit,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
 import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process'
@@ -71,7 +72,7 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw
     ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267
       ? 'ENOENT'
       : error.win32Code === 5
-        ? 'EPERM'
+        ? 'EACCES'
         : error.win32Code === 193
           ? 'EFTYPE'
           : 'UNKNOWN'
@@ -148,8 +149,6 @@ async function runWin32(
   const api = loadWin32ProcessBindings()
   let processHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
-  let targetStarted = false
-  let targetCreationAttempted = false
   const openedStdio: Array<{ handle: NativePtr; label: string }> = []
   try {
     const stdio: ChildStdioHandles = {}
@@ -163,7 +162,6 @@ async function runWin32(
       stdio[key] = handle
       openedStdio.push({ handle, label: `ordinary target ${key} pipe` })
     }
-    targetCreationAttempted = true
     // Match Node's cwd-relative executable lookup and spawn-error attribution.
     const runnerCwd = process.cwd()
     process.chdir(request.cwd)
@@ -178,12 +176,11 @@ async function runWin32(
       processHandle = spawned.process
       jobHandle = spawned.job
       targetPid = spawned.pid
-      targetStarted = true
     } finally {
       process.chdir(runnerCwd)
     }
-    closeStdioHandles(api, openedStdio, true)
     appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid })
+    closeStdioHandles(api, openedStdio, true)
 
     await new Promise<void>((resolve, reject) => {
       let settled = false
@@ -235,7 +232,8 @@ async function runWin32(
       }, 10)
     })
   } catch (error) {
-    const targetSpawnFailed = targetCreationAttempted && !targetStarted
+    const targetSpawnFailed = (error instanceof Win32Error && error.api === 'CreateProcessW')
+      || (error instanceof Error && (error as NodeJS.ErrnoException).syscall === 'chdir')
     appendRunnerEvent(eventsPath, {
       type: targetSpawnFailed ? 'spawn-error' : 'runner-error',
       error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error),
@@ -252,11 +250,28 @@ async function runWin32(
   }
 }
 
+function probeWin32Job(): void {
+  const command = process.env.ComSpec ?? process.env.COMSPEC
+  if (command === undefined) throw new Error('subprocess runner cannot probe a Windows Job without ComSpec')
+  const api = loadWin32ProcessBindings()
+  const spawned = spawnOrdinaryJobProcess(api, {
+    command,
+    args: ['/d', '/s', '/c', 'exit 0'],
+    cwd: process.cwd(),
+  })
+  try {
+    const exitCode = waitForProcessExit(api, spawned.process)
+    if (exitCode !== 0) throw new Error(`subprocess Windows Job probe exited with code ${String(exitCode)}`)
+  } finally {
+    closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe')
+  }
+}
+
 async function main(): Promise<void> {
   const args = parseArgs(process.argv.slice(2))
   if (args.mode === 'probe-node') return
   if (args.mode === 'probe-win32') {
-    loadWin32ProcessBindings()
+    probeWin32Job()
     return
   }
   const request = consumeRunnerRequest(args.requestPath)

+ 11 - 12
packages/subprocess/subprocess-local/src/spawn.ts

@@ -406,15 +406,15 @@ function fallbackOwner(
       }
       signalTree(platform, pid, signal, child, taskkill)
     },
-    waitForExit: async (signal) => {
+    waitForExit: async () => {
       /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only
          protects direct internal re-entry after signal() observed absence. */
-      if (stopped) return true
+      if (stopped) return
       observation ??= (async () => {
         while (alive()) await sleepTick()
         stopped = true
       })()
-      return waitWithAbort(observation, signal)
+      await observation
     },
   }
 }
@@ -448,8 +448,8 @@ export function bindManagedProcess(
   }
   const stdoutCollector = collectStream(outMode, stdout, 'stdout')
   const stderrCollector = collectStream(errMode, stderr, 'stderr')
-  const observeCollectedStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise<void> => {
-    if (!isCollect(mode) || stream === null || stream.readableEnded || stream.destroyed) return Promise.resolve()
+  const observeOutputStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise<void> | undefined => {
+    if (mode === 'inherit' || stream === null || stream.readableEnded || stream.destroyed) return undefined
     return new Promise((resolve) => {
       const settle = (): void => {
         stream.off('end', settle)
@@ -462,10 +462,9 @@ export function bindManagedProcess(
       stream.once('error', settle)
     })
   }
-  const collectedStreamsClosed = Promise.all([
-    observeCollectedStream(outMode, stdout),
-    observeCollectedStream(errMode, stderr),
-  ])
+  const stdoutClosed = observeOutputStream(outMode, stdout)
+  const stderrClosed = observeOutputStream(errMode, stderr)
+  const outputStreamsClosed = Promise.all([stdoutClosed, stderrClosed])
   const stopCollectors = (): void => {
     if (stdoutCollector !== undefined) stdout?.destroy()
     if (stderrCollector !== undefined) stderr?.destroy()
@@ -494,7 +493,7 @@ export function bindManagedProcess(
     return rangeExitObservation
   }
 
-  const kill = (sig: NodeJS.Signals): void => {
+  const kill = (sig: 'SIGTERM' | 'SIGKILL'): void => {
     if (rangeExitObserved) return
     launch.owner.signal(sig)
   }
@@ -535,12 +534,12 @@ export function bindManagedProcess(
       resolve(outcome)
     }
     launch.direct.then((outcome) => {
-      if (stdoutCollector === undefined && stderrCollector === undefined) {
+      if (stdoutClosed === undefined && stderrClosed === undefined) {
         settle(outcome)
         return
       }
       pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs)
-      void collectedStreamsClosed.then(() => { settle(outcome) })
+      void outputStreamsClosed.then(() => { settle(outcome) })
     }, (error: unknown) => {
       /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */
       if (settled) return

+ 10 - 10
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -4,10 +4,11 @@ import { spawn, spawnSync } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
-import { observeChildClose, waitWithAbort } from './managed-owner.ts'
+import { observeChildLifecycle } from './managed-owner.ts'
 import { childEnv } from './spawn.ts'
 import {
   cleanupAfterRunner,
+  type RunnerInvocation,
   runnerDirectResult,
   runnerFiles,
   spawnRunnerInvocation,
@@ -19,7 +20,7 @@ import { createWindowsStdioBridge } from './windows-stdio.ts'
 export interface WindowsJobInternals {
   spawn?: typeof spawn
   spawnSync?: typeof spawnSync
-  runnerInvocation?: string[]
+  runnerInvocation?: RunnerInvocation
 }
 
 /**
@@ -30,7 +31,6 @@ export interface WindowsJobInternals {
 export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean {
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
-  if (command === undefined) return false
   const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], {
     env: childEnv(),
     stdio: 'ignore',
@@ -69,7 +69,7 @@ class WindowsJobOwner implements BoundProcessOwner {
     void this.observation.catch(() => {})
   }
 
-  signal(_signal: NodeJS.Signals): void {
+  signal(_signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped || this.runnerClosed || this.startupFailureReported || this.runner.pid === undefined) return
     try {
       if (this.runner.connected) {
@@ -84,8 +84,9 @@ class WindowsJobOwner implements BoundProcessOwner {
     }
   }
 
-  waitForExit(signal?: AbortSignal): Promise<boolean> {
-    return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal)
+  async waitForExit(): Promise<void> {
+    if (this.stopped) return
+    await this.observation
   }
 }
 
@@ -102,7 +103,6 @@ export function launchWindowsJob(
   const run = internals.spawn ?? spawn
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
-  if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty')
   const files = runnerFiles(spec)
   let stdio: ReturnType<typeof createWindowsStdioBridge>
   try {
@@ -134,14 +134,14 @@ export function launchWindowsJob(
     cleanupRunnerFiles(files)
     throw error
   }
-  const runnerClosed = observeChildClose(child)
-  const result = runnerDirectResult(child, files, runnerClosed)
+  const lifecycle = observeChildLifecycle(child)
+  const result = runnerDirectResult(child, files, lifecycle.exited)
   const owner = new WindowsJobOwner(child, result.failureReported)
   void result.direct.then(
     () => { stdio.closeInput() },
     () => { stdio.dispose() },
   )
-  cleanupAfterRunner(files, result.direct, runnerClosed)
+  cleanupAfterRunner(files, result.direct, lifecycle.closed)
   return {
     stdin: stdio.stdin,
     stdout: stdio.stdout,

+ 9 - 12
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -38,7 +38,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       environments.push(options?.env)
       return { status: 0, error: undefined }
     }) as unknown as typeof spawnSync
-    const runnerInvocation = ['node-runtime', 'runner-entry.js']
+    const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js']
     try {
       expect(probeLinuxScope({
         spawnSync: runSync,
@@ -72,9 +72,6 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
     })).toBe(false)
 
-    const emptyInvocation = vi.fn() as unknown as typeof spawnSync
-    expect(probeLinuxScope({ spawnSync: emptyInvocation, runnerInvocation: [] })).toBe(false)
-    expect(emptyInvocation).not.toHaveBeenCalled()
   })
 
   it('keeps user argv out of systemd-run and reports the direct target outcome', async () => {
@@ -102,8 +99,8 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       runnerInvocation: spawnRunnerInvocation(),
     })
     await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null })
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
     const callsBeforeStaleSignal = runSyncMock.mock.calls.length
     launch.owner.signal('SIGKILL')
     expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
@@ -149,7 +146,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     launch.owner.signal('SIGTERM')
     launch.owner.signal('SIGKILL')
     await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it('rejects wait when the selected native owner becomes unreadable', async () => {
@@ -232,7 +229,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
         systemctlQuery: asyncQuery(runSync),
         runnerInvocation: spawnRunnerInvocation(),
       })
-      await expect(launch.owner.waitForExit()).resolves.toBe(true)
+      await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
     },
   )
@@ -255,7 +252,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       systemctlQuery: asyncQuery(runSync),
       runnerInvocation: spawnRunnerInvocation(),
     })
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
     await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
     expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
   })
@@ -272,7 +269,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     })
     expect(launch.pid).toBe(-1)
     await expect(launch.direct).rejects.toThrow('runner failed to start')
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it('does not fabricate a direct outcome after a non-forced scope signal', async () => {
@@ -300,7 +297,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     })
     launch.owner.signal('SIGTERM')
     await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it.each([
@@ -396,7 +393,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       expect(defaults.probeLinuxScope()).toBe(true)
       const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
-      await expect(launch.owner.waitForExit()).resolves.toBe(true)
+      await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
       expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
       expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
       expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function))

+ 21 - 19
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -50,16 +50,9 @@ describe('managed process binding', () => {
           stopped.resolve(undefined)
         }
       },
-      async waitForExit(signal) {
-        if (ownerStopped) return true
-        if (signal?.aborted) return false
-        if (signal === undefined) {
-          await stopped.promise
-          return true
-        }
-        const aborted = Promise.withResolvers<boolean>()
-        signal.addEventListener('abort', () => { aborted.resolve(false) }, { once: true })
-        return Promise.race([stopped.promise.then(() => true), aborted.promise])
+      async waitForExit() {
+        if (ownerStopped) return
+        await stopped.promise
       },
     }
     const handle = bindManagedProcess(spec(), {
@@ -93,28 +86,37 @@ describe('managed process binding', () => {
       stderr: wrapper.stderr,
       pid: 4242,
       direct: Promise.resolve({ exitCode: 0, signal: null }),
-      owner: { signal, waitForExit: async () => true },
+      owner: { signal, waitForExit: async () => {} },
     })
     handle.terminateForHostExit()
     expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
   })
 
-  it('settles when collected streams close before the direct outcome arrives', async () => {
+  it('waits for raw and collected output streams after the direct outcome', async () => {
     const stdout = new PassThrough()
     const stderr = new PassThrough()
     const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
-    const handle = bindManagedProcess(spec(), {
+    const request = {
+      ...spec(),
+      stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const,
+    }
+    const handle = bindManagedProcess(request, {
       stdin: null,
       stdout,
       stderr,
       pid: 4242,
       direct: direct.promise,
-      owner: { signal: vi.fn(), waitForExit: async () => true },
+      owner: { signal: vi.fn(), waitForExit: async () => {} },
     })
+    let doneSettled = false
+    void handle.done.then(() => { doneSettled = true })
+    direct.resolve({ exitCode: 23, signal: null })
+    await Promise.resolve()
+    expect(doneSettled).toBe(false)
     stdout.end()
+    await Promise.resolve()
+    expect(doneSettled).toBe(false)
     stderr.end()
-    await new Promise(resolve => setImmediate(resolve))
-    direct.resolve({ exitCode: 23, signal: null })
     await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null })
   })
 
@@ -132,7 +134,7 @@ describe('managed process binding', () => {
       stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct: direct.promise,
-      owner: { signal: vi.fn(), waitForExit: async () => true },
+      owner: { signal: vi.fn(), waitForExit: async () => {} },
     })
     try {
       direct.resolve({ exitCode: 23, signal: null })
@@ -181,7 +183,7 @@ describe('managed process binding', () => {
       stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       direct,
-      owner: { signal, waitForExit: async () => true },
+      owner: { signal, waitForExit: async () => {} },
     })
     try {
       await expect(handle.done).rejects.toThrow('runner failed')
@@ -213,7 +215,7 @@ describe('managed process binding', () => {
       direct: direct.promise,
       owner: {
         signal,
-        waitForExit: async () => { await stopped.promise; return true },
+        waitForExit: async () => { await stopped.promise },
       },
     })
     direct.resolve({ exitCode: 0, signal: null })

+ 7 - 0
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -166,6 +166,13 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' })
     await expect(missingHandle.waitForExit()).resolves.toBe(true)
 
+    const accessDenied = spec([scratch])
+    const expectedAccessDenied = await directSpawnFailure([scratch])
+    const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied))
+    await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: 'EACCES' })
+    await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: expectedAccessDenied.code })
+    await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true)
+
     const missingCwd = join(scratch, `missing-cwd-${Date.now()}`)
     const cwdArgv = [process.execPath, '-e', 'process.exit(0)']
     const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd)

+ 26 - 22
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -1,5 +1,6 @@
 import { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
+import { EventEmitter } from 'node:events'
 import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -13,7 +14,7 @@ import {
   runnerStdio,
   spawnRunnerInvocation,
 } from '../src/runner-launch.ts'
-import { observeChildClose } from '../src/managed-owner.ts'
+import { observeChildLifecycle } from '../src/managed-owner.ts'
 import {
   appendRunnerEvent,
   cleanupRunnerFiles,
@@ -63,20 +64,6 @@ describe('spawn runner transport', () => {
     expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
   })
 
-  it('does not require SharedArrayBuffer until a native handshake runs', async () => {
-    const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer')
-    Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined })
-    vi.resetModules()
-    try {
-      const isolated = await import('../src/runner-launch.ts')
-      expect(isolated.runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe'])
-    } finally {
-      if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer')
-      else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor)
-      vi.resetModules()
-    }
-  })
-
   it('re-enters a packaged executable through its private runner dispatch', () => {
     const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
     const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
@@ -333,7 +320,7 @@ describe('spawn runner transport', () => {
 
   })
 
-  it('requires an event snapshot started after wrapper close before reporting a missing result', async () => {
+  it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => {
     const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
     let readCount = 0
     vi.resetModules()
@@ -351,12 +338,12 @@ describe('spawn runner transport', () => {
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
       appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
-      const closed = Promise.withResolvers<undefined>()
+      const exited = Promise.withResolvers<undefined>()
       const isolated = await import('../src/runner-launch.ts')
-      const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise)
+      const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise)
       expect(result.failureReported).toBe(false)
       expect(readCount).toBe(1)
-      closed.resolve(undefined)
+      exited.resolve(undefined)
       await Promise.resolve()
       appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
       staleRead.resolve([{ type: 'started', pid: 456 }])
@@ -369,18 +356,35 @@ describe('spawn runner transport', () => {
     }
   })
 
+  it('reports a missing direct result at runner exit without waiting for pipe close', async () => {
+    const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    try {
+      appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
+      const child = new EventEmitter() as ChildProcess
+      Object.assign(child, { pid: 123, exitCode: null, signalCode: null })
+      const lifecycle = observeChildLifecycle(child)
+      const result = runnerDirectResult(child, files, lifecycle.exited)
+      child.emit('exit', 1, null)
+      await expect(result.direct).rejects.toThrow('exited without a direct-command result')
+      child.emit('close', 1, null)
+      await lifecycle.closed
+    } finally {
+      cleanupRunnerFiles(files)
+    }
+  })
+
   it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
       const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
         stdio: 'ignore',
       })
-      const closed = observeChildClose(child)
-      const result = runnerDirectResult(child, files, closed)
+      const lifecycle = observeChildLifecycle(child)
+      const result = runnerDirectResult(child, files, lifecycle.exited)
       expect(result.pid).toBe(-1)
       expect(result.failureReported).toBe(false)
       await expect(result.direct).rejects.toThrow('runner failed to start')
-      await expect(closed).resolves.toBeUndefined()
+      await expect(lifecycle.closed).resolves.toBeUndefined()
     } finally {
       cleanupRunnerFiles(files)
     }

+ 9 - 24
packages/subprocess/subprocess-local/tests/windows-job.spec.ts

@@ -6,12 +6,11 @@ import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { appendRunnerEvent } from '../src/runner-protocol.ts'
-import { bindManagedProcess } from '../src/spawn.ts'
 import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts'
 import type { WindowsStdioBridge } from '../src/windows-stdio.ts'
 
 const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url))
-const invocation = [process.execPath, '--import', 'tsx/esm', fixture]
+const invocation: [string, ...string[]] = [process.execPath, '--import', 'tsx/esm', fixture]
 
 function spec(argv: string[]): SubprocessSpawnSpec {
   return {
@@ -31,7 +30,6 @@ describe('Windows Job runner adapter', () => {
       [...invocation.slice(1), '--mode', 'probe-win32'],
       expect.objectContaining({ stdio: 'ignore' }),
     )
-    expect(probeWindowsJob({ runnerInvocation: [] })).toBe(false)
     expect(probeWindowsJob({
       spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
       runnerInvocation: invocation,
@@ -49,7 +47,7 @@ describe('Windows Job runner adapter', () => {
     })
     expect(launch.pid).toBeGreaterThan(0)
     await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null })
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it('signals the Job runner and waits for its managed range to stop', async () => {
@@ -59,7 +57,7 @@ describe('Windows Job runner adapter', () => {
     })
     launch.owner.signal('SIGTERM')
     await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null })
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
     launch.owner.signal('SIGKILL')
   })
 
@@ -110,7 +108,7 @@ describe('Windows Job runner adapter', () => {
     expect(send).not.toHaveBeenCalled()
     expect(kill).not.toHaveBeenCalled()
     child.emit('close', 0, null)
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it('treats a wrapper that never started as an empty managed range', async () => {
@@ -124,7 +122,7 @@ describe('Windows Job runner adapter', () => {
     launch.owner.signal('SIGTERM')
     expect(kill).not.toHaveBeenCalled()
     child.emit('close', -2, null)
-    await expect(launch.owner.waitForExit()).resolves.toBe(true)
+    await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
   })
 
   it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => {
@@ -190,10 +188,7 @@ describe('Windows Job runner adapter', () => {
     await launch.owner.waitForExit()
   })
 
-  it('uses production runner defaults and rejects an empty invocation', async () => {
-    expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] }))
-      .toThrow('Windows runner invocation is empty')
-
+  it('uses production runner defaults', async () => {
     const child = new EventEmitter() as ChildProcess
     Object.assign(child, {
       pid: 987,
@@ -221,7 +216,7 @@ describe('Windows Job runner adapter', () => {
       appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
       child.emit('close', 0, null)
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
-      await expect(launch.owner.waitForExit()).resolves.toBe(true)
+      await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
       expect(run).toHaveBeenCalledOnce()
       expect(runSync).toHaveBeenCalledOnce()
     } finally {
@@ -234,7 +229,6 @@ describe('Windows Job runner adapter', () => {
     const bridgeFailure = new Error('bridge failed')
     const spawnFailure = new Error('spawn threw')
     const bridges: Array<WindowsStdioBridge & { dispose: ReturnType<typeof vi.fn>; closeInput: ReturnType<typeof vi.fn> }> = []
-    let collectedStreams: { stdout: PassThrough; stderr: PassThrough } | undefined
     vi.resetModules()
     vi.doMock('../src/windows-stdio.ts', async importOriginal => ({
       ...await importOriginal<typeof import('../src/windows-stdio.ts')>(),
@@ -242,7 +236,6 @@ describe('Windows Job runner adapter', () => {
         if (request.argv[0] === 'bridge-failure') throw bridgeFailure
         const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null
         const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null
-        if (stdout !== null && stderr !== null) collectedStreams = { stdout, stderr }
         const bridge = {
           stdin: null,
           stdout,
@@ -288,16 +281,8 @@ describe('Windows Job runner adapter', () => {
         },
       } satisfies SubprocessSpawnSpec
       const launch = isolated.launchWindowsJob(request, { spawn: run, runnerInvocation: ['fake-runner'] })
-      const handle = bindManagedProcess(request, launch)
-      let doneSettled = false
-      void handle.done.then(() => { doneSettled = true })
-      await new Promise(resolve => setImmediate(resolve))
-      expect(doneSettled).toBe(false)
-      collectedStreams?.stdout.emit('end')
-      await Promise.resolve()
-      expect(doneSettled).toBe(false)
-      collectedStreams?.stderr.emit('error', new Error('stream closed'))
-      await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
+      await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
+      await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
       expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce()
     } finally {
       vi.doUnmock('../src/windows-stdio.ts')