Sfoglia il codice sorgente

fix(subprocess): preserve native runner contracts

pku-xht 1 mese fa
parent
commit
6e7b152de7

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

@@ -340,7 +340,7 @@ export function prepareLinuxTerminalScope(
     command: internals.systemdRun ?? 'systemd-run',
     args: scopeArgs(unitBase, invocation, spec.argv),
     cwd: process.cwd(),
-    env: runnerEnvironment(files.requestPath),
+    env: runnerEnvironment(files.requestPath, invocation),
     bindOwner: direct => new SystemdScopeOwner(
       `${unitBase}.scope`,
       files,
@@ -384,7 +384,7 @@ export function launchLinuxScope(
       spec.argv,
     ), {
       cwd: process.cwd(),
-      env: runnerEnvironment(files.requestPath),
+      env: runnerEnvironment(files.requestPath, invocation),
       stdio: runnerStdio(spec, false),
       detached: true,
     })

+ 0 - 2
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -22,8 +22,6 @@ export interface ManagedProcessLaunch {
   stderr: Readable | null
   direct: Promise<SubprocessOutcome>
   owner: BoundProcessOwner
-  /** Rejects if runner ownership is lost before `.done` completes its stdio barrier. */
-  infrastructureFailure?: Promise<never>
 }
 
 /**

+ 12 - 2
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -17,6 +17,8 @@ export const WINDOWS_RUNNER_SELECTION = 'windows' as const
 /** Non-empty command tuple used to launch the private runner entry. */
 export type RunnerInvocation = [string, ...string[]]
 
+const SOURCE_TSCONFIG_PATH = fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url))
+
 /**
  * Resolve the source, built, or packaged entry that calls the same runner core.
  * @returns executable and arguments for the active runtime form.
@@ -57,12 +59,18 @@ export function runnerInvocationAvailable(invocation: RunnerInvocation = spawnRu
 /**
  * Build the bootstrap-safe environment; target overrides arrive through request/IPC.
  * @param selection - private runner selector or Linux launch-request locator.
+ * @param invocation - resolved runner invocation whose source form needs the workspace paths map.
  * @returns environment for the runner before target state is restored.
  */
-export function runnerEnvironment(selection: string): NodeJS.ProcessEnv {
+export function runnerEnvironment(
+  selection: string,
+  invocation?: RunnerInvocation,
+): NodeJS.ProcessEnv {
+  const entry = invocation?.at(-1)
   return childEnv({
     [SUBPROCESS_RUNNER_ENV]: selection,
     SYSTEMD_LOG_TARGET: 'null',
+    ...entry?.endsWith('.ts') === true ? { TSX_TSCONFIG_PATH: SOURCE_TSCONFIG_PATH } : {},
   })
 }
 
@@ -94,11 +102,13 @@ export function parseRunnerTargetArgv(argv: readonly string[]): string[] {
  * on fd 3 and target carriers on fd 4 through fd 6.
  * @param spec - ordinary subprocess request whose stdio modes are preserved.
  * @param ipc - whether to isolate the runner and add its private Node IPC descriptor.
+ * @param stdinCarrier - runner fd 4 carrier; Windows ignore passes an opened null-device fd.
  * @returns child-process stdio options for the runner.
  */
 export function runnerStdio(
   spec: SubprocessSpawnSpec,
   ipc: boolean,
+  stdinCarrier: 'pipe' | number = 'pipe',
 ): StdioOptions {
   const targetStdio: StdioOptions = [
     spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe',
@@ -111,7 +121,7 @@ export function runnerStdio(
     'ignore',
     'ignore',
     'ipc',
-    'pipe',
+    stdinCarrier,
     spec.stdio.stdout === 'inherit' ? 1 : 'pipe',
     spec.stdio.stderr === 'inherit' ? 2 : 'pipe',
   ]

+ 0 - 5
packages/subprocess/subprocess-local/src/spawn.ts

@@ -483,7 +483,6 @@ export function bindManagedProcess(
   let graceTimer: ReturnType<typeof setTimeout> | undefined
   let rangeExitObserved = false
   let rangeExitObservation: Promise<void> | undefined
-  let directResultLatched = false
   let settled = false
 
   const scheduleOwnerCleanup = (): boolean => {
@@ -564,7 +563,6 @@ export function bindManagedProcess(
       resolve(outcome)
     }
     const fail = (error: unknown): void => {
-      if (settled || directResultLatched) return
       settled = true
       terminate()
       stopCollectors()
@@ -573,10 +571,7 @@ export function bindManagedProcess(
       // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exact cancellation reason is the contract.
       reject(error)
     }
-    void launch.infrastructureFailure?.catch(fail)
     launch.direct.then((outcome) => {
-      if (settled) return
-      directResultLatched = true
       if (stdoutClosed === undefined && stderrClosed === undefined) {
         settle(outcome)
         return

+ 20 - 22
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -1,6 +1,8 @@
 /** Windows parent-side launch and ownership for the private Job runner. */
 
 import { spawn } from 'node:child_process'
+import { closeSync, openSync } from 'node:fs'
+import { devNull } from 'node:os'
 import type { Readable, Writable } from 'node:stream'
 import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import {
@@ -77,7 +79,7 @@ class WindowsJobOwner implements BoundProcessOwner {
     this.terminationSent = true
     try {
       this.runner.send?.({ type: 'terminate' }, (error) => {
-        if (error === null) return
+        if (error === null || !this.runner.connected) return
         this.failInfrastructure(error)
         this.terminateForHostExit()
       })
@@ -116,29 +118,32 @@ export function launchWindowsJob(
 ): ManagedProcessLaunch {
   const invocation = internals.runnerInvocation ?? spawnRunnerInvocation()
   const [command, ...prefix] = invocation
-  const child = (internals.spawn ?? spawn)(command, [
-    ...prefix,
-    '--',
-    ...spec.argv,
-  ], {
-    cwd: process.cwd(),
-    env: runnerEnvironment(WINDOWS_RUNNER_SELECTION),
-    stdio: runnerStdio(spec, true),
-  }) as RunnerProcess
+  const ignoredStdinFd = spec.stdio.stdin === 'ignore' ? openSync(devNull, 'r') : undefined
+  let child: RunnerProcess
+  try {
+    child = (internals.spawn ?? spawn)(command, [
+      ...prefix,
+      '--',
+      ...spec.argv,
+    ], {
+      cwd: process.cwd(),
+      env: runnerEnvironment(WINDOWS_RUNNER_SELECTION, invocation),
+      stdio: runnerStdio(spec, true, ignoredStdinFd ?? 'pipe'),
+    }) as RunnerProcess
+  } finally {
+    if (ignoredStdinFd !== undefined) closeSync(ignoredStdinFd)
+  }
   const targetStdin = child.stdio[4] as Writable | null
-  if (spec.stdio.stdin === 'ignore') targetStdin?.destroy()
 
   const direct = Promise.withResolvers<SubprocessOutcome>()
-  const infrastructure = Promise.withResolvers<never>()
   const rangeExit = Promise.withResolvers<void>()
   let resultSeen = false
   let infrastructureFailed = false
   const failInfrastructure = (error: unknown): void => {
-    if (infrastructureFailed) return
     infrastructureFailed = true
-    infrastructure.reject(error)
+    direct.reject(error)
+    rangeExit.reject(error)
   }
-  void infrastructure.promise.catch(() => {})
 
   const owner = new WindowsJobOwner(child, rangeExit.promise, failInfrastructure)
   child.on('message', (value: unknown) => {
@@ -167,8 +172,6 @@ export function launchWindowsJob(
   })
   child.once('error', (error) => {
     failInfrastructure(error)
-    direct.reject(error)
-    rangeExit.reject(error)
   })
   child.once('close', (exitCode, signal) => {
     const clean = exitCode === 0 && signal === null && resultSeen && !infrastructureFailed
@@ -185,8 +188,6 @@ export function launchWindowsJob(
       `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`,
     )
     failInfrastructure(error)
-    if (!resultSeen) direct.reject(error)
-    rangeExit.reject(error)
   })
 
   const start: WindowsStartRequest = { type: 'start', cwd: spec.cwd, env: targetEnv }
@@ -195,12 +196,10 @@ export function launchWindowsJob(
     child.send(start, (error) => {
       if (error === null) return
       failInfrastructure(error)
-      direct.reject(error)
       owner.terminateForHostExit()
     })
   } catch (error) {
     failInfrastructure(error)
-    direct.reject(error)
     owner.terminateForHostExit()
   }
 
@@ -210,6 +209,5 @@ export function launchWindowsJob(
     stderr: child.stdio[6] as Readable | null,
     direct: direct.promise,
     owner,
-    infrastructureFailure: infrastructure.promise,
   }
 }

+ 1 - 1
packages/subprocess/subprocess-local/tests/linux-execve.spec.ts

@@ -5,7 +5,7 @@ afterEach(() => {
   vi.resetModules()
 })
 
-describe('Linux libc execve binding', () => {
+describe.skipIf(process.platform !== 'linux')('Linux libc execve binding', () => {
   it('preserves inherited stdio, null-terminates argv and envp, and reports execve errno', async () => {
     const nativeExecve = vi.fn(() => -1)
     const nativeFcntl = vi.fn((fd: number, command: number) => {

+ 34 - 1
packages/subprocess/subprocess-local/tests/native-windows.spec.ts

@@ -97,6 +97,32 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     expect(readFileSync(output, 'utf8')).toBe('immediate-stdin')
   })
 
+  it('preserves direct Node null-device semantics for ignored stdin', async () => {
+    const script = `
+      const stat = require('node:fs').fstatSync(0)
+      process.stdout.write(JSON.stringify({
+        file: stat.isFile(),
+        directory: stat.isDirectory(),
+        block: stat.isBlockDevice(),
+        character: stat.isCharacterDevice(),
+        fifo: stat.isFIFO(),
+        socket: stat.isSocket(),
+      }))
+    `
+    const direct = spawnSync(process.execPath, ['-e', script], {
+      cwd: scratch,
+      stdio: ['ignore', 'pipe', 'inherit'],
+      encoding: 'utf8',
+    })
+    expect(direct.status).toBe(0)
+
+    const request = spec([process.execPath, '-e', script])
+    const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request)))
+    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
+    await expect(handle.waitForExit()).resolves.toBe(true)
+    expect(handle.collected.stdout?.readFrom(0).text).toBe(direct.stdout)
+  })
+
   it('reports direct exit before terminating its default-inheritance descendant', async () => {
     const pidFile = join(scratch, `job-survivor-${Date.now()}.pid`)
     const factsFile = join(scratch, `job-facts-${Date.now()}.json`)
@@ -167,9 +193,16 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
     await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' })
     await expect(missingHandle.waitForExit()).resolves.toBe(true)
 
+    const expectedAccessDenied = await directSpawnFailure([scratch])
     const accessDenied = spec([scratch])
     const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied, targetEnvironment(accessDenied)))
-    await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: 'EACCES' })
+    await expect(accessDeniedHandle.done).rejects.toMatchObject({
+      code: expectedAccessDenied.code,
+      errno: expectedAccessDenied.errno,
+      syscall: expectedAccessDenied.syscall,
+      path: expectedAccessDenied.path,
+      spawnargs: expectedAccessDenied.spawnargs,
+    })
     await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true)
 
     const missingCwd = join(scratch, `missing-cwd-${Date.now()}`)

+ 1 - 1
packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts

@@ -39,7 +39,7 @@ async function executePosix(invocation: RunnerInvocation): Promise<{ status: num
       '--eval',
       `process.stdout.write(process.argv[0]+'|'+process.cwd()+'|'+process.env.${SUBPROCESS_RUNNER_ENV})`,
     ], {
-      env: runnerEnvironment(files.requestPath),
+      env: runnerEnvironment(files.requestPath, invocation),
       stdio: ['ignore', 'pipe', 'pipe'],
     })
     let stdout = ''

+ 8 - 4
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -12,7 +12,7 @@ import {
   writeFileSync,
 } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join, posix } from 'node:path'
+import { join, posix, resolve } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Win32Error } from '@deepseek-ai/dsh-win32-process'
 import type {
@@ -246,9 +246,13 @@ describe('runner launch inputs', () => {
 
   it('keeps target state out of the bootstrap environment and consumes its selector', () => {
     const env = runnerEnvironment('/tmp/request')
+    const sourceEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/bin.ts'])
+    const builtEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/runner.js'])
     expect(env[SUBPROCESS_RUNNER_ENV]).toBe('/tmp/request')
     expect(env.SYSTEMD_LOG_TARGET).toBe('null')
     expect(env.EXPLICIT).toBeUndefined()
+    expect(sourceEnv.TSX_TSCONFIG_PATH).toBe(resolve(import.meta.dirname, '../../../..', 'tsconfig.base.json'))
+    expect(builtEnv.TSX_TSCONFIG_PATH).toBe(env.TSX_TSCONFIG_PATH)
     expect(consumeRunnerSelection(env)).toBe('/tmp/request')
     expect(env[SUBPROCESS_RUNNER_ENV]).toBeUndefined()
     expect(consumeRunnerSelection({})).toBeUndefined()
@@ -265,7 +269,7 @@ describe('runner launch inputs', () => {
     expect(runnerStdio({
       ...spec,
       stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' },
-    }, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 1, 'pipe'])
+    }, true, 17)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 17, 1, 'pipe'])
   })
 
   it('validates every Node-baseline NUL location before launch', () => {
@@ -310,7 +314,7 @@ describe('runner launch inputs', () => {
     const directory = mkdtempSync(join(tmpdir(), 'dsh-runner-cwd-'))
     scratch.push(directory)
     const invocation = spawnRunnerInvocation()
-    const env = runnerEnvironment('unused')
+    const env = runnerEnvironment('unused', invocation)
     Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV)
     const launched = spawnSync(invocation[0], invocation.slice(1), {
       cwd: directory,
@@ -461,7 +465,7 @@ describe('Linux one-shot exec bootstrap', () => {
     expect(rootExecve).toHaveBeenCalledWith('/tool', ['tool'], { PATH: '' })
   })
 
-  it('preserves symlink-sensitive parent traversal in PATH candidates', async () => {
+  it.skipIf(process.platform === 'win32')('preserves symlink-sensitive parent traversal in PATH candidates', async () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-linux-path-symlink-'))
     scratch.push(root)
     const cwd = join(root, 'cwd')

+ 5 - 39
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -808,10 +808,10 @@ describe('coverage seams', () => {
     await Promise.resolve()
   })
 
-  it('keeps an infrastructure failure authoritative when it precedes the direct result', async () => {
+  it('contains a managed-owner cleanup failure after direct and range settlement', async () => {
     const direct = Promise.withResolvers<{ exitCode: number; signal: null }>()
-    const infrastructureFailure = Promise.withResolvers<never>()
-    const failure = new Error('runner failed before its result')
+    const stopped = Promise.withResolvers<undefined>()
+    const cleanup = vi.fn(() => { throw new Error('protocol cleanup failed') })
     const handle = bindManagedProcess(spec('true', {
       stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
     }), {
@@ -819,55 +819,21 @@ describe('coverage seams', () => {
       stdout: null,
       stderr: null,
       direct: direct.promise,
-      infrastructureFailure: infrastructureFailure.promise,
       owner: {
         signal: vi.fn(),
-        waitForExit: async () => {},
+        waitForExit: () => stopped.promise,
         terminateForHostExit: vi.fn(),
+        cleanup,
       },
     })
 
-    infrastructureFailure.reject(failure)
-    await expect(handle.done).rejects.toBe(failure)
-    direct.resolve({ exitCode: 0, signal: null })
-    await Promise.resolve()
-    await expect(handle.done).rejects.toBe(failure)
-  })
-
-  it('cleans a managed owner after direct settlement and contains a later infrastructure failure', async () => {
-    const direct = Promise.withResolvers<{ exitCode: number; signal: null }>()
-    const stopped = Promise.withResolvers<undefined>()
-    const infrastructureFailure = Promise.withResolvers<never>()
-    const cleanup = vi.fn(() => { throw new Error('protocol cleanup failed') })
-    const owner = {
-      signal: vi.fn(),
-      waitForExit: vi.fn(() => stopped.promise),
-      terminateForHostExit: vi.fn(),
-      cleanup,
-    }
-    const handle = bindManagedProcess(spec('true', {
-      stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' },
-    }), {
-      stdin: null,
-      stdout: null,
-      stderr: null,
-      direct: direct.promise,
-      infrastructureFailure: infrastructureFailure.promise,
-      owner,
-    })
-
     const waiting = handle.waitForExit()
     stopped.resolve(undefined)
     await expect(waiting).resolves.toBe(true)
     expect(cleanup).not.toHaveBeenCalled()
-
     direct.resolve({ exitCode: 0, signal: null })
     await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
     await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
-
-    infrastructureFailure.reject(new Error('late runner failure'))
-    await Promise.resolve()
-    await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
   })
 
   it('retries an early range read but cleans and retains a terminal range failure', async () => {

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

@@ -1,4 +1,5 @@
 import { EventEmitter } from 'node:events'
+import { fstatSync } from 'node:fs'
 import { PassThrough } from 'node:stream'
 import { describe, expect, it, vi } from 'vitest'
 import {
@@ -20,21 +21,28 @@ class FakeChild extends EventEmitter {
   sent: unknown[] = []
   killed: NodeJS.Signals[] = []
   sendError: Error | undefined
+  deferSendCallbacks = false
+  pendingSendCallbacks: Array<(error: Error | null) => void> = []
   throwOnSendCall: number | undefined
   sendThrown: unknown = new Error('send threw')
-  stdinDestroyedAtStart: boolean | undefined
   private sendCalls = 0
 
   send(message: unknown, callback?: (error: Error | null) => void): boolean {
     this.sendCalls += 1
     if (this.sendCalls === this.throwOnSendCall) throw this.sendThrown
-    if ((message as { type?: string }).type === 'start') {
-      this.stdinDestroyedAtStart = this.targetStdin.destroyed
-    }
     this.sent.push(message)
-    queueMicrotask(() => { callback?.(this.sendError ?? null) })
+    if (callback !== undefined && this.deferSendCallbacks) {
+      this.pendingSendCallbacks.push(callback)
+    } else {
+      queueMicrotask(() => { callback?.(this.sendError ?? null) })
+    }
     return true
   }
+  deliverNextSend(error: Error | null): void {
+    const callback = this.pendingSendCallbacks.shift()
+    if (callback === undefined) throw new Error('no deferred send callback')
+    callback(error)
+  }
   kill(signal: NodeJS.Signals): boolean {
     this.killed.push(signal)
     return true
@@ -53,7 +61,7 @@ function launch(
   child = new FakeChild(),
   request: Parameters<typeof launchWindowsJob>[0] = spec,
 ) {
-  const spawn = vi.fn(() => child)
+  const spawn = vi.fn((_command: string, _args: readonly string[], _options: unknown) => child)
   const result = launchWindowsJob(request, { TARGET: 'yes' }, {
     spawn: spawn as never,
     runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'],
@@ -141,7 +149,7 @@ describe('Windows parent runner contract', () => {
     expect(result.stderr).toBe(child.targetStderr)
   })
 
-  it('always carries fd 4 and closes ignored stdin before sending start', () => {
+  it('carries a null-device fd 4 for ignored stdin and closes the parent descriptor after spawn', () => {
     const child = new FakeChild()
     const ignored = {
       ...spec,
@@ -149,12 +157,37 @@ describe('Windows parent runner contract', () => {
     } as const
     const { result, spawn } = launch(child, ignored)
     expect(spawn).toHaveBeenCalledWith('C:\\node.exe', expect.any(Array), expect.objectContaining({
-      stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2],
+      stdio: ['ignore', 'ignore', 'ignore', 'ipc', expect.any(Number), 'pipe', 2],
     }))
-    expect(child.stdinDestroyedAtStart).toBe(true)
+    const options = spawn.mock.calls[0]?.[2] as { stdio: unknown[] }
+    const carrier = options.stdio[4]
+    if (typeof carrier !== 'number') throw new Error('expected numeric null-device carrier')
+    expect(() => fstatSync(carrier)).toThrow()
     expect(result.stdin).toBeNull()
   })
 
+  it('closes the ignored-stdin descriptor when runner spawn throws synchronously', () => {
+    const ignored = {
+      ...spec,
+      stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' },
+    } as const
+    let carrier: number | undefined
+    const spawn = vi.fn((_command: string, _args: readonly string[], options: unknown) => {
+      const candidate = (options as { stdio: unknown[] }).stdio[4]
+      if (typeof candidate !== 'number') throw new Error('expected numeric null-device carrier')
+      carrier = candidate
+      throw new Error('runner spawn failed')
+    })
+
+    expect(() => launchWindowsJob(ignored, { TARGET: 'yes' }, {
+      spawn: spawn as never,
+      runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'],
+    })).toThrow('runner spawn failed')
+    if (carrier === undefined) throw new Error('runner spawn was not attempted')
+    const closedCarrier = carrier
+    expect(() => fstatSync(closedCarrier)).toThrow()
+  })
+
   it('maps target-exit to direct outcome and clean close to range quiescence', async () => {
     const { child, result } = launch()
     child.emit('message', { type: 'target-exit', exitCode: 7 })
@@ -223,7 +256,6 @@ describe('Windows parent runner contract', () => {
     failed.child.connected = false
     failed.child.emit('close', 127, null)
     await expect(failed.result.owner.waitForExit()).rejects.toThrow('exit code 127')
-    await expect(failed.result.infrastructureFailure).rejects.toThrow('exit code 127')
 
     const missing = launch()
     missing.child.connected = false
@@ -240,15 +272,14 @@ describe('Windows parent runner contract', () => {
     const malformed = launch()
     malformed.child.emit('message', { type: 'target-exit', exitCode: -1 })
     expect(malformed.child.killed).toEqual(['SIGKILL'])
-    await expect(malformed.result.infrastructureFailure).rejects.toThrow('invalid target-exit')
+    await expect(malformed.result.direct).rejects.toThrow('invalid target-exit')
+    await expect(malformed.result.owner.waitForExit()).rejects.toThrow('invalid target-exit')
 
     const duplicate = launch()
     duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 })
     duplicate.child.emit('message', { type: 'target-exit', exitCode: 0 })
-    await expect(duplicate.result.infrastructureFailure).rejects.toThrow('more than one direct result')
-    duplicate.child.connected = false
-    duplicate.child.emit('close', 127, null)
-    await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('exit code 127')
+    await expect(duplicate.result.direct).resolves.toEqual({ exitCode: 0, signal: null })
+    await expect(duplicate.result.owner.waitForExit()).rejects.toThrow('more than one direct result')
 
     const errored = launch()
     const spawnError = new Error('runner executable missing')
@@ -260,21 +291,21 @@ describe('Windows parent runner contract', () => {
     sendFailedChild.sendError = new Error('IPC send failed')
     const sendFailed = launch(sendFailedChild)
     await expect(sendFailed.result.direct).rejects.toThrow('IPC send failed')
-    await expect(sendFailed.result.infrastructureFailure).rejects.toThrow('IPC send failed')
+    await expect(sendFailed.result.owner.waitForExit()).rejects.toThrow('IPC send failed')
     expect(sendFailedChild.killed).toEqual(['SIGKILL'])
 
     const noIpc = new FakeChild()
     Object.defineProperty(noIpc, 'send', { value: undefined })
     const noIpcResult = launch(noIpc).result
     await expect(noIpcResult.direct).rejects.toThrow('has no IPC channel')
-    await expect(noIpcResult.infrastructureFailure).rejects.toThrow('has no IPC channel')
+    await expect(noIpcResult.owner.waitForExit()).rejects.toThrow('has no IPC channel')
 
     const nonError = new FakeChild()
     nonError.throwOnSendCall = 1
     nonError.sendThrown = 'start send failed'
     const nonErrorResult = launch(nonError).result
     await expect(nonErrorResult.direct).rejects.toBe('start send failed')
-    await expect(nonErrorResult.infrastructureFailure).rejects.toBe('start send failed')
+    await expect(nonErrorResult.owner.waitForExit()).rejects.toBe('start send failed')
   })
 
   it('fails infrastructure and kills the runner when termination delivery fails', async () => {
@@ -282,25 +313,46 @@ describe('Windows parent runner contract', () => {
     await Promise.resolve()
     callback.child.sendError = new Error('terminate callback failed')
     callback.result.owner.signal('SIGTERM')
-    await expect(callback.result.infrastructureFailure).rejects.toThrow('terminate callback failed')
+    await expect(callback.result.direct).rejects.toThrow('terminate callback failed')
+    await expect(callback.result.owner.waitForExit()).rejects.toThrow('terminate callback failed')
     expect(callback.child.killed).toEqual(['SIGKILL'])
-    callback.child.connected = false
-    callback.child.emit('close', 127, null)
-    await expect(callback.result.direct).rejects.toThrow('exit code 127')
 
     const throwingChild = new FakeChild()
     throwingChild.throwOnSendCall = 2
     throwingChild.sendThrown = 'terminate send threw'
     const throwing = launch(throwingChild)
     throwing.result.owner.signal('SIGTERM')
-    await expect(throwing.result.infrastructureFailure).rejects.toBe('terminate send threw')
+    await expect(throwing.result.direct).rejects.toBe('terminate send threw')
+    await expect(throwing.result.owner.waitForExit()).rejects.toBe('terminate send threw')
     expect(throwing.child.killed).toEqual(['SIGKILL'])
 
     const errorChild = new FakeChild()
     errorChild.throwOnSendCall = 2
     const error = launch(errorChild)
     error.result.owner.signal('SIGTERM')
-    await expect(error.result.infrastructureFailure).rejects.toThrow('send threw')
+    await expect(error.result.direct).rejects.toThrow('send threw')
+    await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw')
+  })
+
+  it('ignores a terminate callback error delivered after clean runner disconnect', async () => {
+    const child = new FakeChild()
+    const launched = launch(child)
+    const handle = bindManagedProcess(spec, launched.result)
+    await Promise.resolve()
+    child.deferSendCallbacks = true
+    child.emit('message', {
+      type: 'error', error: { name: 'Error', message: 'target start failed', code: 'ENOENT' },
+    })
+    await expect(handle.done).rejects.toMatchObject({ code: 'ENOENT' })
+    expect(child.pendingSendCallbacks).toHaveLength(1)
+
+    child.connected = false
+    child.emit('close', 0, null)
+    await expect(handle.waitForExit()).resolves.toBe(true)
+    child.deliverNextSend(new Error('late EPIPE'))
+    await Promise.resolve()
+    expect(child.killed).toEqual([])
+    await expect(handle.waitForExit()).resolves.toBe(true)
   })
 
   it('uses synchronous runner termination for host exit and isolates repeated control', () => {

+ 0 - 3
packages/subprocess/win32-process/src/process.ts

@@ -571,9 +571,6 @@ export function spawnCurrentTokenJobProcess(
  * @param api - active binding table.
  */
 export function probeCurrentTokenJobSupport(api: CurrentTokenProcessBindings): void {
-  if (typeof api.getStartupInfoW !== 'function') {
-    throw new Error('current-token Job support requires GetStartupInfoW')
-  }
   const job = createKillOnCloseJob(api)
   closeHandleChecked(api, job, 'current-token Job capability probe')
 }

+ 0 - 4
packages/subprocess/win32-process/tests/ordinary-process.spec.ts

@@ -276,9 +276,5 @@ describe('ordinary Job process operations', () => {
       )
       expectFailure(startupInfo(table), 'invalid handle for target stdin fd 4')
     }
-
-    const missingBinding = api({ getStartupInfoW: undefined as never })
-    expect(() => { probeCurrentTokenJobSupport(missingBinding) })
-      .toThrow('current-token Job support requires GetStartupInfoW')
   })
 })