Parcourir la source

fix(subprocess): tighten native containment settlement

pku-xht il y a 3 semaines
Parent
commit
ff6a49660f

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-28-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/architecture/2026-08-28-subprocess-native-containment.md
-2026-08-28-subprocess-native-containment.md: 84ddc8dd533123a90555b813f7a2b5d95b3a00a1
-2026-08-28-subprocess-native-containment.zh.md: fd7bbd362b7d4ef1cefb0c379728dcfda8366a67
+2026-08-28-subprocess-native-containment.md: ad9955f91ec00fc89e975d64ef99e37bcf5c4288
+2026-08-28-subprocess-native-containment.zh.md: cb3f65cdb65e77cae2d31cd337965acf0cd5c5e9

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md

@@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e
 
 The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor.
 
-Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
+Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion.
 
 The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants.
 
@@ -38,7 +38,7 @@ The parent permanently latches a validated numeric `target-exit` as soon as it a
 
 Source launches execute the package runner entry through the TypeScript source launcher, built launches resolve the `@deepseek-ai/dsh-subprocess-local/runner` export, and the Python SDK single-file executable enters through `@deepseek-ai/dsh`'s packaging-owned `runtime-bootstrap.js`. That bootstrap imports the public CLI when the private selector is absent; otherwise it removes the selector and dispatches to the same subprocess runner core. The public `dsh` argument parser has no hidden runner mode, and packaging ships no second Node executable.
 
-The selector is a per-spawn locator or sentinel, not a credential or persistent format. Linux uses one strict request plus one optional strict startup-error file. Windows uses one IPC channel with closed `start` and `terminate` requests and exactly two result branches: `target-exit` with a numeric `exitCode`, and `error` with required `name` and `message` plus only optional `code`, `syscall`, and `path`; the parent derives `signal: null`. Pre-commit cancellation also uses `error` with the private `DSH_SUBPROCESS_START_CANCELLED` code. The cancellation reason never crosses the wire, so the parent maps that code back to the first local reason exactly, including `null` or `undefined`. Missing, extra, mistyped, or unknown fields fail closed. Target environments may contain the selector name, including Windows case variants, because the provider transmits target state separately and restores it only after private selection is consumed.
+The selector is a per-spawn locator or sentinel, not a credential or persistent format. Linux uses one strict request plus one optional strict startup-error file. Windows uses one IPC channel with closed `start` and `terminate` requests and exactly two result branches: `target-exit` with a numeric `exitCode`, and `error` with required `name` and `message` plus only optional `code`, `syscall`, and `path`; the parent derives `signal: null`. Pre-commit cancellation uses the same ordinary `error` record. The cancellation reason never crosses the wire, so a parent cancellation latch restores its first local reason exactly, including `null` or `undefined`. Missing, extra, mistyped, or unknown fields fail closed. Target environments may contain the selector name, including Windows case variants, because the provider transmits target state separately and restores it only after private selection is consumed.
 
 ### Fallback and cleanup
 
@@ -54,8 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th
 
 ## Verification
 
-- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
-- Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, private coded start cancellation with raw local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, start delivery after runner spawn, empty-range settlement after pre-spawn failure, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
+- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including an exited launcher with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup.
+- Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup.
 - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics.
 - Native Windows tests prove suspended creation, Job assignment before resume, inherited stdio, default descendant inheritance, direct result, termination, active-process zero, abnormal/disconnected runner cleanup, kill-on-close, and synchronous host-exit termination. Source, built, and Python packaged smokes enter the same runner core.
 - Public seam types, local and E2B providers, LSP and subagent consumers, shell fixtures, READMEs, the Cordis catalog, and the keyless subprocess API snapshot contain no ordinary PID; terminal PID remains.

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md

@@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只
 
 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。
 
-request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
+request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。
 
 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。
 
@@ -38,7 +38,7 @@ parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久
 
 source 启动通过 TypeScript source launcher 执行包内 runner 入口,built 启动解析 `@deepseek-ai/dsh-subprocess-local/runner` export,Python SDK 单文件可执行程序则从 `@deepseek-ai/dsh` 由打包层拥有的 `runtime-bootstrap.js` 进入。私有 selector 不存在时,该 bootstrap 导入公共 CLI;否则会删除 selector,并分派到同一 subprocess runner core。公共 `dsh` 参数解析器没有隐藏 runner mode,打包也不提供第二个 Node 可执行程序。
 
-selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linux 使用一个严格 request 与一个可选严格 startup-error 文件。Windows 使用一条 IPC channel,承载闭集的 `start` 与 `terminate` request,以及恰好两个 result 分支:只含数字 `exitCode` 的 `target-exit`,以及必含 `name`、`message` 且只允许可选 `code`、`syscall`、`path` 的 `error`;parent 会派生 `signal: null`。提交前取消同样使用 `error`,并携带私有 `DSH_SUBPROCESS_START_CANCELLED` code。取消 reason 不跨 wire 传递,因此 parent 会把该 code 原样映射回第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。
+selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linux 使用一个严格 request 与一个可选严格 startup-error 文件。Windows 使用一条 IPC channel,承载闭集的 `start` 与 `terminate` request,以及恰好两个 result 分支:只含数字 `exitCode` 的 `target-exit`,以及必含 `name`、`message` 且只允许可选 `code`、`syscall`、`path` 的 `error`;parent 会派生 `signal: null`。提交前取消使用同一种普通 `error` record。取消 reason 不跨 wire 传递,因此 parent cancellation latch 会原样恢复第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。
 
 ### Fallback 与 cleanup
 
@@ -54,8 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu
 
 ## Verification
 
-- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
-- Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、带私有 code 的 start cancellation 与原样本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
+- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 launcher 退出且 request 未消费)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。
+- Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。
 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。
 - native Windows 测试证明 suspended creation、resume 前 Job assignment、继承 stdio、默认后代继承、direct result、termination、active-process zero、异常/disconnected runner cleanup、kill-on-close 与同步 host-exit termination。source、built 与 Python packaged 冒烟测试进入同一 runner core。
 - 公共 seam 类型、local 与 E2B provider、LSP 与 subagent 消费方、shell fixture、README、Cordis catalog 与 keyless subprocess API snapshot 都不包含普通 PID;terminal PID 保留。

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

@@ -192,19 +192,15 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
     const platform = this.internals.platform ?? process.platform
     let fallbackReason: string | undefined
     if (platform === 'linux') {
-      const deepProbe = this.internals.linuxNativeAvailable ?? probeLinuxNative
-      const managerProbe = this.internals.linuxManagerAvailable
-        ?? this.internals.linuxNativeAvailable
-        ?? probeLinuxManager
       const available = this.linuxDeepProbePassed
-        ? managerProbe()
-        : deepProbe()
+        ? probeLinuxManager()
+        : probeLinuxNative()
       if (available) this.linuxDeepProbePassed = true
       if (available) return 'linux-scope'
       fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable'
     }
     if (kind === 'ordinary' && platform === 'win32') {
-      const available = this.internals.windowsNativeAvailable?.() ?? probeWindowsJob()
+      const available = probeWindowsJob()
       if (available) return 'windows-job'
     }
     this.warnFallback(platform, kind, fallbackReason)

+ 11 - 5
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -38,7 +38,7 @@ export interface LinuxScopeInternals {
   resolveRunnerInvocation?: () => RunnerInvocation
   runnerAvailable?: (invocation: RunnerInvocation) => boolean
   loadLinuxExecve?: typeof loadLinuxExecve
-  sleep?: (delayMs: number) => Promise<void>
+  sleep?: (delayMs: number, signal?: AbortSignal) => Promise<void>
 }
 
 interface SystemctlResult {
@@ -84,6 +84,10 @@ function unitStem(prefix: string): string {
   return `${prefix}-${String(process.pid)}-${randomBytes(6).toString('hex')}`
 }
 
+function sleepWithAbort(delayMs: number, signal?: AbortSignal): Promise<void> {
+  return sleepMs(delayMs, undefined, { signal })
+}
+
 /**
  * Confirm this exact runner entry and libc execve binding without a probe mode.
  * @param internals - optional runner and libc-binding seams used by tests.
@@ -170,7 +174,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
     private readonly systemctl: string,
     private readonly runSync: typeof spawnSync,
     private readonly query: (command: string, args: readonly string[]) => Promise<SystemctlResult>,
-    private readonly sleep: (delayMs: number) => Promise<void>,
+    private readonly sleep: (delayMs: number, signal?: AbortSignal) => Promise<void>,
   ) {}
 
   signal(signal: 'SIGTERM' | 'SIGKILL'): void {
@@ -300,10 +304,12 @@ class SystemdScopeOwner implements BoundProcessOwner {
     if (generation !== this.wakeGeneration) return
     const wake = Promise.withResolvers<void>()
     const waiter = { generation, resolve: wake.resolve }
+    const sleepController = new AbortController()
     this.wakeWaiter = waiter
     try {
-      await Promise.race([this.sleep(delayMs), wake.promise])
+      await Promise.race([this.sleep(delayMs, sleepController.signal), wake.promise])
     } finally {
+      sleepController.abort()
       if (this.wakeWaiter === waiter) this.wakeWaiter = undefined
     }
   }
@@ -430,7 +436,7 @@ export function prepareLinuxTerminalScope(
       internals.systemctl ?? 'systemctl',
       internals.spawnSync ?? spawnSync,
       internals.systemctlQuery ?? querySystemctl,
-      internals.sleep ?? sleepMs,
+      internals.sleep ?? sleepWithAbort,
     ),
     resolveOutcome: (outcome) => {
       const startup = readLinuxStartupError(files.startupErrorPath)
@@ -485,7 +491,7 @@ export function launchLinuxScope(
     internals.systemctl ?? 'systemctl',
     internals.spawnSync ?? spawnSync,
     internals.systemctlQuery ?? querySystemctl,
-    internals.sleep ?? sleepMs,
+    internals.sleep ?? sleepWithAbort,
   )
   return {
     stdin: child.stdin,

+ 0 - 3
packages/subprocess/subprocess-local/src/runner-protocol.ts

@@ -31,9 +31,6 @@ export interface SerializedRunnerError {
   path?: string
 }
 
-/** Private error code used to map pre-commit Windows cancellation to the parent-local reason. */
-export const WINDOWS_START_CANCELLED_CODE = 'DSH_SUBPROCESS_START_CANCELLED' as const
-
 /** A Linux pre-exec failure published atomically beside its consumed request. */
 export type LinuxStartupError =
   { type: 'error'; error: SerializedRunnerError }

+ 11 - 32
packages/subprocess/subprocess-local/src/spawn-runner.ts

@@ -1,7 +1,6 @@
 /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */
 
 import { closeSync } from 'node:fs'
-import koffi from 'koffi'
 import {
   closeHandleChecked,
   isJobEmpty,
@@ -22,7 +21,6 @@ import {
   linuxLaunchFilesFromLocator,
   parseWindowsStartRequest,
   serializeRunnerError,
-  WINDOWS_START_CANCELLED_CODE,
   writeLinuxStartupError,
 } from './runner-protocol.ts'
 import type {
@@ -42,27 +40,6 @@ type RunnerHost = Pick<NodeJS.Process, 'env' | 'exitCode' | 'connected' | 'cwd'
   send?: NodeJS.Process['send']
 }
 
-interface UvErrorBindings {
-  translateSystemError(systemError: number): number
-  errorName(error: number): string
-}
-
-function loadUvErrorBindings(): UvErrorBindings {
-  const node = koffi.load(null)
-  return {
-    translateSystemError: node.func(
-      'uv_translate_sys_error',
-      'int',
-      ['int'],
-    ),
-    errorName: node.func(
-      'uv_err_name',
-      'str',
-      ['int'],
-    ),
-  }
-}
-
 /** Injectable operations used by the protocol-owner tests. */
 export interface SpawnRunnerInternals {
   execve(file: string, argv: string[], env: Record<string, string>): never
@@ -74,7 +51,6 @@ export interface SpawnRunnerInternals {
   isJobEmpty: typeof isJobEmpty
   terminateJob: typeof terminateJob
   closeHandleChecked: typeof closeHandleChecked
-  uvErrorBindings?: UvErrorBindings
 }
 
 const defaultInternals: SpawnRunnerInternals = {
@@ -90,7 +66,14 @@ const defaultInternals: SpawnRunnerInternals = {
   closeHandleChecked,
 }
 
-const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'EAGAIN', 'EMFILE', 'ENFILE', 'ENOENT'])
+const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'ENOENT'])
+const WINDOWS_SPAWN_ERROR_CODES = new Map<number, string>([
+  [2, 'ENOENT'],
+  [3, 'ENOENT'],
+  [5, 'EPERM'],
+  [193, 'EFTYPE'],
+  [740, 'EACCES'],
+])
 
 function nodeSpawnError(
   syscall: string,
@@ -110,7 +93,6 @@ function nodeSpawnError(
 function asSpawnError(
   error: unknown,
   program: string,
-  internals: Pick<SpawnRunnerInternals, 'uvErrorBindings'>,
 ): SerializedRunnerError {
   const serialized = serializeRunnerError(error)
   if (!(error instanceof Win32Error)) {
@@ -118,9 +100,7 @@ function asSpawnError(
       ? serialized
       : nodeSpawnError(`spawn ${program}`, serialized.code, program)
   }
-  const uv = internals.uvErrorBindings ?? loadUvErrorBindings()
-  const errno = uv.translateSystemError(error.win32Code)
-  const code = uv.errorName(errno)
+  const code = WINDOWS_SPAWN_ERROR_CODES.get(error.win32Code) ?? 'UNKNOWN'
   if (NODE_SPAWN_DETAIL_CODES.has(code)) {
     return nodeSpawnError(`spawn ${program}`, code, program)
   }
@@ -135,7 +115,6 @@ function windowsStartCancelledError(): SerializedRunnerError {
   return {
     name: 'Error',
     message: 'subprocess target start was cancelled',
-    code: WINDOWS_START_CANCELLED_CODE,
   }
 }
 
@@ -213,7 +192,7 @@ function runLinux(
   } catch (error) {
     writeLinuxStartupError(files, {
       type: 'error',
-      error: asSpawnError(error, argv[0] as string, internals),
+      error: asSpawnError(error, argv[0] as string),
     })
     host.exitCode = 127
   }
@@ -344,7 +323,7 @@ class WindowsJobRunner {
       if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') {
         await this.publishTerminalResult({
           type: 'error',
-          error: asSpawnError(error, this.argv[0] as string, this.internals),
+          error: asSpawnError(error, this.argv[0] as string),
         }, 0)
         return
       }

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

@@ -59,12 +59,6 @@ export interface SpawnInternals {
   platform?: NodeJS.Platform
   /** Linux process-group member probe (defaults to `/proc` inspection). */
   linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined
-  /** Test seam for the per-spawn Linux native prerequisite check. */
-  linuxNativeAvailable?: () => boolean
-  /** Test seam for the lightweight Linux user-manager reachability check. */
-  linuxManagerAvailable?: () => boolean
-  /** Test seam for the per-spawn Windows native prerequisite check. */
-  windowsNativeAvailable?: () => boolean
 }
 
 /**

+ 27 - 10
packages/subprocess/subprocess-local/src/terminal.ts

@@ -13,8 +13,28 @@ import type {
 import type { BoundProcessOwner } from './managed-owner.ts'
 import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
 
-function delay(ms: number): Promise<void> {
-  return new Promise(resolve => setTimeout(resolve, ms))
+function delay(ms: number, signal?: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    const finish = (): void => {
+      clearTimeout(timer)
+      signal?.removeEventListener('abort', finish)
+      resolve()
+    }
+    const timer = setTimeout(finish, ms)
+    signal?.addEventListener('abort', finish, { once: true })
+  })
+}
+
+async function raceWithDelay<T, U>(operation: Promise<T>, ms: number, timeout: U): Promise<T | U> {
+  const controller = new AbortController()
+  try {
+    return await Promise.race([
+      operation,
+      delay(ms, controller.signal).then(() => timeout),
+    ])
+  } finally {
+    controller.abort()
+  }
 }
 
 function signalName(number: number | undefined): NodeJS.Signals | null {
@@ -344,13 +364,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   private async closeManagedRange(owner: BoundProcessOwner): Promise<void> {
     owner.signal('SIGTERM')
     const observation = owner.waitForExit()
-    const first = await Promise.race([
-      observation.then(
-        () => ({ kind: 'stopped' as const }),
-        (error: unknown) => ({ kind: 'failed' as const, error }),
-      ),
-      delay(this.graceMs).then(() => ({ kind: 'timeout' as const })),
-    ])
+    const first = await raceWithDelay(observation.then(
+      () => ({ kind: 'stopped' as const }),
+      (error: unknown) => ({ kind: 'failed' as const, error }),
+    ), this.graceMs, { kind: 'timeout' as const })
     if (first.kind !== 'stopped') {
       owner.signal('SIGKILL')
       if (first.kind === 'failed') {
@@ -366,7 +383,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
       await observation
     }
     if (!this.exited) {
-      await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
+      await raceWithDelay(this.done.then(() => undefined), this.graceMs, undefined)
     }
     if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
   }

+ 4 - 9
packages/subprocess/subprocess-local/src/windows-job.ts

@@ -14,7 +14,6 @@ import {
   type WindowsRunnerResult,
   deserializeRunnerError,
   parseWindowsRunnerResult,
-  WINDOWS_START_CANCELLED_CODE,
 } from './runner-protocol.ts'
 import {
   runnerEnvironment,
@@ -81,7 +80,7 @@ class WindowsJobOwner implements BoundProcessOwner {
     this.terminationSent = true
     try {
       this.runner.send?.({ type: 'terminate' }, (error) => {
-        if (error === null || this.directResultType() === 'error') return
+        if (error === null || this.directResultType() !== undefined) return
         this.failInfrastructure(error)
         this.terminateForHostExit()
       })
@@ -91,10 +90,8 @@ class WindowsJobOwner implements BoundProcessOwner {
     }
   }
 
-  startCancellationReason(): unknown {
-    return this.cancellationReasonSet
-      ? this.cancellationReason
-      : new Error('subprocess target start was cancelled')
+  mapStartFailure(failure: unknown): unknown {
+    return this.cancellationReasonSet ? this.cancellationReason : failure
   }
 
   async waitForExit(): Promise<void> {
@@ -170,10 +167,8 @@ export function launchWindowsJob(
     directResultType = result.type
     if (result.type === 'target-exit') {
       direct.resolve({ exitCode: result.exitCode, signal: null })
-    } else if (result.error.code === WINDOWS_START_CANCELLED_CODE) {
-      direct.reject(owner.startCancellationReason())
     } else {
-      direct.reject(deserializeRunnerError(result.error))
+      direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error)))
     }
   })
   child.once('spawn', () => {

+ 6 - 3
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -303,9 +303,10 @@ describe('Linux scope establishment and quiescence', () => {
   it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => {
     const states = [activeUnit('reloading'), activeUnit('inactive')]
     const sleeping = Promise.withResolvers<undefined>()
-    const sleep = vi.fn(async () => {
+    const sleep = vi.fn(async (_delayMs: number, signal?: AbortSignal) => {
       sleeping.resolve(undefined)
-      await new Promise<void>(() => {})
+      if (signal === undefined) throw new Error('missing sleep cancellation signal')
+      await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
     })
     const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep })
     consumeLinuxLaunchRequest(launched.requestPath)
@@ -313,7 +314,9 @@ describe('Linux scope establishment and quiescence', () => {
     await sleeping.promise
     launched.result.owner.signal('SIGTERM')
     await expect(waiting).resolves.toBeUndefined()
-    expect(sleep).toHaveBeenCalledExactlyOnceWith(50)
+    expect(sleep).toHaveBeenCalledOnce()
+    expect(sleep.mock.calls[0]?.[0]).toBe(50)
+    expect(sleep.mock.calls[0]?.[1]?.aborted).toBe(true)
     expect(launched.spawnSync).toHaveBeenCalledOnce()
     launched.result.owner.cleanup?.()
   })

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

@@ -679,26 +679,6 @@ describe('LocalSubprocessRuntime', () => {
     }
   })
 
-  it('reports Linux capability failure through the real selector path', async () => {
-    const ctx = new Context()
-    const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    const fiber = await ctx.plugin(LocalSubprocessRuntime)
-    const runtime = ctx.subprocess as LocalSubprocessRuntime
-    runtime.internals = { platform: 'linux', linuxNativeAvailable: () => false }
-    try {
-      const select = (runtime as unknown as {
-        selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback'
-      }).selectContainmentMode.bind(runtime)
-      expect(select('terminal')).toBe('fallback')
-      expect(warning).toHaveBeenCalledWith(expect.stringContaining(
-        'the current user-systemd scope or private bootstrap is unavailable',
-      ))
-    } finally {
-      warning.mockRestore()
-      await fiber.dispose()
-    }
-  })
-
   it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => {
     const linuxLaunch = { kind: 'linux' }
     const windowsLaunch = { kind: 'windows' }

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

@@ -30,7 +30,6 @@ import {
   parseWindowsStartRequest,
   readLinuxStartupError,
   serializeRunnerError,
-  WINDOWS_START_CANCELLED_CODE,
   writeLinuxStartupError,
 } from '../src/runner-protocol.ts'
 import {
@@ -106,10 +105,6 @@ function internals(overrides: Partial<SpawnRunnerInternals> = {}): SpawnRunnerIn
     isJobEmpty: vi.fn(() => true),
     terminateJob: vi.fn(),
     closeHandleChecked: vi.fn(),
-    uvErrorBindings: {
-      translateSystemError: vi.fn(systemError => systemError === 2 ? -4058 : -4094),
-      errorName: vi.fn(error => error === -4058 ? 'ENOENT' : 'UNKNOWN'),
-    },
     ...overrides,
   }
 }
@@ -608,26 +603,19 @@ describe('Windows Job runner protocol owner', () => {
     expect(host.exitCode).toBe(0)
   })
 
-  it('uses libuv translation and Node detail-bearing codes for Win32 process-creation errors', async () => {
-    for (const [win32Code, code, errno, enriched, program] of [
-      [2, 'ENOENT', -4058, true, 'tool.exe'],
-      [740, 'EACCES', -4092, true, '$&.exe'],
-      [10035, 'EAGAIN', -4088, true, 'tool.exe'],
-      [4, 'EMFILE', -4066, true, 'tool.exe'],
-      [12345, 'ENFILE', -4061, true, 'tool.exe'],
-      [5, 'EPERM', -4048, false, 'tool.exe'],
-      [193, 'EFTYPE', -4028, false, 'tool.exe'],
-      [999, 'UNKNOWN', -4094, false, 'tool.exe'],
+  it('maps only the promised Win32 process-creation error subset', async () => {
+    for (const [win32Code, code, enriched, program] of [
+      [2, 'ENOENT', true, 'tool.exe'],
+      [3, 'ENOENT', true, 'tool.exe'],
+      [740, 'EACCES', true, '$&.exe'],
+      [5, 'EPERM', false, 'tool.exe'],
+      [193, 'EFTYPE', false, 'tool.exe'],
+      [4, 'UNKNOWN', false, 'tool.exe'],
     ] as const) {
       const host = new FakeRunnerHost()
-      const translateSystemError = vi.fn(() => errno)
-      const errorName = vi.fn(() => code)
       await runWindows(host, internals({
         spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }),
-        uvErrorBindings: { translateSystemError, errorName },
       }), undefined, [program, 'literal arg'])
-      expect(translateSystemError).toHaveBeenCalledExactlyOnceWith(win32Code)
-      expect(errorName).toHaveBeenCalledExactlyOnceWith(errno)
       const syscall = enriched ? `spawn ${program}` : 'spawn'
       expect(host.sent).toMatchObject([{
         type: 'error',
@@ -648,47 +636,6 @@ describe('Windows Job runner protocol owner', () => {
     }
   })
 
-  it('loads the error translation functions from Node-linked libuv', async () => {
-    const host = new FakeRunnerHost()
-    const native = internals({
-      spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }),
-    })
-    Reflect.deleteProperty(native, 'uvErrorBindings')
-    await runWindows(host, native)
-    expect(host.sent).toMatchObject([{
-      type: 'error',
-      error: {
-        code: 'ENOENT',
-        path: 'tool.exe',
-      },
-    }])
-  })
-
-  it.skipIf(process.platform !== 'win32')('preserves native EMFILE and UNKNOWN translations', async () => {
-    for (const [win32Code, code, enriched] of [
-      [4, 'EMFILE', true],
-      [999, 'UNKNOWN', false],
-    ] as const) {
-      const host = new FakeRunnerHost()
-      const native = internals({
-        spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }),
-      })
-      Reflect.deleteProperty(native, 'uvErrorBindings')
-      await runWindows(host, native)
-      expect(host.sent).toMatchObject([{
-        type: 'error',
-        error: { code },
-      }])
-      const result = parseWindowsRunnerResult(host.sent[0])
-      if (result.type !== 'error') throw new Error('expected runner error')
-      if (enriched) {
-        expect(result.error).toMatchObject({ path: 'tool.exe' })
-      } else {
-        expect(result.error).not.toHaveProperty('path')
-      }
-    }
-  })
-
   it('rejects a Windows runner without an initial IPC channel', async () => {
     const disconnected = new FakeRunnerHost()
     disconnected.connected = false
@@ -790,7 +737,6 @@ describe('Windows Job runner protocol owner', () => {
       error: {
         name: 'Error',
         message: 'subprocess target start was cancelled',
-        code: WINDOWS_START_CANCELLED_CODE,
       },
     }])
     expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()
@@ -808,7 +754,6 @@ describe('Windows Job runner protocol owner', () => {
       error: {
         name: 'Error',
         message: 'subprocess target start was cancelled',
-        code: WINDOWS_START_CANCELLED_CODE,
       },
     }])
     expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled()

+ 28 - 0
packages/subprocess/subprocess-local/tests/terminal.spec.ts

@@ -115,6 +115,7 @@ function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number):
 
 describe('LocalTerminalHandle', () => {
   it('terminates a managed range with TERM when it stops within the grace period', async () => {
+    vi.useFakeTimers()
     const pty = new FakePty()
     const inspector = new FakeInspector()
     const stopped = Promise.withResolvers<undefined>()
@@ -136,6 +137,33 @@ describe('LocalTerminalHandle', () => {
 
     expect(signals).toEqual(['SIGTERM'])
     await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
+    expect(vi.getTimerCount()).toBe(0)
+  })
+
+  it('cancels the terminal-exit grace when the pty exits first', async () => {
+    vi.useFakeTimers()
+    const pty = new FakePty()
+    const stopped = Promise.withResolvers<undefined>()
+    const signals: Array<'SIGTERM' | 'SIGKILL'> = []
+    const owner: BoundProcessOwner = {
+      signal(signal) {
+        signals.push(signal)
+        if (signal === 'SIGTERM') {
+          stopped.resolve(undefined)
+          setTimeout(() => { pty.emitExit(0, 15) }, 1)
+        }
+      },
+      waitForExit: () => stopped.promise,
+      terminateForHostExit: vi.fn(),
+    }
+    const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner)
+
+    const terminating = handle.terminate()
+    await vi.advanceTimersByTimeAsync(1)
+    await terminating
+
+    expect(signals).toEqual(['SIGTERM'])
+    expect(vi.getTimerCount()).toBe(0)
   })
 
   it('escalates a managed range to KILL after the TERM grace expires', async () => {

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

@@ -7,7 +7,6 @@ import {
   probeWindowsJob,
 } from '../src/windows-job.ts'
 import { bindManagedProcess } from '../src/spawn.ts'
-import { WINDOWS_START_CANCELLED_CODE } from '../src/runner-protocol.ts'
 
 class FakeChild extends EventEmitter {
   pid: number | undefined = 432
@@ -214,7 +213,7 @@ describe('Windows parent runner contract', () => {
     await expect(handle.waitForExit()).rejects.toThrow('exit code 127')
   })
 
-  it('maps error and preserves raw start-cancellation reasons', async () => {
+  it('maps errors and restores raw start-cancellation reasons from the parent latch', async () => {
     const spawned = launch()
     spawned.child.emit('message', {
       type: 'error', error: { name: 'Error', message: 'missing', code: 'ENOENT' },
@@ -231,7 +230,7 @@ describe('Windows parent runner contract', () => {
     cancelled.child.emit('message', {
       type: 'error',
       error: {
-        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+        name: 'Error', message: 'subprocess target start was cancelled',
       },
     })
     await expect(cancelled.result.direct).rejects.toBe(reason)
@@ -245,7 +244,7 @@ describe('Windows parent runner contract', () => {
     nullCancelled.child.emit('message', {
       type: 'error',
       error: {
-        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+        name: 'Error', message: 'subprocess target start was cancelled',
       },
     })
     await expect(nullCancelled.result.direct).rejects.toBeNull()
@@ -254,13 +253,14 @@ describe('Windows parent runner contract', () => {
     await expect(nullCancelled.result.owner.waitForExit()).resolves.toBeUndefined()
 
     const implicit = launch()
+    implicit.result.owner.signal('SIGTERM')
     implicit.child.emit('message', {
       type: 'error',
       error: {
-        name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE,
+        name: 'Error', message: 'subprocess target start was cancelled',
       },
     })
-    await expect(implicit.result.direct).rejects.toThrow('target start was cancelled')
+    await expect(implicit.result.direct).rejects.toBeUndefined()
     implicit.child.connected = false
     implicit.child.emit('close', 0, null)
     await expect(implicit.result.owner.waitForExit()).resolves.toBeUndefined()
@@ -381,7 +381,7 @@ describe('Windows parent runner contract', () => {
     await expect(handle.waitForExit()).resolves.toBe(true)
   })
 
-  it('preserves a direct result but rejects range settlement when termination delivery later fails', async () => {
+  it('accepts clean range settlement when a target result races redundant termination delivery', async () => {
     const child = new FakeChild()
     const launched = launch(child)
     const handle = bindManagedProcess(spec, launched.result)
@@ -398,9 +398,11 @@ describe('Windows parent runner contract', () => {
     expect(child.connected).toBe(true)
     child.deliverNextSend(new Error('late EPIPE'))
     await Promise.resolve()
-    expect(child.killed).toEqual(['SIGKILL'])
+    expect(child.killed).toEqual([])
+    child.connected = false
+    child.emit('close', 0, null)
     await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null })
-    await expect(handle.waitForExit()).rejects.toThrow('late EPIPE')
+    await expect(handle.waitForExit()).resolves.toBe(true)
   })
 
   it('uses synchronous runner termination for host exit and isolates repeated control', () => {

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
snapshots/session/cordis-inspect-jsdoc/session.jsonl


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff