Parcourir la source

fix(subprocess): harden managed-range cleanup

pku-xht il y a 1 mois
Parent
commit
f913c813d2

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

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

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

@@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct-
 
 The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals.
 
-Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default; after target creation, the runner releases its own standard-handle copies before publishing startup, so pipe EOF follows the target and descendants that actually inherited the stream. The runner remains until the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
+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, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the Job by default; after target creation, the runner releases its own standard-handle copies before publishing startup, so pipe EOF follows the target and descendants that actually inherited the stream. The runner remains until the direct result is reported and `QueryInformationJobObject` reports zero active Job members. Parent IPC disconnect terminates the Job during JavaScript-observable host exit.
 
 When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback.
 

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

@@ -14,7 +14,7 @@ Status: implemented
 
 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。
 
-Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;target 创建后,runner 会在发布启动事实前释放自身持有的标准句柄副本,因此 pipe EOF 取决于 target 与实际继承该流的 descendant。runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
+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 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 Job;target 创建后,runner 会在发布启动事实前释放自身持有的标准句柄副本,因此 pipe EOF 取决于 target 与实际继承该流的 descendant。runner 会一直存活到 direct result 已报告且 `QueryInformationJobObject` 报告 Job active member 归零。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。
 
 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。
 

+ 3 - 6
packages/subagent/subagent-acp/src/run.ts

@@ -110,12 +110,9 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boo
  * @param eofGraceMs - tier-1 window after stdin EOF.
  */
 export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number): Promise<void> {
-  // A spawn failure has no process to tear down; observe the rejection so
-  // disposal in a finally block cannot surface it as unhandled.
-  if (child.pid <= 0) {
-    await child.done.catch(() => {})
-    return
-  }
+  // Observe the direct result independently. A non-positive pid does not prove
+  // that a native owner has no range left to terminate or await.
+  void child.done.catch(() => {})
   child.stdin?.end()
   if (await treeExitsWithin(child, eofGraceMs)) return
   // terminate() owns the provider-specific procedure. Its unbounded wait is

+ 25 - 2
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
 import SubagentRuntime from '@deepseek-ai/dsh-subagent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
+import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
 import * as acp from '../src/index.ts'
 import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -196,6 +196,29 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
     await expect(disposeAcpChild(child, 1_000)).resolves.toBeUndefined()
     await expect(child.done).rejects.toThrow()
   })
+
+  it('still asks an unpublished native owner to terminate and settle', async () => {
+    const terminate = vi.fn()
+    const waitForExit = vi.fn()
+      .mockResolvedValueOnce(false)
+      .mockResolvedValueOnce(true)
+    const done = Promise.reject(new Error('runner failed before publishing target pid'))
+    void done.catch(() => {})
+    const child: SubprocessHandle = {
+      pid: -1,
+      stdin: undefined,
+      stdout: undefined,
+      stderr: undefined,
+      collected: {},
+      done,
+      terminate,
+      waitForExit,
+    }
+
+    await expect(disposeAcpChild(child, 100)).resolves.toBeUndefined()
+    expect(terminate).toHaveBeenCalledOnce()
+    expect(waitForExit).toHaveBeenCalledTimes(2)
+  })
 })
 
 describe('cwd resolution', () => {

+ 13 - 9
packages/subagent/subagent-claude-code/src/run.ts

@@ -454,26 +454,30 @@ export async function startClaudeCodeRun(
     )
     requestCancel()
     if (child !== undefined && child.pid <= 0) {
-      let closeError: Error | undefined
+      let spawnError = thrown(error)
+      void child.done.catch((childError: unknown) => { spawnError = thrown(childError) })
+      const cleanupErrors: Error[] = []
       try {
         query?.close()
       } catch (disposeError: unknown) {
-        closeError = thrown(disposeError)
+        cleanupErrors.push(thrown(disposeError))
       }
-
-      let spawnError = thrown(error)
+      child.terminate()
       try {
-        await child.done
-      } catch (childError: unknown) {
-        spawnError = thrown(childError)
+        await child.waitForExit()
+      } catch (disposeError: unknown) {
+        cleanupErrors.push(thrown(disposeError))
       }
+      await Promise.resolve()
 
-      if (closeError !== undefined) {
+      if (cleanupErrors.length > 0) {
         const failure = startupFailure(spawnError)
         const cleanupFailure = new ClaudeCodeFailure({
           stage: 'teardown',
           category: 'unknown',
-        }, closeError)
+        }, cleanupErrors.length === 1
+          ? cleanupErrors[0]
+          : new AggregateError(cleanupErrors, 'Claude Code teardown failures'))
         const aggregate = new AggregateError(
           [failure, cleanupFailure],
           `${failure.message}; ${cleanupFailure.message}`,

+ 2 - 2
packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts

@@ -1449,8 +1449,8 @@ describe('run publication, cancellation, and settlement', () => {
     await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES')
     await expect(failedStartup).rejects.toMatchObject({ cause: spawnError })
     expect(failed.close).toHaveBeenCalledOnce()
-    expect(failedSpawn.terminate).not.toHaveBeenCalled()
-    expect(failedSpawn.waitForExit).not.toHaveBeenCalled()
+    expect(failedSpawn.terminate).toHaveBeenCalledOnce()
+    expect(failedSpawn.waitForExit).toHaveBeenCalledOnce()
 
     const failedSpawnAbort = new AbortController()
     const cancelledFailedSpawn = fakeChild({

+ 20 - 20
packages/subagent/subagent-codex/src/run.ts

@@ -188,32 +188,32 @@ export async function disposeCodexChild(
 ): Promise<void> {
   wire.close()
 
-  if (child.pid > 0) {
-    let outcome: SubprocessOutcome | undefined
+  const targetPublished = child.pid > 0
+  let outcome: SubprocessOutcome | undefined
+  if (targetPublished) {
     void child.done.then(
       (value) => { outcome = value },
       /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */
       () => {},
     )
-    try {
-      child.stdin?.end()
-    } catch {
-      // A concurrently closed stdin does not change tree ownership below.
-    }
-    child.terminate()
-    try {
-      await child.waitForExit()
-    } catch (error: unknown) {
-      throw new CodexRunFailure({
-        stage: 'teardown',
-        category: 'unknown',
-        outcome,
-      }, thrown(error))
-    }
-    await child.done
-  } else {
-    await child.done.catch(() => {})
   }
+  try {
+    child.stdin?.end()
+  } catch {
+    // A concurrently closed stdin does not change range ownership below.
+  }
+  child.terminate()
+  try {
+    await child.waitForExit()
+  } catch (error: unknown) {
+    throw new CodexRunFailure({
+      stage: 'teardown',
+      category: 'unknown',
+      outcome,
+    }, thrown(error))
+  }
+  if (targetPublished) await child.done
+  else await child.done.catch(() => {})
 }
 
 /**

+ 5 - 4
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

@@ -1922,7 +1922,8 @@ describe('run lifecycle and quiescence', () => {
     await expect(asyncSpawnFailure)
       .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
     await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN')
-    expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled()
+    expect(asyncSpawnFailureChild.terminate).toHaveBeenCalledOnce()
+    expect(asyncSpawnFailureChild.waitForExit).toHaveBeenCalledOnce()
 
     const child = fakeChild()
     const starting = startCodexRun(request(), runSpec(child))
@@ -2296,7 +2297,7 @@ describe('disposeCodexChild', () => {
       .resolves.toBeUndefined()
   })
 
-  it('handles a spawn-level failure with no managed range', async () => {
+  it('asks an unpublished owner to settle after a spawn-level failure', async () => {
     const child = fakeChild({
       pid: -1,
       doneError: new Error('spawn failed'),
@@ -2304,8 +2305,8 @@ describe('disposeCodexChild', () => {
     const wire = defaultWire(child)
     await expect(disposeCodexChild(wire, child.handle))
       .resolves.toBeUndefined()
-    expect(child.terminate).not.toHaveBeenCalled()
-    expect(child.waitForExit).not.toHaveBeenCalled()
+    expect(child.terminate).toHaveBeenCalledOnce()
+    expect(child.waitForExit).toHaveBeenCalledOnce()
   })
 
   it('reports tree-wait failure with safe teardown facts', async () => {

+ 7 - 2
packages/subprocess/subprocess-local/src/index.ts

@@ -94,8 +94,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     const pending: Promise<unknown>[] = []
     for (const handle of this.live) {
       handle.terminate()
-      // Spawn-failure rejections already settled and left the live set.
-      pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
+      // Direct result and range observation are independent. Start both now so
+      // an unreadable owner cannot hide behind a target that termination failed
+      // to stop; direct-result rejection itself remains non-fatal to disposal.
+      pending.push(Promise.all([
+        handle.done.catch(() => {}),
+        handle.waitForExit(),
+      ]).then(() => undefined))
     }
     for (const terminal of this.terminals) {
       pending.push(terminal.terminate())

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

@@ -38,7 +38,7 @@ const SCOPE_POLL_INTERVAL_MS = 200
 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu
 
 function systemctlEnv(): NodeJS.ProcessEnv {
-  return { ...process.env, LC_ALL: 'C' }
+  return childEnv({ LC_ALL: 'C' })
 }
 
 function querySystemctl(command: string, args: readonly string[]): Promise<SystemctlResult> {
@@ -106,7 +106,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean {
 class SystemdScopeOwner implements BoundProcessOwner {
   private stopped = false
   private observation: Promise<void> | undefined
-  private killConfirmed = false
   private killFailure: Error | undefined
 
   constructor(
@@ -127,7 +126,6 @@ class SystemdScopeOwner implements BoundProcessOwner {
       this.unit,
     ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS })
     if (result.error === undefined && result.status === 0) {
-      if (signal === 'SIGKILL') this.killConfirmed = true
       return
     }
     if (signal === 'SIGKILL') {
@@ -174,9 +172,6 @@ class SystemdScopeOwner implements BoundProcessOwner {
     return waitWithAbort(this.observation, signal)
   }
 
-  forcedOutcome(): { exitCode: null; signal: 'SIGKILL' } | undefined {
-    return this.killConfirmed ? { exitCode: null, signal: 'SIGKILL' } : undefined
-  }
 }
 
 /**
@@ -218,7 +213,7 @@ export function launchLinuxScope(
   })
   const closed = observeChildClose(child)
   const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child)
-  const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome())
+  const result = runnerDirectResult(child, files, closed)
   cleanupAfterRunner(files, result.direct, closed)
   return { child, pid: result.pid, direct: result.direct, closed, owner }
 }

+ 1 - 6
packages/subprocess/subprocess-local/src/runner-launch.ts

@@ -114,7 +114,6 @@ async function waitForDirectResult(
   files: RunnerFiles,
   initial: RunnerEvent[],
   closed: Promise<void>,
-  missingResult?: () => SubprocessOutcome | undefined,
 ): Promise<SubprocessOutcome> {
   let seen = 0
   const wrapperState = { closed: false }
@@ -131,8 +130,6 @@ async function waitForDirectResult(
     }
     seen = Math.max(seen, events.length, initial.length)
     if (closedBeforeRead) {
-      const known = missingResult?.()
-      if (known !== undefined) return known
       throw new Error('native subprocess runner exited without a direct-command result')
     }
     await sleepMs(RUNNER_EVENT_POLL_MS)
@@ -144,14 +141,12 @@ async function waitForDirectResult(
  * @param child - native wrapper process.
  * @param files - private request and result paths.
  * @param closed - wrapper close observation attached before the start handshake.
- * @param missingResult - authoritative outcome available when force-kill prevents a final event.
  * @returns target pid and direct result promise.
  */
 export function runnerDirectResult(
   child: ChildProcess,
   files: RunnerFiles,
   closed: Promise<void>,
-  missingResult?: () => SubprocessOutcome | undefined,
 ): {
   pid: number
   direct: Promise<SubprocessOutcome>
@@ -165,7 +160,7 @@ export function runnerDirectResult(
   }
   return {
     pid: handshake.pid,
-    direct: waitForDirectResult(files, handshake.events, closed, missingResult),
+    direct: waitForDirectResult(files, handshake.events, closed),
   }
 }
 

+ 14 - 2
packages/subprocess/subprocess-local/src/runner-protocol.ts

@@ -2,9 +2,10 @@
 
 import {
   appendFileSync,
+  lstatSync,
   mkdtempSync,
   readFileSync,
-  rmSync,
+  rmdirSync,
   unlinkSync,
   writeFileSync,
 } from 'node:fs'
@@ -218,7 +219,18 @@ export function deserializeSpawnError(serialized: SerializedSpawnError): Error {
  */
 export function cleanupRunnerFiles(files: RunnerFiles): void {
   try {
-    rmSync(files.directory, { recursive: true, force: true })
+    if (lstatSync(files.directory).isSymbolicLink()) {
+      unlinkSync(files.directory)
+      return
+    }
+    for (const file of [files.requestPath, files.eventsPath]) {
+      try {
+        unlinkSync(file)
+      } catch (error) {
+        if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      }
+    }
+    rmdirSync(files.directory)
   } catch {
     // A crash residue remains private and is not reused by later spawns.
   }

+ 1 - 0
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -134,6 +134,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise<voi
   try {
     let spawned
     try {
+      process.chdir(request.cwd)
       const [command, ...args] = request.argv
       spawned = spawnOrdinaryJobProcess(api, { command: command as string, args, cwd: request.cwd })
     } catch (error) {

+ 8 - 6
packages/subprocess/subprocess-local/src/spawn.ts

@@ -449,6 +449,12 @@ export function bindManagedProcess(
   }
   const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
   const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
+  const stopCollectors = (): void => {
+    if (stdoutCollector !== undefined) child.stdout?.destroy()
+    if (stderrCollector !== undefined) child.stderr?.destroy()
+    stdoutCollector?.seal()
+    stderrCollector?.seal()
+  }
 
   let graceTimer: ReturnType<typeof setTimeout> | undefined
   let rangeExitObserved = false
@@ -509,10 +515,7 @@ export function bindManagedProcess(
       settled = true
       // Only harness-collected pipes are force-closed at the drain boundary;
       // a 'pipe'-mode stream belongs to the caller and closes with the child.
-      if (stdoutCollector !== undefined) child.stdout?.destroy()
-      if (stderrCollector !== undefined) child.stderr?.destroy()
-      stdoutCollector?.seal()
-      stderrCollector?.seal()
+      stopCollectors()
       cleanup()
       resolve(outcome)
     }
@@ -529,8 +532,7 @@ export function bindManagedProcess(
       if (settled) return
       settled = true
       terminate()
-      stdoutCollector?.seal()
-      stderrCollector?.seal()
+      stopCollectors()
       cleanup()
       reject(error instanceof Error ? error : new Error(String(error)))
     })

+ 23 - 14
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -28,6 +28,9 @@ function asyncQuery(runSync: typeof spawnSync) {
 
 describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => {
   it('requires a readable user manager and literal-argument systemd support', () => {
+    const secretName = 'DSH_SCOPE_TEST_TOKEN'
+    const previousSecret = process.env[secretName]
+    process.env[secretName] = 'secret'
     const calls: string[][] = []
     const environments: Array<NodeJS.ProcessEnv | undefined> = []
     const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => {
@@ -36,18 +39,24 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
       return { status: 0, error: undefined }
     }) as unknown as typeof spawnSync
     const runnerInvocation = ['node-runtime', 'runner-entry.js']
-    expect(probeLinuxScope({
-      spawnSync: runSync,
-      systemdRun: 'systemd-run',
-      systemctl: 'systemctl',
-      runnerInvocation,
-    })).toBe(true)
-    expect(calls[1]).toContain('--expand-environment=no')
-    expect(calls[1]).not.toContain('--pipe')
-    expect(calls[1]).not.toContain('--wait')
-    const separator = calls[1]?.indexOf('--') ?? -1
-    expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node'])
-    expect(environments[0]?.LC_ALL).toBe('C')
+    try {
+      expect(probeLinuxScope({
+        spawnSync: runSync,
+        systemdRun: 'systemd-run',
+        systemctl: 'systemctl',
+        runnerInvocation,
+      })).toBe(true)
+      expect(calls[1]).toContain('--expand-environment=no')
+      expect(calls[1]).not.toContain('--pipe')
+      expect(calls[1]).not.toContain('--wait')
+      const separator = calls[1]?.indexOf('--') ?? -1
+      expect(calls[1]?.slice(separator + 1)).toEqual([...runnerInvocation, '--mode', 'probe-node'])
+      expect(environments[0]?.LC_ALL).toBe('C')
+      expect(environments[0]).not.toHaveProperty(secretName)
+    } finally {
+      if (previousSecret === undefined) Reflect.deleteProperty(process.env, secretName)
+      else process.env[secretName] = previousSecret
+    }
 
     const oldSystemd = vi.fn((command: string) => ({
       status: command === 'systemctl' ? 0 : 1,
@@ -104,7 +113,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     expect(systemdArgs).not.toContain('literal $VALUE')
   })
 
-  it('still escalates after a missing-unit TERM response and uses the authoritative scope KILL', async () => {
+  it('still escalates after a missing-unit TERM response without fabricating a direct result', async () => {
     let wrapper: ReturnType<typeof spawn> | undefined
     const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
       const separator = args.indexOf('--')
@@ -133,7 +142,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', ()
     })
     launch.owner.signal('SIGTERM')
     launch.owner.signal('SIGKILL')
-    await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
+    await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
     await expect(launch.owner.waitForExit()).resolves.toBe(true)
   })
 

+ 35 - 0
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -87,6 +87,41 @@ describe('LocalSubprocessRuntime', () => {
     expect(process.listeners('exit')).not.toContain(listener)
   })
 
+  it('observes range failure without waiting for a stuck direct result', async () => {
+    const before = new Set(process.listeners('exit'))
+    const ctx = new Context()
+    const disposalErrors: unknown[] = []
+    ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
+    const fiber = await ctx.plugin(LocalSubprocessRuntime)
+    const listener = process.listeners('exit').find(candidate => !before.has(candidate))
+    const rangeFailure = new Error('managed range became unreadable')
+    const terminate = vi.fn()
+    const terminateForHostExit = vi.fn()
+    const live = (ctx.subprocess as unknown as {
+      live: Set<{
+        done: Promise<never>
+        terminate(): void
+        terminateForHostExit(): void
+        waitForExit(): Promise<boolean>
+      }>
+    }).live
+    live.add({
+      done: new Promise<never>(() => {}),
+      terminate,
+      terminateForHostExit,
+      waitForExit: async () => { throw rangeFailure },
+    })
+
+    await expect(Promise.race([
+      fiber.dispose().then(() => 'disposed'),
+      new Promise<string>(resolve => setTimeout(() => { resolve('timeout') }, 100)),
+    ])).resolves.toBe('disposed')
+    expect(terminate).toHaveBeenCalledOnce()
+    expect(terminateForHostExit).toHaveBeenCalledOnce()
+    expect(disposalErrors).toEqual([rangeFailure])
+    expect(process.listeners('exit')).not.toContain(listener)
+  })
+
   it('contains each host-exit termination failure and continues with the other targets', async () => {
     const before = new Set(process.listeners('exit'))
     const ctx = new Context()

+ 2 - 0
packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts

@@ -182,6 +182,8 @@ describe('managed process binding', () => {
     try {
       await expect(handle.done).rejects.toThrow('runner failed')
       expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
+      expect(wrapper.stdout?.destroyed).toBe(true)
+      expect(wrapper.stderr?.destroyed).toBe(true)
     } finally {
       wrapper.kill('SIGKILL')
     }

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

@@ -1,5 +1,5 @@
 import { spawn, spawnSync } from 'node:child_process'
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
@@ -138,6 +138,13 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => {
   })
 
   it('preserves missing-target and invalid-executable rejection errors', async () => {
+    const relativeExecutable = `relative-node-${String(Date.now())}.exe`
+    copyFileSync(process.execPath, join(scratch, relativeExecutable))
+    const relative = spec([relativeExecutable, '-e', 'process.exit(17)'])
+    const relativeHandle = bindManagedProcess(relative, launchWindowsJob(relative))
+    await expect(relativeHandle.done).resolves.toEqual({ exitCode: 17, signal: null })
+    await expect(relativeHandle.waitForExit()).resolves.toBe(true)
+
     const missing = spec([`missing-native-target-${Date.now()}.exe`])
     const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing))
     await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' })

+ 19 - 14
packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts

@@ -1,6 +1,7 @@
 import { spawn, spawnSync } from 'node:child_process'
 import type { ChildProcess } from 'node:child_process'
-import { existsSync, statSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
@@ -109,6 +110,23 @@ describe('spawn runner transport', () => {
     }
   })
 
+  it('unlinks a substituted runner-directory link without traversing it', () => {
+    const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
+    const outside = mkdtempSync(join(tmpdir(), 'dsh-runner-outside-'))
+    const sentinel = join(outside, 'events.ndjson')
+    writeFileSync(sentinel, 'keep')
+    rmSync(files.directory, { recursive: true, force: true })
+    symlinkSync(outside, files.directory, process.platform === 'win32' ? 'junction' : 'dir')
+    try {
+      cleanupRunnerFiles(files)
+      expect(existsSync(files.directory)).toBe(false)
+      expect(existsSync(sentinel)).toBe(true)
+    } finally {
+      rmSync(files.directory, { recursive: true, force: true })
+      rmSync(outside, { recursive: true, force: true })
+    }
+  })
+
   it.each([
     ['non-object request', null, 'no executable'],
     ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'],
@@ -270,19 +288,6 @@ describe('spawn runner transport', () => {
       cleanupRunnerFiles(missing)
     }
 
-    const forced = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} })
-    try {
-      appendRunnerEvent(forced.eventsPath, { type: 'started', pid: 789 })
-      const result = runnerDirectResult(
-        fakeChild(123),
-        forced,
-        Promise.resolve(),
-        () => ({ exitCode: null, signal: 'SIGKILL' }),
-      )
-      await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
-    } finally {
-      cleanupRunnerFiles(forced)
-    }
   })
 
   it('requires an event snapshot started after wrapper close before reporting a missing result', async () => {