Browse Source

fix(subprocess): close native lifecycle gaps

pku-xht 1 month ago
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;
 # 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:
 # 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
 #   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.
 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.
 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。
 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 重放。
 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 { setTimeout as sleepMs } from 'node:timers/promises'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
 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 { childEnv } from './spawn.ts'
 import {
 import {
   cleanupAfterRunner,
   cleanupAfterRunner,
+  type RunnerInvocation,
   runnerDirectResult,
   runnerDirectResult,
   runnerFiles,
   runnerFiles,
   runnerStdio,
   runnerStdio,
@@ -23,7 +24,7 @@ export interface LinuxScopeInternals {
   systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
   systemctlQuery?: (command: string, args: readonly string[]) => Promise<SystemctlResult>
   systemdRun?: string
   systemdRun?: string
   systemctl?: string
   systemctl?: string
-  runnerInvocation?: string[]
+  runnerInvocation?: RunnerInvocation
 }
 }
 
 
 interface SystemctlResult {
 interface SystemctlResult {
@@ -72,7 +73,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
   const runSync = internals.spawnSync ?? spawnSync
   const runSync = internals.spawnSync ?? spawnSync
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [runnerCommand, ...runnerPrefix] = invocation
   const [runnerCommand, ...runnerPrefix] = invocation
-  if (runnerCommand === undefined) return false
   const systemdRun = internals.systemdRun ?? 'systemd-run'
   const systemdRun = internals.systemdRun ?? 'systemd-run'
   const systemctl = internals.systemctl ?? 'systemctl'
   const systemctl = internals.systemctl ?? 'systemctl'
   const timeout = 5_000
   const timeout = 5_000
@@ -117,7 +117,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     private readonly onForceKillAttempt: () => void,
     private readonly onForceKillAttempt: () => void,
   ) {}
   ) {}
 
 
-  signal(signal: NodeJS.Signals): void {
+  signal(signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped) return
     if (this.stopped) return
     const result = this.runSync(this.systemctl, [
     const result = this.runSync(this.systemctl, [
       '--user',
       '--user',
@@ -165,13 +165,13 @@ class SystemdScopeOwner implements BoundProcessOwner {
     return true
     return true
   }
   }
 
 
-  async waitForExit(signal?: AbortSignal): Promise<boolean> {
-    if (this.stopped) return true
+  async waitForExit(): Promise<void> {
+    if (this.stopped) return
     this.observation ??= (async () => {
     this.observation ??= (async () => {
       while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
       while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS)
       this.stopped = true
       this.stopped = true
     })()
     })()
-    return waitWithAbort(this.observation, signal)
+    await this.observation
   }
   }
 }
 }
 
 
@@ -212,7 +212,7 @@ export function launchLinuxScope(
     env: childEnv(),
     env: childEnv(),
     stdio: runnerStdio(spec),
     stdio: runnerStdio(spec),
   })
   })
-  const closed = observeChildClose(child)
+  const lifecycle = observeChildLifecycle(child)
   let forceKillAttempted = false
   let forceKillAttempted = false
   const owner = new SystemdScopeOwner(
   const owner = new SystemdScopeOwner(
     `${unitBase}.scope`,
     `${unitBase}.scope`,
@@ -222,13 +222,13 @@ export function launchLinuxScope(
     child,
     child,
     () => { forceKillAttempted = true },
     () => { 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> => {
   const direct = result.direct.catch(async (error: unknown): Promise<SubprocessOutcome> => {
     if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error
     if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error
     await owner.waitForExit()
     await owner.waitForExit()
     return { exitCode: null, signal: 'SIGKILL' }
     return { exitCode: null, signal: 'SIGKILL' }
   })
   })
-  cleanupAfterRunner(files, direct, closed)
+  cleanupAfterRunner(files, direct, lifecycle.closed)
   return {
   return {
     stdin: child.stdin,
     stdin: child.stdin,
     stdout: child.stdout,
     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. */
 /** Platform owner used by termination and whole-range settlement. */
 export interface BoundProcessOwner {
 export interface BoundProcessOwner {
   /** Signal the established managed range; a confirmed-stopped owner stays inert. */
   /** 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. */
   /** 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. */
 /** Platform launch facts consumed by the common stdio and result lifecycle. */
@@ -27,19 +27,23 @@ export interface ManagedProcessLaunch {
   owner: BoundProcessOwner
   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 { DirectResultUnavailableError } from './managed-owner.ts'
 import { childEnv } from './spawn.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_HANDSHAKE_TIMEOUT_MS = 10_000
 const RUNNER_EVENT_POLL_MS = 100
 const RUNNER_EVENT_POLL_MS = 100
 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner'
 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.
  * Resolve the runner entry from the current module's source or built plane.
  * @returns Node executable and runner argv prefix.
  * @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]
   if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG]
   /* v8 ignore start -- source-plane coverage cannot execute the bundled module;
   /* v8 ignore start -- source-plane coverage cannot execute the bundled module;
      the required built-runner smoke executes its published entry. */
      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 (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')
     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)
     Atomics.wait(handshakeWait, 0, 0, 5)
   }
   }
   throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`)
   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(
 async function waitForDirectResult(
   files: RunnerFiles,
   files: RunnerFiles,
   initial: RunnerEvent[],
   initial: RunnerEvent[],
-  closed: Promise<void>,
+  exited: Promise<void>,
 ): Promise<SubprocessOutcome> {
 ): Promise<SubprocessOutcome> {
   let seen = 0
   let seen = 0
-  const wrapperState = { closed: false }
-  void closed.then(() => { wrapperState.closed = true })
+  const wrapperState = { exited: false }
+  void exited.then(() => { wrapperState.exited = true })
   for (;;) {
   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.
     // event was written before the runner exited.
-    const closedBeforeRead = wrapperState.closed
+    const exitedBeforeRead = wrapperState.exited
     const events = await readRunnerEventsAsync(files.eventsPath)
     const events = await readRunnerEventsAsync(files.eventsPath)
     for (const event of events.slice(seen)) {
     for (const event of events.slice(seen)) {
       if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal }
       if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal }
       if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error)
       if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error)
     }
     }
     seen = Math.max(seen, events.length, initial.length)
     seen = Math.max(seen, events.length, initial.length)
-    if (closedBeforeRead) {
+    if (exitedBeforeRead) {
       throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result')
       throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result')
     }
     }
     await sleepMs(RUNNER_EVENT_POLL_MS)
     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.
  * Bind runner events into one direct result while preserving the target pid.
  * @param child - native wrapper process.
  * @param child - native wrapper process.
  * @param files - private request and result paths.
  * @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.
  * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure.
  */
  */
 export function runnerDirectResult(
 export function runnerDirectResult(
   child: ChildProcess,
   child: ChildProcess,
   files: RunnerFiles,
   files: RunnerFiles,
-  closed: Promise<void>,
+  exited: Promise<void>,
 ): {
 ): {
   pid: number
   pid: number
   direct: Promise<SubprocessOutcome>
   direct: Promise<SubprocessOutcome>
@@ -163,7 +164,7 @@ export function runnerDirectResult(
   }
   }
   return {
   return {
     pid: handshake.pid,
     pid: handshake.pid,
-    direct: waitForDirectResult(files, handshake.events, closed),
+    direct: waitForDirectResult(files, handshake.events, exited),
     failureReported: handshake.failureReported,
     failureReported: handshake.failureReported,
   }
   }
 }
 }

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

@@ -9,6 +9,7 @@ import {
   pollProcessExit,
   pollProcessExit,
   spawnOrdinaryJobProcess,
   spawnOrdinaryJobProcess,
   terminateJob,
   terminateJob,
+  waitForProcessExit,
   Win32Error,
   Win32Error,
 } from '@deepseek-ai/dsh-win32-process'
 } from '@deepseek-ai/dsh-win32-process'
 import type { ChildStdioHandles, NativePtr } 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
     ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267
       ? 'ENOENT'
       ? 'ENOENT'
       : error.win32Code === 5
       : error.win32Code === 5
-        ? 'EPERM'
+        ? 'EACCES'
         : error.win32Code === 193
         : error.win32Code === 193
           ? 'EFTYPE'
           ? 'EFTYPE'
           : 'UNKNOWN'
           : 'UNKNOWN'
@@ -148,8 +149,6 @@ async function runWin32(
   const api = loadWin32ProcessBindings()
   const api = loadWin32ProcessBindings()
   let processHandle: NativePtr | undefined
   let processHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
   let jobHandle: NativePtr | undefined
-  let targetStarted = false
-  let targetCreationAttempted = false
   const openedStdio: Array<{ handle: NativePtr; label: string }> = []
   const openedStdio: Array<{ handle: NativePtr; label: string }> = []
   try {
   try {
     const stdio: ChildStdioHandles = {}
     const stdio: ChildStdioHandles = {}
@@ -163,7 +162,6 @@ async function runWin32(
       stdio[key] = handle
       stdio[key] = handle
       openedStdio.push({ handle, label: `ordinary target ${key} pipe` })
       openedStdio.push({ handle, label: `ordinary target ${key} pipe` })
     }
     }
-    targetCreationAttempted = true
     // Match Node's cwd-relative executable lookup and spawn-error attribution.
     // Match Node's cwd-relative executable lookup and spawn-error attribution.
     const runnerCwd = process.cwd()
     const runnerCwd = process.cwd()
     process.chdir(request.cwd)
     process.chdir(request.cwd)
@@ -178,12 +176,11 @@ async function runWin32(
       processHandle = spawned.process
       processHandle = spawned.process
       jobHandle = spawned.job
       jobHandle = spawned.job
       targetPid = spawned.pid
       targetPid = spawned.pid
-      targetStarted = true
     } finally {
     } finally {
       process.chdir(runnerCwd)
       process.chdir(runnerCwd)
     }
     }
-    closeStdioHandles(api, openedStdio, true)
     appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid })
     appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid })
+    closeStdioHandles(api, openedStdio, true)
 
 
     await new Promise<void>((resolve, reject) => {
     await new Promise<void>((resolve, reject) => {
       let settled = false
       let settled = false
@@ -235,7 +232,8 @@ async function runWin32(
       }, 10)
       }, 10)
     })
     })
   } catch (error) {
   } 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, {
     appendRunnerEvent(eventsPath, {
       type: targetSpawnFailed ? 'spawn-error' : 'runner-error',
       type: targetSpawnFailed ? 'spawn-error' : 'runner-error',
       error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(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> {
 async function main(): Promise<void> {
   const args = parseArgs(process.argv.slice(2))
   const args = parseArgs(process.argv.slice(2))
   if (args.mode === 'probe-node') return
   if (args.mode === 'probe-node') return
   if (args.mode === 'probe-win32') {
   if (args.mode === 'probe-win32') {
-    loadWin32ProcessBindings()
+    probeWin32Job()
     return
     return
   }
   }
   const request = consumeRunnerRequest(args.requestPath)
   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)
       signalTree(platform, pid, signal, child, taskkill)
     },
     },
-    waitForExit: async (signal) => {
+    waitForExit: async () => {
       /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only
       /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only
          protects direct internal re-entry after signal() observed absence. */
          protects direct internal re-entry after signal() observed absence. */
-      if (stopped) return true
+      if (stopped) return
       observation ??= (async () => {
       observation ??= (async () => {
         while (alive()) await sleepTick()
         while (alive()) await sleepTick()
         stopped = true
         stopped = true
       })()
       })()
-      return waitWithAbort(observation, signal)
+      await observation
     },
     },
   }
   }
 }
 }
@@ -448,8 +448,8 @@ export function bindManagedProcess(
   }
   }
   const stdoutCollector = collectStream(outMode, stdout, 'stdout')
   const stdoutCollector = collectStream(outMode, stdout, 'stdout')
   const stderrCollector = collectStream(errMode, stderr, 'stderr')
   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) => {
     return new Promise((resolve) => {
       const settle = (): void => {
       const settle = (): void => {
         stream.off('end', settle)
         stream.off('end', settle)
@@ -462,10 +462,9 @@ export function bindManagedProcess(
       stream.once('error', settle)
       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 => {
   const stopCollectors = (): void => {
     if (stdoutCollector !== undefined) stdout?.destroy()
     if (stdoutCollector !== undefined) stdout?.destroy()
     if (stderrCollector !== undefined) stderr?.destroy()
     if (stderrCollector !== undefined) stderr?.destroy()
@@ -494,7 +493,7 @@ export function bindManagedProcess(
     return rangeExitObservation
     return rangeExitObservation
   }
   }
 
 
-  const kill = (sig: NodeJS.Signals): void => {
+  const kill = (sig: 'SIGTERM' | 'SIGKILL'): void => {
     if (rangeExitObserved) return
     if (rangeExitObserved) return
     launch.owner.signal(sig)
     launch.owner.signal(sig)
   }
   }
@@ -535,12 +534,12 @@ export function bindManagedProcess(
       resolve(outcome)
       resolve(outcome)
     }
     }
     launch.direct.then((outcome) => {
     launch.direct.then((outcome) => {
-      if (stdoutCollector === undefined && stderrCollector === undefined) {
+      if (stdoutClosed === undefined && stderrClosed === undefined) {
         settle(outcome)
         settle(outcome)
         return
         return
       }
       }
       pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs)
       pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs)
-      void collectedStreamsClosed.then(() => { settle(outcome) })
+      void outputStreamsClosed.then(() => { settle(outcome) })
     }, (error: unknown) => {
     }, (error: unknown) => {
       /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */
       /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */
       if (settled) return
       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 { randomUUID } from 'node:crypto'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts'
 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 { childEnv } from './spawn.ts'
 import {
 import {
   cleanupAfterRunner,
   cleanupAfterRunner,
+  type RunnerInvocation,
   runnerDirectResult,
   runnerDirectResult,
   runnerFiles,
   runnerFiles,
   spawnRunnerInvocation,
   spawnRunnerInvocation,
@@ -19,7 +20,7 @@ import { createWindowsStdioBridge } from './windows-stdio.ts'
 export interface WindowsJobInternals {
 export interface WindowsJobInternals {
   spawn?: typeof spawn
   spawn?: typeof spawn
   spawnSync?: typeof spawnSync
   spawnSync?: typeof spawnSync
-  runnerInvocation?: string[]
+  runnerInvocation?: RunnerInvocation
 }
 }
 
 
 /**
 /**
@@ -30,7 +31,6 @@ export interface WindowsJobInternals {
 export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean {
 export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean {
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
   const [command, ...prefix] = invocation
-  if (command === undefined) return false
   const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], {
   const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], {
     env: childEnv(),
     env: childEnv(),
     stdio: 'ignore',
     stdio: 'ignore',
@@ -69,7 +69,7 @@ class WindowsJobOwner implements BoundProcessOwner {
     void this.observation.catch(() => {})
     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
     if (this.stopped || this.runnerClosed || this.startupFailureReported || this.runner.pid === undefined) return
     try {
     try {
       if (this.runner.connected) {
       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 run = internals.spawn ?? spawn
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
   const [command, ...prefix] = invocation
-  if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty')
   const files = runnerFiles(spec)
   const files = runnerFiles(spec)
   let stdio: ReturnType<typeof createWindowsStdioBridge>
   let stdio: ReturnType<typeof createWindowsStdioBridge>
   try {
   try {
@@ -134,14 +134,14 @@ export function launchWindowsJob(
     cleanupRunnerFiles(files)
     cleanupRunnerFiles(files)
     throw error
     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)
   const owner = new WindowsJobOwner(child, result.failureReported)
   void result.direct.then(
   void result.direct.then(
     () => { stdio.closeInput() },
     () => { stdio.closeInput() },
     () => { stdio.dispose() },
     () => { stdio.dispose() },
   )
   )
-  cleanupAfterRunner(files, result.direct, runnerClosed)
+  cleanupAfterRunner(files, result.direct, lifecycle.closed)
   return {
   return {
     stdin: stdio.stdin,
     stdin: stdio.stdin,
     stdout: stdio.stdout,
     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)
       environments.push(options?.env)
       return { status: 0, error: undefined }
       return { status: 0, error: undefined }
     }) as unknown as typeof spawnSync
     }) as unknown as typeof spawnSync
-    const runnerInvocation = ['node-runtime', 'runner-entry.js']
+    const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js']
     try {
     try {
       expect(probeLinuxScope({
       expect(probeLinuxScope({
         spawnSync: runSync,
         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,
       spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
     })).toBe(false)
     })).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 () => {
   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(),
       runnerInvocation: spawnRunnerInvocation(),
     })
     })
     await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null })
     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
     const callsBeforeStaleSignal = runSyncMock.mock.calls.length
     launch.owner.signal('SIGKILL')
     launch.owner.signal('SIGKILL')
     expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
     expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
@@ -149,7 +146,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     launch.owner.signal('SIGTERM')
     launch.owner.signal('SIGTERM')
     launch.owner.signal('SIGKILL')
     launch.owner.signal('SIGKILL')
     await expect(launch.direct).resolves.toEqual({ exitCode: null, 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 () => {
   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),
         systemctlQuery: asyncQuery(runSync),
         runnerInvocation: spawnRunnerInvocation(),
         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 })
       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),
       systemctlQuery: asyncQuery(runSync),
       runnerInvocation: spawnRunnerInvocation(),
       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 })
     await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
     expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
     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)
     expect(launch.pid).toBe(-1)
     await expect(launch.direct).rejects.toThrow('runner failed to start')
     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 () => {
   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')
     launch.owner.signal('SIGTERM')
     await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
     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([
   it.each([
@@ -396,7 +393,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       expect(defaults.probeLinuxScope()).toBe(true)
       expect(defaults.probeLinuxScope()).toBe(true)
       const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
       const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: 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).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
       expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
       expect(runSync).toHaveBeenCalledWith('systemctl', 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))
       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)
           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(), {
     const handle = bindManagedProcess(spec(), {
@@ -93,28 +86,37 @@ describe('managed process binding', () => {
       stderr: wrapper.stderr,
       stderr: wrapper.stderr,
       pid: 4242,
       pid: 4242,
       direct: Promise.resolve({ exitCode: 0, signal: null }),
       direct: Promise.resolve({ exitCode: 0, signal: null }),
-      owner: { signal, waitForExit: async () => true },
+      owner: { signal, waitForExit: async () => {} },
     })
     })
     handle.terminateForHostExit()
     handle.terminateForHostExit()
     expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL')
     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 stdout = new PassThrough()
     const stderr = new PassThrough()
     const stderr = new PassThrough()
     const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
     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,
       stdin: null,
       stdout,
       stdout,
       stderr,
       stderr,
       pid: 4242,
       pid: 4242,
       direct: direct.promise,
       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()
     stdout.end()
+    await Promise.resolve()
+    expect(doneSettled).toBe(false)
     stderr.end()
     stderr.end()
-    await new Promise(resolve => setImmediate(resolve))
-    direct.resolve({ exitCode: 23, signal: null })
     await expect(handle.done).resolves.toEqual({ 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,
       stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       pid: wrapper.pid as number,
       direct: direct.promise,
       direct: direct.promise,
-      owner: { signal: vi.fn(), waitForExit: async () => true },
+      owner: { signal: vi.fn(), waitForExit: async () => {} },
     })
     })
     try {
     try {
       direct.resolve({ exitCode: 23, signal: null })
       direct.resolve({ exitCode: 23, signal: null })
@@ -181,7 +183,7 @@ describe('managed process binding', () => {
       stderr: wrapper.stderr,
       stderr: wrapper.stderr,
       pid: wrapper.pid as number,
       pid: wrapper.pid as number,
       direct,
       direct,
-      owner: { signal, waitForExit: async () => true },
+      owner: { signal, waitForExit: async () => {} },
     })
     })
     try {
     try {
       await expect(handle.done).rejects.toThrow('runner failed')
       await expect(handle.done).rejects.toThrow('runner failed')
@@ -213,7 +215,7 @@ describe('managed process binding', () => {
       direct: direct.promise,
       direct: direct.promise,
       owner: {
       owner: {
         signal,
         signal,
-        waitForExit: async () => { await stopped.promise; return true },
+        waitForExit: async () => { await stopped.promise },
       },
       },
     })
     })
     direct.resolve({ exitCode: 0, signal: null })
     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.done).rejects.toMatchObject({ code: 'ENOENT' })
     await expect(missingHandle.waitForExit()).resolves.toBe(true)
     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 missingCwd = join(scratch, `missing-cwd-${Date.now()}`)
     const cwdArgv = [process.execPath, '-e', 'process.exit(0)']
     const cwdArgv = [process.execPath, '-e', 'process.exit(0)']
     const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd)
     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 { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } 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 { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
@@ -13,7 +14,7 @@ import {
   runnerStdio,
   runnerStdio,
   spawnRunnerInvocation,
   spawnRunnerInvocation,
 } from '../src/runner-launch.ts'
 } from '../src/runner-launch.ts'
-import { observeChildClose } from '../src/managed-owner.ts'
+import { observeChildLifecycle } from '../src/managed-owner.ts'
 import {
 import {
   appendRunnerEvent,
   appendRunnerEvent,
   cleanupRunnerFiles,
   cleanupRunnerFiles,
@@ -63,20 +64,6 @@ describe('spawn runner transport', () => {
     expect(spawnRunnerInvocation()).toEqual(sourceInvocation)
     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', () => {
   it('re-enters a packaged executable through its private runner dispatch', () => {
     const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
     const packagedProcess = process as NodeJS.Process & { pkg?: unknown }
     const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg')
     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>>>()
     const staleRead = Promise.withResolvers<Awaited<ReturnType<typeof readRunnerEventsAsync>>>()
     let readCount = 0
     let readCount = 0
     vi.resetModules()
     vi.resetModules()
@@ -351,12 +338,12 @@ describe('spawn runner transport', () => {
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
     try {
       appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 })
       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 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(result.failureReported).toBe(false)
       expect(readCount).toBe(1)
       expect(readCount).toBe(1)
-      closed.resolve(undefined)
+      exited.resolve(undefined)
       await Promise.resolve()
       await Promise.resolve()
       appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
       appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null })
       staleRead.resolve([{ type: 'started', pid: 456 }])
       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 () => {
   it('contains wrapper spawn errors while publishing the runner startup rejection', async () => {
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
     try {
     try {
       const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
       const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], {
         stdio: 'ignore',
         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.pid).toBe(-1)
       expect(result.failureReported).toBe(false)
       expect(result.failureReported).toBe(false)
       await expect(result.direct).rejects.toThrow('runner failed to start')
       await expect(result.direct).rejects.toThrow('runner failed to start')
-      await expect(closed).resolves.toBeUndefined()
+      await expect(lifecycle.closed).resolves.toBeUndefined()
     } finally {
     } finally {
       cleanupRunnerFiles(files)
       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 { describe, expect, it, vi } from 'vitest'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { appendRunnerEvent } from '../src/runner-protocol.ts'
 import { appendRunnerEvent } from '../src/runner-protocol.ts'
-import { bindManagedProcess } from '../src/spawn.ts'
 import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts'
 import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts'
 import type { WindowsStdioBridge } from '../src/windows-stdio.ts'
 import type { WindowsStdioBridge } from '../src/windows-stdio.ts'
 
 
 const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url))
 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 {
 function spec(argv: string[]): SubprocessSpawnSpec {
   return {
   return {
@@ -31,7 +30,6 @@ describe('Windows Job runner adapter', () => {
       [...invocation.slice(1), '--mode', 'probe-win32'],
       [...invocation.slice(1), '--mode', 'probe-win32'],
       expect.objectContaining({ stdio: 'ignore' }),
       expect.objectContaining({ stdio: 'ignore' }),
     )
     )
-    expect(probeWindowsJob({ runnerInvocation: [] })).toBe(false)
     expect(probeWindowsJob({
     expect(probeWindowsJob({
       spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
       spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
       runnerInvocation: invocation,
       runnerInvocation: invocation,
@@ -49,7 +47,7 @@ describe('Windows Job runner adapter', () => {
     })
     })
     expect(launch.pid).toBeGreaterThan(0)
     expect(launch.pid).toBeGreaterThan(0)
     await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null })
     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 () => {
   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')
     launch.owner.signal('SIGTERM')
     await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null })
     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')
     launch.owner.signal('SIGKILL')
   })
   })
 
 
@@ -110,7 +108,7 @@ describe('Windows Job runner adapter', () => {
     expect(send).not.toHaveBeenCalled()
     expect(send).not.toHaveBeenCalled()
     expect(kill).not.toHaveBeenCalled()
     expect(kill).not.toHaveBeenCalled()
     child.emit('close', 0, null)
     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 () => {
   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')
     launch.owner.signal('SIGTERM')
     expect(kill).not.toHaveBeenCalled()
     expect(kill).not.toHaveBeenCalled()
     child.emit('close', -2, null)
     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 () => {
   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()
     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
     const child = new EventEmitter() as ChildProcess
     Object.assign(child, {
     Object.assign(child, {
       pid: 987,
       pid: 987,
@@ -221,7 +216,7 @@ describe('Windows Job runner adapter', () => {
       appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
       appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null })
       child.emit('close', 0, null)
       child.emit('close', 0, null)
       await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: 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(run).toHaveBeenCalledOnce()
       expect(runSync).toHaveBeenCalledOnce()
       expect(runSync).toHaveBeenCalledOnce()
     } finally {
     } finally {
@@ -234,7 +229,6 @@ describe('Windows Job runner adapter', () => {
     const bridgeFailure = new Error('bridge failed')
     const bridgeFailure = new Error('bridge failed')
     const spawnFailure = new Error('spawn threw')
     const spawnFailure = new Error('spawn threw')
     const bridges: Array<WindowsStdioBridge & { dispose: ReturnType<typeof vi.fn>; closeInput: ReturnType<typeof vi.fn> }> = []
     const bridges: Array<WindowsStdioBridge & { dispose: ReturnType<typeof vi.fn>; closeInput: ReturnType<typeof vi.fn> }> = []
-    let collectedStreams: { stdout: PassThrough; stderr: PassThrough } | undefined
     vi.resetModules()
     vi.resetModules()
     vi.doMock('../src/windows-stdio.ts', async importOriginal => ({
     vi.doMock('../src/windows-stdio.ts', async importOriginal => ({
       ...await importOriginal<typeof import('../src/windows-stdio.ts')>(),
       ...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
         if (request.argv[0] === 'bridge-failure') throw bridgeFailure
         const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null
         const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null
         const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null
         const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null
-        if (stdout !== null && stderr !== null) collectedStreams = { stdout, stderr }
         const bridge = {
         const bridge = {
           stdin: null,
           stdin: null,
           stdout,
           stdout,
@@ -288,16 +281,8 @@ describe('Windows Job runner adapter', () => {
         },
         },
       } satisfies SubprocessSpawnSpec
       } satisfies SubprocessSpawnSpec
       const launch = isolated.launchWindowsJob(request, { spawn: run, runnerInvocation: ['fake-runner'] })
       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()
       expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce()
     } finally {
     } finally {
       vi.doUnmock('../src/windows-stdio.ts')
       vi.doUnmock('../src/windows-stdio.ts')