From 894f6aeb8ad455308465354ce9474e8f287a322d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 16:26:19 +0800 Subject: [PATCH 001/110] fix(subprocess): contain escaped descendants with native owners --- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.md | 2 +- .../2026-07-26-subprocess-seam.zh.md | 2 +- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 10 +- ...8-19-shared-win32-process-primitives.zh.md | 10 +- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 6 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 6 +- ...20-subprocess-native-containment.i18n.yaml | 6 + ...026-08-20-subprocess-native-containment.md | 37 +++ ...-08-20-subprocess-native-containment.zh.md | 37 +++ docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 11 +- docs/subsystems/subprocess.zh.md | 11 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 12 +- .../subprocess/subprocess-local/README.zh.md | 12 +- .../subprocess/subprocess-local/package.json | 8 + .../subprocess/subprocess-local/src/index.ts | 43 ++- .../subprocess-local/src/linux-scope.ts | 172 +++++++++++ .../subprocess-local/src/managed-owner.ts | 53 ++++ .../subprocess-local/src/runner-launch.ts | 147 +++++++++ .../subprocess-local/src/runner-protocol.ts | 166 +++++++++++ .../subprocess-local/src/spawn-runner.ts | 195 ++++++++++++ .../subprocess/subprocess-local/src/spawn.ts | 281 ++++++++++-------- .../subprocess-local/src/windows-job.ts | 105 +++++++ .../tests/fixtures/fake-job-runner.ts | 25 ++ .../tests/linux-scope.spec.ts | 113 +++++++ .../subprocess-local/tests/local.spec.ts | 21 ++ .../tests/managed-spawn.spec.ts | 82 +++++ .../tests/native-containment.e2e.ts | 106 +++++++ .../tests/native-windows.e2e.ts | 125 ++++++++ .../tests/spawn-runner.spec.ts | 143 +++++++++ .../tests/windows-job.spec.ts | 50 ++++ .../subprocess/subprocess-local/tsconfig.json | 3 + .../subprocess-local/tsdown.config.ts | 16 + packages/subprocess/subprocess/src/types.ts | 7 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 11 +- .../subprocess/win32-process/README.zh.md | 11 +- .../subprocess/win32-process/package.json | 2 +- packages/subprocess/win32-process/src/abi.ts | 2 + packages/subprocess/win32-process/src/ffi.ts | 26 ++ .../subprocess/win32-process/src/index.ts | 7 + .../subprocess/win32-process/src/process.ts | 144 +++++++-- .../tests/ordinary-process.spec.ts | 124 ++++++++ .../win32-process/verify/abi-probe.cpp | 2 + pnpm-lock.yaml | 3 + 52 files changed, 2165 insertions(+), 222 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md create mode 100644 packages/subprocess/subprocess-local/src/linux-scope.ts create mode 100644 packages/subprocess/subprocess-local/src/managed-owner.ts create mode 100644 packages/subprocess/subprocess-local/src/runner-launch.ts create mode 100644 packages/subprocess/subprocess-local/src/runner-protocol.ts create mode 100644 packages/subprocess/subprocess-local/src/spawn-runner.ts create mode 100644 packages/subprocess/subprocess-local/src/windows-job.ts create mode 100644 packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts create mode 100644 packages/subprocess/subprocess-local/tests/linux-scope.spec.ts create mode 100644 packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts create mode 100644 packages/subprocess/subprocess-local/tests/native-containment.e2e.ts create mode 100644 packages/subprocess/subprocess-local/tests/native-windows.e2e.ts create mode 100644 packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-job.spec.ts create mode 100644 packages/subprocess/subprocess-local/tsdown.config.ts create mode 100644 packages/subprocess/win32-process/tests/ordinary-process.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 8422759a46..c49351fcd3 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: aa7a989a0321760c9bc278df8356a57f4dd0d459 -2026-07-26-subprocess-seam.zh.md: 22de6774b866b9b7a0248dc7b06587f7f3235fdc +2026-07-26-subprocess-seam.md: 92d36bf6d522ab939ef6c0de1063e0accea2946f +2026-07-26-subprocess-seam.zh.md: 74072b640cdb3286099c3b15bc15fe11cdb90263 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index aa7a989a03..92d36bf6d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -13,7 +13,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: - **`@deepseek-ai/dsh-subprocess` (Service Definition)** — the abstract `SubprocessRuntime` owning `ctx.subprocess`: executable lookup, fully explicit ordinary spawns, and the terminal primitive added by the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md). Each stdio stream independently selects `'pipe'`, `'inherit'`, or bounded collection `{ maxBytes, spill? }`; stdin selects `'ignore'`, `'pipe'`, or `{ data }`. `SubprocessOutcome` carries exit facts with deliberately no timeout/cancel classification, while collected output remains on the handle after settlement. The Service Definition also owns process and terminal handles, the shared scrub, and `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`; `argv` is never shell-interpreted. -- **`@deepseek-ai/dsh-subprocess-local` (Service Provider)** — `LocalSubprocessRuntime` over the former `run.ts` plumbing (`spawn.ts`) plus `node-pty`: detached groups, bounded collection and private spill files, executable lookup, foreground/session inspection, and disposal that terminates and joins every managed process. `terminate()` owns TERM→grace→KILL for the tree, `waitForExit()` observes tree liveness, and injected `taskkill /T` covers Windows. Ordinary and terminal spawns apply the Service Definition's case-insensitive `KEY`/`PASSWORD`/`SECRET`/`TOKEN` scrub before explicit env. The provider has no config; every limit arrives on the spec, while Bash and PTY presentation environment overrides stay in their Consumers. +- **`@deepseek-ai/dsh-subprocess-local` (Service Provider)** — `LocalSubprocessRuntime` over the former `run.ts` plumbing (`spawn.ts`) plus `node-pty`: bounded collection and private spill files, executable lookup, foreground/session inspection, and disposal that terminates and joins every managed process. Ordinary Linux commands use a user-systemd scope when the host can preserve literal argv and read scope state; ordinary Windows commands start suspended in a kill-on-close Job. `terminate()` and `waitForExit()` use that same OS range, while `.done` remains the direct command result. Unsupported hosts retain the disclosed PGID or `taskkill /T` fallback. Ordinary and terminal spawns apply the Service Definition's case-insensitive `KEY`/`PASSWORD`/`SECRET`/`TOKEN` scrub before explicit env. The provider has no config; every limit arrives on the spec, while Bash and PTY presentation environment overrides stay in their Consumers. - **`dsh-bash-local` (Consumer)** — `inject: ['subprocess']`; maps each resolved `ShellExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. - **`dsh-shell` (Service Definition)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash Consumer changes an import; `ShellExecRequest`/`ShellExecSpec`/`ShellProcess` and the sandbox facts remain bash-owned. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 22de6774b8..74072b640c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -13,7 +13,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: - **`@deepseek-ai/dsh-subprocess`(Service Definition)**——拥有 `ctx.subprocess` 的抽象 `SubprocessRuntime`:可执行文件查找、完全显式的普通 spawn,以及[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)新增的终端原语。每条 stdio 流独立选择 `'pipe'`、`'inherit'` 或有界收集 `{ maxBytes, spill? }`;stdin 选择 `'ignore'`、`'pipe'` 或 `{ data }`。`SubprocessOutcome` 只承载刻意不含超时/取消分类的退出事实,收集输出在结算后仍留在句柄上。该 Service Definition 还拥有进程与终端句柄、共享凭据清除,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`;`argv` 绝不经过 shell 解释。 -- **`@deepseek-ai/dsh-subprocess-local`(Service Provider)**——`LocalSubprocessRuntime` 构建在原 `run.ts` 管道(现为 `spawn.ts`)与 `node-pty` 之上:detached 进程组、有界收集与私有 spill 文件、可执行文件查找、前台/会话检查,以及终止每个受管进程并等待其退出的 dispose。`terminate()` 拥有面向进程树的 TERM→宽限→KILL,`waitForExit()` 观察进程树存活性,可注入的 `taskkill /T` 覆盖 Windows。普通与终端 spawn 都先应用 Service Definition 对 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 不区分大小写的清除,再合并显式 env。该 Service Provider 没有配置;每项限制都随 spec 到达,Bash 与 PTY 的呈现环境覆盖仍归各自 Consumer 所有。 +- **`@deepseek-ai/dsh-subprocess-local`(Service Provider)**——`LocalSubprocessRuntime` 构建在原 `run.ts` 管道(现为 `spawn.ts`)与 `node-pty` 之上:有界收集与私有 spill 文件、可执行文件查找、前台/会话检查,以及终止每个受管进程并等待其退出的 dispose。普通 Linux 命令在宿主能保留 literal argv 并读取 scope 状态时使用 user-systemd scope;普通 Windows 命令以 suspended 状态进入 kill-on-close Job。`terminate()` 与 `waitForExit()` 使用同一 OS range,而 `.done` 仍是 direct command result。不支持的宿主保留已披露的 PGID 或 `taskkill /T` fallback。普通与终端 spawn 都先应用 Service Definition 对 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 不区分大小写的清除,再合并显式 env。该 Service Provider 没有配置;每项限制都随 spec 到达,Bash 与 PTY 的呈现环境覆盖仍归各自 Consumer 所有。 - **`dsh-bash-local`(Consumer)**——`inject: ['subprocess']`;把每个解析后的 `ShellExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-shell`(Service Definition)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash Consumer 需要改动导入;`ShellExecRequest`/`ShellExecSpec`/`ShellProcess` 与沙箱事实仍归 bash 所有。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 780aa7e236..b3aae39a5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 8765e5f7350dab56ad42169f6e16b55679ca8982 -2026-08-19-shared-win32-process-primitives.zh.md: b21ece8445e8863c08818c42d6c9bf7672813823 +2026-08-19-shared-win32-process-primitives.md: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0 +2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 8765e5f735..a3ab8ebcfa 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, polling, termination, and handle operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation starts the target suspended, assigns it to the kill-on-close Job, and resumes it only after assignment, so target code cannot run outside the Job. Assignment failure terminates the suspended target before releasing its handles; resume failure closes the assigned Job. The sandbox retains its existing pipe-drain, direct-wait, result, and returned-Job lifecycle. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately and closes the Job only after it is empty. -The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. +The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended creation followed by Job assignment and resume, wait and exit-code reads, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the migrated ABI and native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. -**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused `CreateProcessW`, application-name, parent-stdio, and Job-settlement APIs would freeze speculative obligations and enlarge the failure matrix. +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, polling, and Job controls were added only with their runner consumer. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index b21ece8445..e64f7537cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、polling、termination 与 handle 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。分配失败会先终止 suspended target 再释放句柄;恢复失败会关闭已经分配的 Job。sandbox 保留既有 pipe-drain、direct-wait、result 与返回 Job 的生命周期。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,并只在 Job 为空后关闭它。 -该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 +该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、suspended 创建后的 Job 分配与恢复、wait 与 exit-code 读取、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖迁移后的 ABI 和 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 -**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的 `CreateProcessW`、application-name、parent-stdio 与 Job-settlement API 会冻结推测性义务,并扩大失败矩阵。 +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、polling 与 Job control 只随实际 runner consumer 一起加入。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 9093df381b..4149912ac7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 70b2a95dda22e1e2bf12d76acf9a471a59c7e73b -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: ea0b27583b44926d761a42de34d575800f8a8a13 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 517b7e82f00ea16ec6d9f8731be67963a46035da +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: b80a3872b2d85b997fd48a82e4637d2c9d00013b diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 70b2a95dda..517b7e82f0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -16,7 +16,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: -- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID /T /F` on Windows. +- An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. @@ -32,7 +32,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. -Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. +Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. ## Alternatives considered @@ -48,4 +48,4 @@ Unit evidence pins synchronous POSIX group and Windows taskkill delivery, termin Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. -The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726. +The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it. Native ordinary-process ownership is described by the [containment decision](2026-08-20-subprocess-native-containment.md); PTY ownership remains a separate boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index ea0b27583b..b80a3872b2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -16,7 +16,7 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: -- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID /T /F`。 +- 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 @@ -32,7 +32,7 @@ Status: implemented 父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 -单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 +单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 ## Alternatives considered @@ -48,4 +48,4 @@ Status: implemented 每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 -listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。 +listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代。native ordinary-process ownership 由[containment decision](2026-08-20-subprocess-native-containment.md)说明;PTY ownership 仍是独立边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml new file mode 100644 index 0000000000..885a4dbca3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: bbe36a39580316e7bee34e6e22321d6eb46c9580 +2026-08-20-subprocess-native-containment.zh.md: 9b882f6369d3c7ebc1a2595fc23fbd37cc9eec6c diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md new file mode 100644 index 0000000000..bbe36a3958 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -0,0 +1,37 @@ +# Agent Note: Ordinary subprocesses use native managed ranges where supported + +Status: implemented + +English | [中文](2026-08-20-subprocess-native-containment.zh.md) + +## Problem + +The local subprocess provider treated a POSIX process group or a Windows direct-parent tree as the managed range. A descendant could call `setsid`, double-fork, or outlive the direct parent, so `terminate()` could miss work that `waitForExit()` had already declared gone. The direct command result and the complete managed range are different lifecycle facts and must not be collapsed into one wrapper exit code. + +## Decision + +`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. + +The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.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. 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, while the runner remains until both the direct result is reported and the Job is empty. 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. + +## Verification + +Linux native evidence covers a real `setsid` descendant and a double-fork daemon whose direct parent exits first. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, Node-shaped spawn failures, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries. + +## Alternatives considered + +**Scan the process table for escaped descendants.** Rejected because parent and PID snapshots do not provide a persistent ownership fact and can follow PID reuse. + +**Expose a public backend selector or generic launch framework.** Rejected because callers need one subprocess contract, while systemd and Job creation have different launch mechanics. Only the signal/wait owner is common. + +**Support legacy systemd argument expansion.** Rejected because shell-style expansion can change user argv. Hosts without the literal-argument option use the disclosed fallback. + +**Use private macOS coalition APIs.** Rejected because no supported public owner gives the required membership and settlement contract. + +## Consequences + +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md new file mode 100644 index 0000000000..9b882f6369 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Ordinary subprocesses use native managed ranges where supported + +Status: implemented + +[English](2026-08-20-subprocess-native-containment.md) | 中文 + +## Problem + +本地 subprocess provider 把 POSIX 进程组或 Windows direct-parent tree 当作 managed range。descendant 可以调用 `setsid`、double-fork 或活得比 direct parent 更久,导致 `terminate()` 漏掉的工作已经被 `waitForExit()` 宣布消失。direct command result 与完整 managed range 是不同的生命周期事实,不能压成一个 wrapper exit code。 + +## Decision + +`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 + +common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.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。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 Job 为空。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 重放。 + +## Verification + +Linux native 证据覆盖真实 `setsid` descendant,以及 direct parent 先退出的 double-fork daemon。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、Node-shaped spawn failure、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。 + +## Alternatives considered + +**扫描进程表寻找 escaped descendant。** 拒绝,因为 parent 与 PID snapshot 不提供持续所有权事实,还可能跟随 PID reuse。 + +**暴露公共 backend selector 或通用 launch framework。** 拒绝,因为调用方只需要一个 subprocess contract,而 systemd 与 Job creation 具有不同 launch mechanics;只有 signal/wait owner 是共同部分。 + +**支持 legacy systemd argument expansion。** 拒绝,因为 shell-style expansion 会改变 user argv;缺少 literal-argument option 的宿主使用已披露的 fallback。 + +**使用 private macOS coalition API。** 拒绝,因为没有受支持的公开 owner 能提供所需 membership 与 settlement contract。 + +## Consequences + +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index d063134567..d5151f85d2 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: af6770e8c04f79ab1981967d5a3bca5a39b954be -subprocess.zh.md: 19f20e7fcd675d87cf768258d1ab9c37bf80a836 +subprocess.md: f3cd7d21b75f66bd4f4306c80ab8bb98d01a3bc8 +subprocess.zh.md: f4c6ee66d659781f19fe59cf1b9be5c635f0a48e diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index af6770e8c0..f3cd7d21b7 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -129,19 +129,18 @@ interface SubprocessSpawnSpec { } ``` -## Handles: streams, readers, and tree-scoped termination +## Handles: streams, readers, and managed-range termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. * - * Termination is tree-scoped everywhere: POSIX signals the detached process - * group (falling back to the direct child when the group is gone), Windows - * terminates the tree via `taskkill /T`, so helper processes cannot outlive - * the handle unnoticed. + * Termination and {@link SubprocessHandle.waitForExit} use the same managed + * range. Supported Linux and Windows hosts use an OS-owned scope or Job; + * weaker platform fallbacks are disclosed by the provider. */ interface SubprocessHandle { /** Process id (tree root); -1 when the spawn itself failed. */ diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 19f20e7fcd..f4c6ee66d6 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -129,19 +129,18 @@ interface SubprocessSpawnSpec { } ``` -## 句柄:流、读取器与以进程树为范围的终止 +## 句柄:流、读取器与 managed-range 终止 -spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树。这足以让消费方构建自己的分级清理流程;ACP 后端的 `disposeAcpChild` 会先关闭 stdin,让子进程收到 EOF,是仓库内的参考实现。 +spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 ```ts type-equiv /** * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. * - * Termination is tree-scoped everywhere: POSIX signals the detached process - * group (falling back to the direct child when the group is gone), Windows - * terminates the tree via `taskkill /T`, so helper processes cannot outlive - * the handle unnoticed. + * Termination and {@link SubprocessHandle.waitForExit} use the same managed + * range. Supported Linux and Windows hosts use an OS-owned scope or Job; + * weaker platform fallbacks are disclosed by the provider. */ interface SubprocessHandle { /** Process id (tree root); -1 when the spawn itself failed. */ diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 32344c63b8..3b0ecc7b1b 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: 790db2c3c82fc9e359cae6a0ff1eab156b2776b5 -README.zh.md: e6ac837e0c0408720d46609edf154d423a96c11b +README.md: 1b516c36d81a51fc2e0023d69f748cb4b46f36a4 +README.zh.md: 16c24c55db6aefd9f7caf3eb43e8bdc3a867ab65 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 790db2c3c8..1b516c36d8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -8,7 +8,7 @@ The shared process substrate for one execution world: executable lookup, fully-s |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for restricted process creation, inherited/anonymous-pipe stdio, suspended Job assignment, waits, and handle cleanup | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index e6ac837e0c..16c24c55db 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:restricted process creation、继承/匿名管道 stdio、suspended Job 分配、wait 与句柄清理的唯一 Koffi owner | +| [`win32-process`](win32-process/README.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名管道 stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 6b02932a92..e56503f816 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 0935bb309bd10dec7503a74708a28442223bf296 -README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d +README.md: d7986dbb3ef436cc572090fe89b8a2ee62916f0d +README.zh.md: 5aa32a4a2d4950b16d329a174d4995799e9ba151 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 0935bb309b..d7986dbb3e 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -2,18 +2,19 @@ English | [中文](README.zh.md) -Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, spawns ordinary detached process trees with explicit stdio, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)). +Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, gives ordinary Linux and Windows commands an OS-owned managed range when the host supports it, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)). ## Behavior -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F`. `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after the same scope or Job is empty. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and still-open collected pipes retain the existing bounded drain grace. +- **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. -- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -25,11 +26,12 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. +- **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. +- **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. -The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring. +Common process handling lives in `src/spawn.ts`; Linux scopes, Windows Jobs, and the private runner live in their platform modules; `src/index.ts` owns selection and service wiring. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e2e6c67e4d..5aa32a4a2d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -2,18 +2,19 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md))。 +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,在宿主支持时为普通 Linux 与 Windows 命令建立 OS-owned managed range,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md))。 ## 行为 -- **以适合平台的方式发送信号的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;重新指定父进程并脱离该组的 daemon 仍可能存活。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL,`waitForExit()` 只在同一 scope 或 Job 为空后成功。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;仍打开的 collected pipe 保留既有有界排空宽限期。 +- **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 ## 模型体验 @@ -25,11 +26,12 @@ ## 已知限制与暂缓事项 -- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 +- **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 +- **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 -原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。 +common process handling 位于 `src/spawn.ts`;Linux scope、Windows Job 与 private runner 位于各自平台模块;`src/index.ts` 拥有选择与 service wiring。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 86db53e276..04913db865 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./spawn-runner": { + "types": "./lib/types/spawn-runner.d.ts", + "default": "./lib/spawn-runner.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -28,6 +32,8 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/spawn-runner.js", + "lib/runner-protocol-*.js", "scripts/ensure-spawn-helper.mjs", "lib/types/**/*.d.ts" ], @@ -42,6 +48,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-win32-process": "workspace:^", "koffi": "^3.1.0", "node-pty": "1.2.0-beta.15" }, @@ -50,6 +57,7 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-win32-process": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 261f614fba..0ddbcfec31 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -21,8 +21,10 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -import { childEnv, spawnSubprocess } from './spawn.ts' +import { bindManagedProcess, childEnv, spawnSubprocess, validateSubprocessSpec } from './spawn.ts' import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' +import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts' +import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -41,6 +43,9 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} + /** Ordinary native containment mode, selected once before its first user command. */ + private ordinaryMode: 'linux-scope' | 'windows-job' | 'fallback' | undefined + private fallbackWarned = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -144,7 +149,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } spawn(spec: SubprocessSpawnSpec): SubprocessHandle { - const handle = spawnSubprocess(spec, this.internals) + validateSubprocessSpec(spec) + const mode = this.selectOrdinaryMode() + const handle = mode === 'linux-scope' + ? bindManagedProcess(spec, launchLinuxScope(spec), this.internals) + : mode === 'windows-job' + ? bindManagedProcess(spec, launchWindowsJob(spec), this.internals) + : spawnSubprocess(spec, this.internals) this.live.add(handle) // Release ownership only once the whole TREE is gone, not at direct-child // settlement — a TERM-trapping helper that outlives the leader must stay @@ -152,10 +163,36 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { // case waitForExit resolves immediately after settlement. const release = (): Promise => handle.waitForExit().then(() => { this.live.delete(handle) }) - handle.done.then(release, release) + void handle.done.then(release, release).catch(() => {}) return handle } + private selectOrdinaryMode(): 'linux-scope' | 'windows-job' | 'fallback' { + if (this.ordinaryMode !== undefined) return this.ordinaryMode + const platform = this.internals.platform ?? process.platform + if (platform === 'linux' && probeLinuxScope()) this.ordinaryMode = 'linux-scope' + else if (platform === 'win32' && probeWindowsJob()) this.ordinaryMode = 'windows-job' + else this.ordinaryMode = 'fallback' + if (this.ordinaryMode === 'fallback') this.warnFallback(platform) + return this.ordinaryMode + } + + private warnFallback(platform: NodeJS.Platform): void { + if (this.fallbackWarned) return + this.fallbackWarned = true + const reason = platform === 'darwin' + ? 'macOS has no supported persistent process-range owner' + : platform === 'linux' + ? 'a modern readable user-systemd scope is unavailable' + : platform === 'win32' + ? 'the Win32 Job runner is unavailable' + : `platform ${platform} has no native managed range` + process.emitWarning( + `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`, + { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, + ) + } + // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts new file mode 100644 index 0000000000..10b9501bba --- /dev/null +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -0,0 +1,172 @@ +/** Linux user-systemd scope launch and managed-range ownership. */ + +import { randomBytes } from 'node:crypto' +import { spawn, spawnSync } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { setTimeout as sleepMs } from 'node:timers/promises' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' +import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { childEnv } from './spawn.ts' +import { + cleanupAfterRunner, + runnerDirectResult, + runnerFiles, + runnerStdio, + spawnRunnerInvocation, +} from './runner-launch.ts' + +/** Test seams for systemd command execution. */ +export interface LinuxScopeInternals { + spawn?: typeof spawn + spawnSync?: typeof spawnSync + systemdRun?: string + systemctl?: string + runnerInvocation?: string[] +} + +function unitStem(prefix: string): string { + return `${prefix}-${process.pid}-${randomBytes(6).toString('hex')}` +} + +/** + * Confirm a modern readable user manager and literal-argument scope launch. + * @param internals - injected command paths and runners. + * @returns true only before any user command is selected for native launch. + */ +export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { + const runSync = internals.spawnSync ?? spawnSync + const systemdRun = internals.systemdRun ?? 'systemd-run' + const systemctl = internals.systemctl ?? 'systemctl' + const timeout = 5_000 + const manager = runSync(systemctl, ['--user', 'show-environment'], { + encoding: 'utf8', + stdio: 'ignore', + timeout, + }) + if (manager.error !== undefined || manager.status !== 0) return false + const probe = runSync(systemdRun, [ + '--user', + '--scope', + '--quiet', + '--wait', + '--collect', + '--pipe', + '--expand-environment=no', + `--unit=${unitStem('dsh-subprocess-probe')}`, + '--', + process.execPath, + '-e', + '', + ], { + env: childEnv(), + stdio: 'ignore', + timeout, + }) + return probe.error === undefined && probe.status === 0 +} + +class SystemdScopeOwner implements BoundProcessOwner { + private stopped = false + private observation: Promise | undefined + private lastSignal: NodeJS.Signals | undefined + + constructor( + private readonly unit: string, + private readonly systemctl: string, + private readonly runSync: typeof spawnSync, + private readonly runner: ChildProcess, + ) {} + + signal(signal: NodeJS.Signals): void { + if (this.stopped) return + this.lastSignal = signal + this.runSync(this.systemctl, [ + '--user', + 'kill', + '--kill-whom=all', + `--signal=${signal}`, + this.unit, + ], { stdio: 'ignore', timeout: 5_000 }) + } + + private active(): boolean { + const result = this.runSync(this.systemctl, [ + '--user', + 'show', + this.unit, + '--property=ActiveState', + '--value', + ], { encoding: 'utf8', timeout: 5_000 }) + if (result.error !== undefined) throw result.error + const output = `${result.stdout}\n${result.stderr}` + if (result.status !== 0) { + if (/not found|could not be found|no such/iu.test(output)) { + return this.runner.exitCode === null && this.runner.signalCode === null + } + throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) + } + const state = result.stdout.trim() + if (state === 'inactive' || state === 'failed') return false + if (state === 'active' || state === 'activating' || state === 'deactivating') return true + throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) + } + + async waitForExit(signal?: AbortSignal): Promise { + if (this.stopped) return true + this.observation ??= (async () => { + while (this.active()) await sleepMs(15) + this.stopped = true + })() + return waitWithAbort(this.observation, signal) + } + + forcedOutcome(): { exitCode: null; signal: 'SIGKILL' } | undefined { + return this.lastSignal === 'SIGKILL' ? { exitCode: null, signal: 'SIGKILL' } : undefined + } +} + +/** + * Launch one direct command inside a transient user scope. + * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. + * @param internals - injected command runners used by platform tests. + * @returns wrapper streams, target outcome, and the bound scope owner. + */ +export function launchLinuxScope( + spec: SubprocessSpawnSpec, + internals: LinuxScopeInternals = {}, +): ManagedProcessLaunch { + const run = internals.spawn ?? spawn + const runSync = internals.spawnSync ?? spawnSync + const systemdRun = internals.systemdRun ?? 'systemd-run' + const systemctl = internals.systemctl ?? 'systemctl' + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const files = runnerFiles(spec) + const unitBase = unitStem('dsh-subprocess') + const child = run(systemdRun, [ + '--user', + '--scope', + '--quiet', + '--wait', + '--collect', + '--pipe', + '--expand-environment=no', + `--unit=${unitBase}`, + '--', + ...invocation, + '--mode', + 'node', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec), + }) + const closed = observeChildClose(child) + const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, child) + const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome()) + cleanupAfterRunner(files, result.direct, owner) + return { child, pid: result.pid, direct: result.direct, closed, owner } +} diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts new file mode 100644 index 0000000000..66a87a7a05 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -0,0 +1,53 @@ +/** Minimal managed-range ownership bound to one ordinary subprocess handle. */ + +import type { ChildProcess } from 'node:child_process' +import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' + +/** Platform owner used by termination and whole-range settlement. */ +export interface BoundProcessOwner { + /** Signal the established managed range; a confirmed-stopped owner stays inert. */ + signal(signal: NodeJS.Signals): void + /** Wait for the same managed range to become empty. */ + waitForExit(signal?: AbortSignal): Promise +} + +/** Platform launch facts consumed by the common stdio and result lifecycle. */ +export interface ManagedProcessLaunch { + child: ChildProcess + pid: number + direct: Promise + closed: Promise + owner: BoundProcessOwner +} + +/** + * Observe wrapper close from the moment it is spawned. + * @param child - direct child or native wrapper. + * @returns promise settled by the ChildProcess close event. + */ +export function observeChildClose(child: ChildProcess): Promise { + return new Promise((resolve) => { child.once('close', () => { resolve() }) }) +} + +/** + * Apply an optional abort bound to one shared wait promise. + * @param pending - authoritative platform wait. + * @param signal - optional caller bound. + * @returns true on completion, false when the bound aborts first. + */ +export async function waitWithAbort(pending: Promise, signal?: AbortSignal): Promise { + if (signal?.aborted) return false + if (signal === undefined) { + await pending + return true + } + const aborted = Promise.withResolvers() + const onAbort = (): void => { aborted.resolve(false) } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + try { + return await Promise.race([pending.then(() => true), aborted.promise]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts new file mode 100644 index 0000000000..29d66c4519 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -0,0 +1,147 @@ +/** Parent-side launch and direct-result transport for native runners. */ + +import type { ChildProcess, StdioOptions } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { setTimeout as sleepMs } from 'node:timers/promises' +import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner } from './managed-owner.ts' +import { + cleanupRunnerFiles, + createRunnerFiles, + deserializeSpawnError, + readRunnerEvents, +} from './runner-protocol.ts' +import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' +import { childEnv } from './spawn.ts' + +const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) + +/** + * Resolve the built runner in production or its source entry in repository execution. + * @returns Node executable and runner argv prefix. + */ +export function spawnRunnerInvocation(): string[] { + const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) + if (existsSync(builtEntry)) return [process.execPath, builtEntry] + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] +} + +/** + * Build wrapper stdio corresponding to the public target dispositions. + * @param spec - target stdio request. + * @param ipc - append a Node IPC channel for the Windows runner. + * @returns child-process stdio configuration. + */ +export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { + const stdio: StdioOptions = [ + spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', + spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', + spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', + ] + if (ipc) stdio.push('ipc') + return stdio +} + +/** + * Materialize the exact target request without undefined environment tombstones. + * @param spec - target argv, cwd, and explicit environment. + * @returns private request and event paths. + */ +export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles { + const env = Object.fromEntries( + Object.entries(childEnv(spec.env)).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + const request: RunnerRequest = { argv: [...spec.argv], cwd: spec.cwd, env } + return createRunnerFiles(request) +} + +interface RunnerHandshake { + pid: number + events: RunnerEvent[] +} + +/** Wait synchronously only until the runner reports target start or spawn failure. */ +function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): RunnerHandshake { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const events = readRunnerEvents(files.eventsPath) + const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') + if (terminal?.type === 'started') return { pid: terminal.pid, events } + if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } + if (child.pid === undefined) throw new Error('native subprocess runner failed to start') + Atomics.wait(handshakeWait, 0, 0, 5) + } + throw new Error('native subprocess runner did not report target start within 10000ms') +} + +async function waitForDirectResult( + files: RunnerFiles, + initial: RunnerEvent[], + closed: Promise, + missingResult?: () => SubprocessOutcome | undefined, +): Promise { + let seen = 0 + let wrapperClosed = false + void closed.then(() => { wrapperClosed = true }) + for (;;) { + const events = readRunnerEvents(files.eventsPath) + for (const event of events.slice(seen)) { + if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } + if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) + } + seen = Math.max(seen, events.length, initial.length) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- child close mutates this flag asynchronously. + if (wrapperClosed) { + const known = missingResult?.() + if (known !== undefined) return known + throw new Error('native subprocess runner exited without a direct-command result') + } + await sleepMs(10) + } +} + +/** + * Bind runner events into one direct result while preserving the target pid. + * @param child - native wrapper process. + * @param files - private request and result paths. + * @param closed - wrapper close observation attached before the start handshake. + * @param 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, + missingResult?: () => SubprocessOutcome | undefined, +): { + pid: number + direct: Promise +} { + let handshake: RunnerHandshake + try { + handshake = waitForRunnerHandshake(child, files) + } catch (error) { + cleanupRunnerFiles(files) + return { pid: -1, direct: Promise.resolve().then(() => { throw error }) } + } + return { + pid: handshake.pid, + direct: waitForDirectResult(files, handshake.events, closed, missingResult), + } +} + +/** + * Remove request/result files after both direct and managed-range lifecycles settle. + * @param files - private request and result paths. + * @param direct - target result promise. + * @param owner - bound scope or Job owner. + */ +export function cleanupAfterRunner( + files: RunnerFiles, + direct: Promise, + owner: BoundProcessOwner, +): void { + void Promise.allSettled([direct, owner.waitForExit()]).then(() => { cleanupRunnerFiles(files) }) +} diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts new file mode 100644 index 0000000000..a328c6bcff --- /dev/null +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -0,0 +1,166 @@ +/** Private request and result transport shared by native subprocess runners. */ + +import { + appendFileSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** One direct command request consumed exactly once by the runner. */ +export interface RunnerRequest { + argv: string[] + cwd: string + env: Record +} + +/** Spawn-error fields preserved across the runner process boundary. */ +export interface SerializedSpawnError { + name: string + message: string + code?: string + errno?: number + syscall?: string + path?: string + spawnargs?: string[] +} + +/** Append-only direct-command facts emitted by the runner. */ +export type RunnerEvent = + | { type: 'started'; pid: number } + | { type: 'exit'; exitCode: number | null; signal: NodeJS.Signals | null } + | { type: 'spawn-error'; error: SerializedSpawnError } + | { type: 'runner-error'; error: SerializedSpawnError } + +/** Private per-spawn files; their directory is created with the host default private mkdtemp mode. */ +export interface RunnerFiles { + directory: string + requestPath: string + eventsPath: string +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Materialize one private runner request. + * @param request - exact target argv, cwd, and environment. + * @returns request and event paths owned by this spawn. + */ +export function createRunnerFiles(request: RunnerRequest): RunnerFiles { + const directory = mkdtempSync(join(tmpdir(), 'dsh-subprocess-runner-')) + const requestPath = join(directory, 'request.json') + const eventsPath = join(directory, 'events.ndjson') + writeFileSync(requestPath, JSON.stringify(request), { flag: 'wx', mode: 0o600 }) + return { directory, requestPath, eventsPath } +} + +/** + * Read and remove the single-use request before target execution. + * @param requestPath - private request file. + * @returns parsed runner request. + */ +export function consumeRunnerRequest(requestPath: string): RunnerRequest { + const parsed: unknown = JSON.parse(readFileSync(requestPath, 'utf8')) + unlinkSync(requestPath) + if (!isRecord(parsed) || !Array.isArray(parsed.argv) || parsed.argv.length === 0 + || !parsed.argv.every(value => typeof value === 'string')) { + throw new Error('subprocess runner request has no executable') + } + if (typeof parsed.cwd !== 'string' || !isRecord(parsed.env) + || !Object.values(parsed.env).every(value => typeof value === 'string')) { + throw new Error('subprocess runner request has invalid cwd or environment') + } + return { + argv: parsed.argv, + cwd: parsed.cwd, + env: parsed.env as Record, + } +} + +/** + * Append one complete event record. + * @param eventsPath - private append-only event file. + * @param event - direct-command fact. + */ +export function appendRunnerEvent(eventsPath: string, event: RunnerEvent): void { + appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { mode: 0o600 }) +} + +/** + * Parse every complete event record currently present. + * @param eventsPath - private event file. + * @returns complete records in append order. + */ +export function readRunnerEvents(eventsPath: string): RunnerEvent[] { + let content: string + try { + content = readFileSync(eventsPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + const lines = content.split('\n') + if (lines.at(-1) !== '') lines.pop() + return lines.filter(line => line.length > 0).map((line) => { + const event: unknown = JSON.parse(line) + if (isRecord(event) + && (event.type === 'started' || event.type === 'exit' || event.type === 'spawn-error' || event.type === 'runner-error')) { + return event as unknown as RunnerEvent + } + throw new Error(`subprocess runner emitted unknown event: ${line}`) + }) +} + +/** + * Convert an unknown failure into stable cross-process error fields. + * @param error - failure raised by target or runner launch. + * @returns serializable Node-shaped fields. + */ +export function serializeSpawnError(error: unknown): SerializedSpawnError { + const source = error instanceof Error ? error : new Error(String(error)) + const node = source as NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } + return { + name: source.name, + message: source.message, + ...typeof node.code === 'string' ? { code: node.code } : {}, + ...typeof node.errno === 'number' ? { errno: node.errno } : {}, + ...typeof node.syscall === 'string' ? { syscall: node.syscall } : {}, + ...typeof node.path === 'string' ? { path: node.path } : {}, + ...Array.isArray(node.spawnargs) ? { spawnargs: [...node.spawnargs] } : {}, + } +} + +/** + * Reconstruct one Node-shaped spawn error for the public done rejection. + * @param serialized - fields received from the runner. + * @returns error with Node spawn properties restored. + */ +export function deserializeSpawnError(serialized: SerializedSpawnError): Error { + const error = new Error(serialized.message) + error.name = serialized.name + return Object.assign(error, { + ...serialized.code === undefined ? {} : { code: serialized.code }, + ...serialized.errno === undefined ? {} : { errno: serialized.errno }, + ...serialized.syscall === undefined ? {} : { syscall: serialized.syscall }, + ...serialized.path === undefined ? {} : { path: serialized.path }, + ...serialized.spawnargs === undefined ? {} : { spawnargs: serialized.spawnargs }, + }) +} + +/** + * Remove only the private directory created for this spawn. + * @param files - private paths returned by createRunnerFiles. + */ +export function cleanupRunnerFiles(files: RunnerFiles): void { + try { + rmSync(files.directory, { recursive: true, force: true }) + } catch { + // A crash residue remains private and is not reused by later spawns. + } +} diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts new file mode 100644 index 0000000000..04a4df5ffa --- /dev/null +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -0,0 +1,195 @@ +/** Native managed-range runner for ordinary local subprocesses. */ + +import { spawn } from 'node:child_process' +import { + closeHandleChecked, + isJobEmpty, + loadWin32ProcessBindings, + pollProcessExit, + spawnOrdinaryJobProcess, + terminateJob, + Win32Error, +} from '@deepseek-ai/dsh-win32-process' +import type { NativePtr } from '@deepseek-ai/dsh-win32-process' +import { + appendRunnerEvent, + consumeRunnerRequest, + serializeSpawnError, +} from './runner-protocol.ts' +import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' + +type RunnerArgs = + | { mode: 'probe-win32' } + | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } + +function parseArgs(argv: string[]): RunnerArgs { + let mode: string | undefined + let requestPath: string | undefined + let eventsPath: string | undefined + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (value === undefined) throw new Error(`subprocess runner missing value after ${String(key)}`) + if (key === '--mode') mode = value + else if (key === '--request') requestPath = value + else if (key === '--events') eventsPath = value + else throw new Error(`subprocess runner unknown argument: ${String(key)}`) + } + if (mode === 'probe-win32') return { mode } + if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) + if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') + return { mode, requestPath, eventsPath } +} + +function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { + if (!(error instanceof Win32Error)) return serializeSpawnError(error) + const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? 'ENOENT' + : error.win32Code === 5 + ? 'EACCES' + : error.win32Code === 193 + ? 'EINVAL' + : 'UNKNOWN' + const program = request.argv[0] as string + return { + name: 'Error', + message: `spawn ${program} ${code}: ${error.message}`, + code, + syscall: `spawn ${program}`, + path: program, + spawnargs: request.argv.slice(1), + } +} + +function runNode(request: RunnerRequest, eventsPath: string): void { + for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { + process.on(signal, () => { /* The scope target receives it; the runner stays to report direct outcome. */ }) + } + const [program, ...args] = request.argv + const child = spawn(program as string, args, { + cwd: request.cwd, + env: request.env, + stdio: 'inherit', + }) + let started = false + let failed = false + child.once('spawn', () => { + started = true + appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number }) + }) + child.once('error', (error) => { + failed = true + if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) }) + else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) + process.exitCode = 127 + }) + child.once('exit', (exitCode, signal) => { + if (failed) return + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal }) + process.exitCode = exitCode ?? 1 + }) +} + +function replaceEnvironment(env: Record): void { + for (const key of Object.keys(process.env)) Reflect.deleteProperty(process.env, key) + Object.assign(process.env, env) +} + +async function runWin32(request: RunnerRequest, eventsPath: string): Promise { + replaceEnvironment(request.env) + const api = loadWin32ProcessBindings() + let processHandle: NativePtr | undefined + let jobHandle: NativePtr | undefined + let targetStarted = false + try { + let spawned + try { + const [command, ...args] = request.argv + spawned = spawnOrdinaryJobProcess(api, { command: command as string, args, cwd: request.cwd }) + } catch (error) { + appendRunnerEvent(eventsPath, { type: 'spawn-error', error: win32SpawnError(error, request) }) + return + } + processHandle = spawned.process + jobHandle = spawned.job + targetStarted = true + appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) + + let terminationRequested = false + const terminate = (): void => { + if (terminationRequested || jobHandle === undefined) return + terminationRequested = true + terminateJob(api, jobHandle, 1) + } + process.on('message', (message: unknown) => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() + }) + process.on('disconnect', terminate) + + await new Promise((resolve, reject) => { + const timer = setInterval(() => { + try { + if (processHandle !== undefined) { + const exitCode = pollProcessExit(api, processHandle) + if (exitCode !== undefined) { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) + closeHandleChecked(api, processHandle, 'ordinary direct process') + processHandle = undefined + } + } + if (processHandle === undefined && jobHandle !== undefined && isJobEmpty(api, jobHandle)) { + closeHandleChecked(api, jobHandle, 'ordinary process Job') + jobHandle = undefined + clearInterval(timer) + resolve() + } + } catch (error) { + clearInterval(timer) + reject(error instanceof Error ? error : new Error(String(error))) + } + }, 10) + }) + } catch (error) { + appendRunnerEvent(eventsPath, { + type: targetStarted ? 'runner-error' : 'spawn-error', + error: targetStarted ? serializeSpawnError(error) : win32SpawnError(error, request), + }) + process.exitCode = 127 + } finally { + if (processHandle !== undefined) { + try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } + } + if (jobHandle !== undefined) { + try { closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ } + } + } +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + if (args.mode === 'probe-win32') { + loadWin32ProcessBindings() + return + } + const request = consumeRunnerRequest(args.requestPath) + if (args.mode === 'node') runNode(request, args.eventsPath) + else { + try { + await runWin32(request, args.eventsPath) + } finally { + if (process.connected) process.disconnect() + } + } +} + +main().catch((error: unknown) => { + try { + const args = parseArgs(process.argv.slice(2)) + if (args.mode !== 'probe-win32') { + appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) + } + } catch { + // No trustworthy transport remains; the parent reports the missing result. + } + process.exitCode = 127 +}) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 433ba01791..84064f0390 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -24,6 +24,8 @@ import type { SubprocessOutputMode, SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' +import { observeChildClose, waitWithAbort } from './managed-owner.ts' import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts' /** @@ -315,30 +317,106 @@ function signalTree( } /** - * Spawn one isolated detached process tree with the spec's per-stream stdio - * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome}; - * only spawn failures reject. - * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. - * @param internals - test-only spill-directory, platform, and taskkill overrides. - * @returns live subprocess handle. - * @throws when `graceMs` cannot be represented by one Node timer. + * Validate the synchronous portion of one ordinary spawn request. + * @param spec - exact target request. + * @throws when grace, cancellation, or argv is invalid before launch. */ -export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { +export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } - const spillDir = internals.spillDir ?? privateSpillDir() - const platform = internals.platform ?? process.platform - const taskkill = internals.taskkill ?? taskkillProcessTree - const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers - if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } - const [program, ...args] = spec.argv + const [program] = spec.argv if (program === undefined || program.length === 0) { throw new Error('invalid argv: expected a non-empty program name at argv[0]') } +} + +function directChildResult(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let completed = false + child.once('error', (error) => { + if (completed) return + completed = true + reject(error instanceof Error ? error : new Error(String(error))) + }) + child.once('exit', (exitCode, signal) => { + if (completed) return + completed = true + resolve({ exitCode, signal }) + }) + }) +} + +function fallbackOwner( + platform: NodeJS.Platform, + pid: number, + child: ChildProcess, + taskkill: (pid: number) => void, + linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined, + direct: Promise, +): BoundProcessOwner { + let stopped = false + let directSettled = false + let observation: Promise | undefined + void direct.then( + () => { directSettled = true }, + () => { directSettled = true }, + ) + + const alive = (): boolean => { + if (stopped || pid <= 0) return false + if (platform === 'win32') return child.exitCode === null && child.signalCode === null + try { + process.kill(-pid, 0) + if (directSettled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + + return { + signal: (signal) => { + if (!alive()) { + stopped = true + return + } + signalTree(platform, pid, signal, child, taskkill) + }, + waitForExit: async (signal) => { + if (stopped) return true + observation ??= (async () => { + while (alive()) await sleepTick() + stopped = true + })() + return waitWithAbort(observation, signal) + }, + } +} + +/** + * Bind platform launch facts to the existing stdio, outcome, abort, and escalation lifecycle. + * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. + * @param launch - platform child streams, direct outcome, and managed-range owner. + * @param internals - test-only spill-directory override. + * @returns live subprocess handle. + */ +export function bindManagedProcess( + spec: SubprocessSpawnSpec, + launch: ManagedProcessLaunch, + internals: Pick = {}, +): LocalSubprocessHandle { + validateSubprocessSpec(spec) + const spillDir = internals.spillDir ?? privateSpillDir() + const child = launch.child const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => mode !== 'pipe' && mode !== 'inherit' @@ -346,20 +424,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const errMode = spec.stdio.stderr const stdinMode = spec.stdio.stdin - const env = childEnv(spec.env) - const child = spawn(program, args, { - cwd: spec.cwd, - env, - stdio: [ - stdinMode === 'ignore' ? 'ignore' : 'pipe', - outMode === 'inherit' ? 'inherit' : 'pipe', - errMode === 'inherit' ? 'inherit' : 'pipe', - ], - // `detached` gives teardown a tree root on POSIX (its own process group); - // Windows terminates by root pid through taskkill /T instead. - detached: platform !== 'win32', - }) - const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => { if (!isCollect(mode) || stream === null) return undefined const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir) @@ -370,85 +434,34 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined - let treeExitObserved = false - let treeExitObservation: Promise | undefined + let rangeExitObserved = false + let rangeExitObservation: Promise | undefined let settled = false - // Failed spawns use pid -1 so signalling remains a no-op. - const pid = child.pid ?? -1 - - /** Whether the detached tree's root (or POSIX group) is still alive. */ - const treeAlive = (): boolean => { - /* v8 ignore next -- only a timer callback already queued when the observer settles can enter here; - the guard is the final defense against probing an id after its tree was confirmed absent. */ - if (treeExitObserved) return false - if (pid <= 0) return false - if (platform === 'win32') { - // Windows has no group-liveness probe; the direct child's exit is the - // observable boundary (taskkill /T already took the tree with it). - return child.exitCode === null && child.signalCode === null - } - try { - process.kill(-pid, 0) - // A group containing only unreaped zombies still answers kill(0), but - // it can execute no work and cannot be signalled into quiescence. Only - // inspect after direct-child settlement so live-process polls remain a - // syscall rather than repeated process-table scans. - if (settled && platform === 'linux' && linuxGroupHasLiveMembers(pid) === false) return false - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing - makes observing the other arm platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return child.exitCode === null && child.signalCode === null - /* v8 ignore stop */ - } - } - /** - * Start or reuse the handle's single whole-tree exit observer. The first + * Start or reuse the handle's single managed-range exit observer. The first * confirmed absence is a permanent no-more-signals boundary: it cancels a - * pending escalation before this process-group id can be reused. + * pending escalation before a stale platform identity can be reused. */ - const observeTreeExit = (): Promise => { - treeExitObservation ??= (async () => { - while (treeAlive()) await sleepTick() - treeExitObserved = true + const observeRangeExit = (): Promise => { + rangeExitObservation ??= (async () => { + await launch.owner.waitForExit() + rangeExitObserved = true if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined })() - return treeExitObservation + return rangeExitObservation } - // The escalation's tier primitive (not on the handle — terminate() is the - // only consumer-facing termination verb). Guards on TREE liveness, not - // outcome settlement: a TERM-trapping helper can outlive the settled direct - // child and must stay signalable, while a fully-dead tree (possible pid - // reuse) must not be re-signalled by a later tier. const kill = (sig: NodeJS.Signals): void => { - /* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer; - this remains the timer/death race guard and cannot be staged deterministically. */ - if (!treeAlive()) return - signalTree(platform, pid, sig, child, taskkill) + if (rangeExitObserved) return + launch.owner.signal(sig) } const terminate = (): void => { - if (treeExitObserved || graceTimer !== undefined) return - // Observe from the first termination tier onward, even when inherited - // pipes delay `done` and no consumer has begun its own teardown wait. - void observeTreeExit() - // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await. - if (treeExitObserved) return + if (rangeExitObserved || graceTimer !== undefined) return + void observeRangeExit() kill('SIGTERM') - // The escalation must survive direct-child settlement — the leader dying - // does not mean the tree died — so settle does not clear this timer, and - // kill() re-probes tree liveness before force-killing. It stays ref'd: - // the pending SIGKILL is a commitment, and a parent exiting before it - // fires would orphan a trapped survivor. Self-bounds at graceMs. graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } @@ -469,7 +482,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const done = new Promise((resolve, reject) => { let pipeDrainTimer: ReturnType | undefined - const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { + let directOutcome: SubprocessOutcome | undefined + let wrapperClosed = false + const settle = (outcome: SubprocessOutcome): void => { if (settled) return settled = true // Only harness-collected pipes are force-closed at the drain boundary; @@ -479,23 +494,24 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter stdoutCollector?.seal() stderrCollector?.seal() cleanup() - resolve({ exitCode, signal }) + resolve(outcome) } - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. + launch.direct.then((outcome) => { + directOutcome = outcome + pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) + if (wrapperClosed) settle(outcome) + }, (error: unknown) => { + if (settled) return settled = true + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() - reject(error) + reject(error instanceof Error ? error : new Error(String(error))) }) - child.on('exit', (exitCode, signal) => { - // A surviving descendant that inherited a pipe must not hold the - // outcome open indefinitely: after exit, the same bounded grace that - // governs kills also bounds the close wait. - pipeDrainTimer = setTimeout(() => { - settle(exitCode, signal) - }, spec.graceMs) + void launch.closed.then(() => { + wrapperClosed = true + if (directOutcome !== undefined) settle(directOutcome) }) - child.on('close', settle) function cleanup(): void { // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. @@ -505,27 +521,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) const waitForExit = async (signal?: AbortSignal): Promise => { - const observed = observeTreeExit() - if (treeExitObserved) return true - if (signal?.aborted) return false - if (signal === undefined) { - await observed - return true - } - const aborted = Promise.withResolvers() - const onAbort = (): void => { aborted.resolve(false) } - signal.addEventListener('abort', onAbort, { once: true }) - /* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */ - if (signal.aborted) onAbort() - try { - return await Promise.race([observed.then(() => true), aborted.promise]) - } finally { - signal.removeEventListener('abort', onAbort) - } + if (rangeExitObserved) return true + return waitWithAbort(observeRangeExit(), signal) } return { - pid, + pid: launch.pid, /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, @@ -541,3 +542,37 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter waitForExit, } } + +/** + * Spawn one detached PGID/taskkill fallback and bind the common lifecycle. + * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. + * @param internals - test-only spill-directory, platform, and taskkill overrides. + * @returns live subprocess handle. + */ +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { + validateSubprocessSpec(spec) + const platform = internals.platform ?? process.platform + const [program, ...args] = spec.argv + const child = spawn(program as string, args, { + cwd: spec.cwd, + env: childEnv(spec.env), + stdio: [ + spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', + spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', + spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', + ], + detached: platform !== 'win32', + }) + const closed = observeChildClose(child) + const direct = directChildResult(child) + const pid = child.pid ?? -1 + const owner = fallbackOwner( + platform, + pid, + child, + internals.taskkill ?? taskkillProcessTree, + internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, + direct, + ) + return bindManagedProcess(spec, { child, pid, direct, closed, owner }, internals) +} diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts new file mode 100644 index 0000000000..e33841e9e0 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -0,0 +1,105 @@ +/** Windows Job runner launch and managed-range ownership. */ + +import { spawn, spawnSync } from 'node:child_process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' +import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { childEnv } from './spawn.ts' +import { + cleanupAfterRunner, + runnerDirectResult, + runnerFiles, + runnerStdio, + spawnRunnerInvocation, +} from './runner-launch.ts' + +/** Test seams for the runner process. */ +export interface WindowsJobInternals { + spawn?: typeof spawn + spawnSync?: typeof spawnSync + runnerInvocation?: string[] +} + +/** + * Confirm in a separate process that shared Win32 bindings and the runner entry are available. + * @param internals - injected process runners used by tests. + * @returns true when native launch can be selected before a user command. + */ +export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const [command, ...prefix] = invocation + if (command === undefined) return false + const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], { + env: childEnv(), + stdio: 'ignore', + timeout: 5_000, + }) + return result.error === undefined && result.status === 0 +} + +class WindowsJobOwner implements BoundProcessOwner { + private stopped = false + private readonly observation: Promise + + constructor(private readonly runner: ReturnType) { + this.observation = new Promise((resolve) => { + runner.once('close', () => { + this.stopped = true + resolve() + }) + }) + } + + signal(_signal: NodeJS.Signals): void { + if (this.stopped) return + try { + if (this.runner.connected) { + this.runner.send({ type: 'terminate' }, (error) => { + if (error !== null) this.runner.kill() + }) + } else { + this.runner.kill() + } + } catch { + this.runner.kill() + } + } + + waitForExit(signal?: AbortSignal): Promise { + return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal) + } +} + +/** + * Launch one direct command through the Job-owning runner. + * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. + * @param internals - injected process runner used by tests. + * @returns wrapper streams, target outcome, and the bound Job owner. + */ +export function launchWindowsJob( + spec: SubprocessSpawnSpec, + internals: WindowsJobInternals = {}, +): ManagedProcessLaunch { + const run = internals.spawn ?? spawn + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const [command, ...prefix] = invocation + if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') + const files = runnerFiles(spec) + const child = run(command, [ + ...prefix, + '--mode', + 'win32', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec, true), + }) + const closed = observeChildClose(child) + const owner = new WindowsJobOwner(child) + const result = runnerDirectResult(child, files, closed) + cleanupAfterRunner(files, result.direct, owner) + return { child, pid: result.pid, direct: result.direct, closed, owner } +} diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts new file mode 100644 index 0000000000..f6d2b149ae --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -0,0 +1,25 @@ +import { appendRunnerEvent, consumeRunnerRequest } from '../../src/runner-protocol.ts' + +const args = process.argv.slice(2) +const requestPath = args[args.indexOf('--request') + 1] as string +const eventsPath = args[args.indexOf('--events') + 1] as string +const request = consumeRunnerRequest(requestPath) +appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) + +const configuredExit = Number(request.argv[1]) +if (Number.isSafeInteger(configuredExit)) { + setTimeout(() => { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) + process.exitCode = configuredExit + }, 10) +} else { + const hold = setInterval(() => {}, 1_000) + process.on('message', (message: unknown) => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) + clearInterval(hold) + process.disconnect() + process.exitCode = 1 + } + }) +} diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts new file mode 100644 index 0000000000..98c0389e48 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -0,0 +1,113 @@ +import { spawn, spawnSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts' +import { spawnRunnerInvocation } from '../src/runner-launch.ts' + +function spec(argv: string[]): SubprocessSpawnSpec { + return { + argv, + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + graceMs: 100, + env: { LITERAL_VALUE: '$HOME ${UNCHANGED}' }, + } +} + +describe('Linux systemd scope adapter', () => { + it('requires a readable user manager and literal-argument systemd support', () => { + const calls: string[][] = [] + const runSync = vi.fn((command: string, args: readonly string[]) => { + calls.push([command, ...args]) + return { status: 0, error: undefined } + }) as unknown as typeof spawnSync + expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true) + expect(calls[1]).toContain('--expand-environment=no') + + const oldSystemd = vi.fn((command: string) => ({ + status: command === 'systemctl' ? 0 : 1, + error: undefined, + })) as unknown as typeof spawnSync + expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false) + }) + + it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { + let wrapper: ReturnType | undefined + let systemdArgs: readonly string[] = [] + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + systemdArgs = args + const separator = args.indexOf('--') + const command = args[separator + 1] as string + wrapper = spawn(command, args.slice(separator + 2), options) + return wrapper + }) as unknown as typeof spawn + const runSyncMock = vi.fn((command: string, args: readonly string[]) => { + if (command === 'systemctl' && args[1] === 'show') { + const active = wrapper?.exitCode === null && wrapper.signalCode === null + return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } + } + return { status: 0, stdout: '', stderr: '', error: undefined } + }) + const runSync = runSyncMock as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + const callsBeforeStaleSignal = runSyncMock.mock.calls.length + launch.owner.signal('SIGKILL') + expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) + expect(systemdArgs).toContain('--expand-environment=no') + expect(systemdArgs).not.toContain('literal $VALUE') + }) + + it('uses the authoritative SIGKILL scope signal when the runner cannot report after force kill', async () => { + let wrapper: ReturnType | undefined + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + const command = args[separator + 1] as string + wrapper = spawn(command, args.slice(separator + 2), { ...options, detached: true }) + return wrapper + }) as unknown as typeof spawn + const runSync = vi.fn((command: string, args: readonly string[]) => { + if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) { + process.kill(-wrapper.pid, 'SIGKILL') + } + if (command === 'systemctl' && args[1] === 'show') { + const active = wrapper?.exitCode === null && wrapper.signalCode === null + return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } + } + return { status: 0, stdout: '', stderr: '', error: undefined } + }) as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + launch.owner.signal('SIGKILL') + await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + }) + + it('rejects wait when the selected native owner becomes unreadable', async () => { + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + return spawn(args[separator + 1] as string, args.slice(separator + 2), options) + }) as unknown as typeof spawn + const runSync = vi.fn(() => ({ + status: 1, + stdout: '', + stderr: 'Failed to connect to bus', + error: undefined, + })) as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus') + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 41e6b48bc8..6b6d8e93c4 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -400,6 +400,27 @@ describe('LocalSubprocessRuntime', () => { await fiber.dispose() }) + it('warns once when ordinary spawns use the weaker macOS fallback', async () => { + const warning = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessRuntime) + const runtime = ctx.subprocess as LocalSubprocessRuntime + runtime.internals = { platform: 'darwin' } + try { + const first = runtime.spawn(spec('true')) + const second = runtime.spawn(spec('true')) + await Promise.all([first.done, second.done]) + expect(warning).toHaveBeenCalledOnce() + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('descendants that escape the process group'), + { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, + ) + } finally { + warning.mockRestore() + await fiber.dispose() + } + }) + it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts new file mode 100644 index 0000000000..4485343b5f --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -0,0 +1,82 @@ +import { spawn } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner } from '../src/managed-owner.ts' +import { observeChildClose } from '../src/managed-owner.ts' +import { bindManagedProcess } from '../src/spawn.ts' + +function spec(graceMs = 30): SubprocessSpawnSpec { + return { + argv: [process.execPath], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + graceMs, + } +} + +describe('managed process binding', () => { + it('keeps direct outcome separate from managed-range quiescence', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() + const stopped = Promise.withResolvers() + let ownerStopped = false + const signals: NodeJS.Signals[] = [] + const owner: BoundProcessOwner = { + signal(signal) { + if (ownerStopped) return + signals.push(signal) + if (signal === 'SIGKILL') { + ownerStopped = true + wrapper.kill('SIGKILL') + stopped.resolve(undefined) + } + }, + async waitForExit(signal) { + if (ownerStopped) return true + if (signal?.aborted) return false + if (signal === undefined) { + await stopped.promise + return true + } + const aborted = Promise.withResolvers() + signal.addEventListener('abort', () => { aborted.resolve(false) }, { once: true }) + return Promise.race([stopped.promise.then(() => true), aborted.promise]) + }, + } + const handle = bindManagedProcess(spec(), { + child: wrapper, + pid: 4242, + direct: direct.promise, + closed: observeChildClose(wrapper), + owner, + }) + direct.resolve({ exitCode: 42, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) + + const bound = AbortSignal.timeout(10) + await expect(handle.waitForExit(bound)).resolves.toBe(false) + handle.terminate() + expect(signals).toEqual(['SIGTERM']) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(signals).toEqual(['SIGTERM', 'SIGKILL']) + await expect(handle.waitForExit()).resolves.toBe(true) + handle.terminateForHostExit() + expect(signals).toEqual(['SIGTERM', 'SIGKILL']) + }) + + it('routes synchronous host-exit finalization directly to the owner', () => { + const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) + const signal = vi.fn() + const handle = bindManagedProcess(spec(), { + child: wrapper, + pid: 4242, + direct: Promise.resolve({ exitCode: 0, signal: null }), + closed: observeChildClose(wrapper), + owner: { signal, waitForExit: async () => true }, + }) + handle.terminateForHostExit() + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/native-containment.e2e.ts b/packages/subprocess/subprocess-local/tests/native-containment.e2e.ts new file mode 100644 index 0000000000..7ead41fb22 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/native-containment.e2e.ts @@ -0,0 +1,106 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts' +import { bindManagedProcess } from '../src/spawn.ts' + +const scratch = mkdtempSync(join(tmpdir(), 'dsh-native-containment-')) +afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) + +function spec(argv: string[], graceMs = 100): SubprocessSpawnSpec { + return { + argv, + cwd: scratch, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000 }, + stderr: { maxBytes: 64_000 }, + }, + graceMs, + } +} + +async function waitForPid(path: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const pid = Number(readFileSync(path, 'utf8').trim()) + if (Number.isSafeInteger(pid) && pid > 0) return pid + } catch { + // The target has not written the file yet. + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid file ${path} was not written`) +} + +async function waitGone(pid: number): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + try { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) + if (state === 'Z' || state === 'X') return + } catch { + return + } + } catch { + return + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid ${pid} remained alive`) +} + +const linuxNative = process.platform === 'linux' && probeLinuxScope() + +describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { + it('terminates a setsid descendant and waits for the scope to become empty', async () => { + const pidFile = join(scratch, `setsid-${Date.now()}.pid`) + const command = `setsid sh -c 'echo $$ > "$1"; trap "" TERM; while :; do sleep 60; done' sh ${JSON.stringify(pidFile)} & wait` + const handle = bindManagedProcess(spec(['bash', '-c', command], 80), launchLinuxScope(spec(['bash', '-c', command], 80))) + const descendant = await waitForPid(pidFile) + handle.terminate() + await handle.done + await expect(handle.waitForExit()).resolves.toBe(true) + await waitGone(descendant) + }) + + it('keeps direct outcome separate from a double-fork descendant, then reaps the range', async () => { + const pidFile = join(scratch, `double-fork-${Date.now()}.pid`) + const script = [ + 'import os, signal, time', + 'if os.fork() > 0: os._exit(0)', + 'os.setsid()', + 'if os.fork() > 0: os._exit(0)', + `open(${JSON.stringify(pidFile)}, 'w').write(str(os.getpid()))`, + 'signal.signal(signal.SIGTERM, signal.SIG_IGN)', + 'while True: time.sleep(60)', + ].join('\n') + const request = spec(['python3', '-c', script], 80) + const handle = bindManagedProcess(request, launchLinuxScope(request)) + const descendant = await waitForPid(pidFile) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await waitGone(descendant) + }) + + it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => { + const missing = spec([`missing-native-target-${Date.now()}`]) + const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing)) + await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) + + const deniedPath = join(scratch, `not-executable-${Date.now()}`) + writeFileSync(deniedPath, '#!/bin/sh\nexit 0\n', { mode: 0o600 }) + chmodSync(deniedPath, 0o600) + const denied = spec([deniedPath]) + const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied)) + await expect(deniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.e2e.ts b/packages/subprocess/subprocess-local/tests/native-windows.e2e.ts new file mode 100644 index 0000000000..21e7cede91 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/native-windows.e2e.ts @@ -0,0 +1,125 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { bindManagedProcess } from '../src/spawn.ts' +import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' + +const scratch = mkdtempSync(join(tmpdir(), 'dsh-native-windows-')) +afterAll(() => { rmSync(scratch, { recursive: true, force: true }) }) + +function spec(argv: string[], graceMs = 100, env?: NodeJS.ProcessEnv): SubprocessSpawnSpec { + return { + argv, + cwd: scratch, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000 }, + stderr: { maxBytes: 64_000 }, + }, + graceMs, + env, + } +} + +async function waitForPid(path: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const pid = Number(readFileSync(path, 'utf8').trim()) + if (Number.isSafeInteger(pid) && pid > 0) return pid + } catch { + // Target has not written its descendant pid yet. + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid file ${path} was not written`) +} + +async function waitGone(pid: number): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch { + return + } + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid ${pid} remained alive`) +} + +function cleanup(pid: number): void { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) +} + +const windowsNative = process.platform === 'win32' && probeWindowsJob() + +describe.skipIf(!windowsNative)('Windows Job native containment', () => { + it('terminates the direct target and its default-inheritance descendant', async () => { + const pidFile = join(scratch, `job-child-${Date.now()}.pid`) + const script = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) + setInterval(() => {}, 1000) + ` + const request = spec([process.execPath, '-e', script]) + const handle = bindManagedProcess(request, launchWindowsJob(request)) + const descendant = await waitForPid(pidFile) + try { + handle.terminate() + await handle.done + await expect(handle.waitForExit()).resolves.toBe(true) + await waitGone(descendant) + } finally { + cleanup(descendant) + } + }) + + it('reports direct exit before the inherited descendant leaves the Job', async () => { + const pidFile = join(scratch, `job-survivor-${Date.now()}.pid`) + const factsFile = join(scratch, `job-facts-${Date.now()}.json`) + const script = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) + writeFileSync(${JSON.stringify(factsFile)}, JSON.stringify({ cwd: process.cwd(), value: process.env.TARGET_VALUE, arg: process.argv[1] })) + child.unref() + process.exit(42) + ` + const request = spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }) + const handle = bindManagedProcess(request, launchWindowsJob(request)) + const descendant = await waitForPid(pidFile) + try { + await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) + expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ + cwd: scratch, + value: 'explicit', + arg: 'literal $HOME ${UNCHANGED}', + })) + await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await waitGone(descendant) + } finally { + cleanup(descendant) + } + }) + + it('preserves missing-target and direct cmd rejection errors', async () => { + const missing = spec([`missing-native-target-${Date.now()}.exe`]) + const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing)) + await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) + + const cmd = join(scratch, `direct-${Date.now()}.cmd`) + writeFileSync(cmd, '@exit /b 0\r\n') + const directCmd = spec([cmd]) + const cmdHandle = bindManagedProcess(directCmd, launchWindowsJob(directCmd)) + await expect(cmdHandle.done).rejects.toMatchObject({ code: 'EINVAL' }) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts new file mode 100644 index 0000000000..fa8824b020 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -0,0 +1,143 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, statSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + cleanupRunnerFiles, + createRunnerFiles, + deserializeSpawnError, + readRunnerEvents, + serializeSpawnError, +} from '../src/runner-protocol.ts' + +const sourceInvocation = [ + process.execPath, + '--import', + 'tsx/esm', + fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')), +] +const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) + +function runRunner(invocation: string[], requestPath: string, eventsPath: string) { + const [command, ...prefix] = invocation + return spawnSync(command as string, [ + ...prefix, + '--mode', + 'node', + '--request', + requestPath, + '--events', + eventsPath, + ], { encoding: 'utf8', timeout: 10_000 }) +} + +describe('spawn runner transport', () => { + it('creates private request files and preserves Node-shaped error fields', () => { + const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} }) + try { + if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600) + const source = Object.assign(new Error('spawn missing ENOENT'), { + code: 'ENOENT', + errno: -2, + syscall: 'spawn missing', + path: 'missing', + spawnargs: ['literal $VALUE'], + }) + const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & { + path?: string + spawnargs?: string[] + } + expect(restored).toMatchObject({ + message: 'spawn missing ENOENT', + code: 'ENOENT', + errno: -2, + syscall: 'spawn missing', + path: 'missing', + spawnargs: ['literal $VALUE'], + }) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('reports the direct target pid and exit outcome from the source entry', () => { + const files = createRunnerFiles({ + argv: [process.execPath, '-e', 'process.exit(7)'], + cwd: process.cwd(), + env: {}, + }) + try { + const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) + expect(result.error).toBeUndefined() + const events = readRunnerEvents(files.eventsPath) + expect(events).toHaveLength(2) + expect(events[0]?.type).toBe('started') + if (events[0]?.type !== 'started') throw new Error('expected started event') + expect(events[0].pid).toBeGreaterThan(0) + expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null }) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('preserves literal argv, cwd, and the exact target environment', () => { + const files = createRunnerFiles({ + argv: [ + process.execPath, + '-e', + 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))', + 'literal $HOME ${UNCHANGED}', + ], + cwd: process.cwd(), + env: { RUNNER_VALUE: 'explicit' }, + }) + try { + const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe(JSON.stringify({ + cwd: process.cwd(), + value: 'explicit', + arg: 'literal $HOME ${UNCHANGED}', + })) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('reports target spawn failure without executing a fallback command', () => { + const files = createRunnerFiles({ + argv: [`missing-dsh-runner-${Date.now()}`], + cwd: process.cwd(), + env: {}, + }) + try { + const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) + expect(result.error).toBeUndefined() + const events = readRunnerEvents(files.eventsPath) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } }) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.skipIf(!existsSync(builtEntry))('reports direct outcome from the built entry', () => { + const files = createRunnerFiles({ + argv: [process.execPath, '-e', 'process.exit(11)'], + cwd: process.cwd(), + env: {}, + }) + try { + const result = runRunner([process.execPath, builtEntry], files.requestPath, files.eventsPath) + expect(result.error).toBeUndefined() + const events = readRunnerEvents(files.eventsPath) + expect(events).toHaveLength(2) + expect(events[0]?.type).toBe('started') + if (events[0]?.type !== 'started') throw new Error('expected started event') + expect(events[0].pid).toBeGreaterThan(0) + expect(events[1]).toEqual({ type: 'exit', exitCode: 11, signal: null }) + } finally { + cleanupRunnerFiles(files) + } + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts new file mode 100644 index 0000000000..1e8c4d7331 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -0,0 +1,50 @@ +import { spawn, spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' + +const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) +const invocation = [process.execPath, '--import', 'tsx/esm', fixture] + +function spec(argv: string[]): SubprocessSpawnSpec { + return { + argv, + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + graceMs: 100, + } +} + +describe('Windows Job runner adapter', () => { + it('probes the runner before a user command is selected', () => { + const runSync = vi.fn(() => ({ status: 0, error: undefined })) as unknown as typeof spawnSync + expect(probeWindowsJob({ spawnSync: runSync, runnerInvocation: invocation })).toBe(true) + expect(runSync).toHaveBeenCalledWith( + process.execPath, + [...invocation.slice(1), '--mode', 'probe-win32'], + expect.objectContaining({ stdio: 'ignore' }), + ) + }) + + it('reports direct outcome separately from runner settlement', async () => { + const launch = launchWindowsJob(spec(['fake-target', '7']), { + spawn, + runnerInvocation: invocation, + }) + expect(launch.pid).toBeGreaterThan(0) + await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + }) + + it('signals the Job runner and waits for its managed range to stop', async () => { + const launch = launchWindowsJob(spec(['fake-target']), { + spawn, + runnerInvocation: invocation, + }) + launch.owner.signal('SIGTERM') + await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + launch.owner.signal('SIGKILL') + }) +}) diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 322e836024..e61772e83f 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../win32-process" + }, { "path": "../../util/timeout" }, diff --git a/packages/subprocess/subprocess-local/tsdown.config.ts b/packages/subprocess/subprocess-local/tsdown.config.ts new file mode 100644 index 0000000000..7902eb0af1 --- /dev/null +++ b/packages/subprocess/subprocess-local/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + index: 'lib/types/index.js', + invariant: 'lib/types/invariant.js', + 'spawn-runner': 'lib/types/spawn-runner.js', + }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 5d86f098d4..09b56eb76a 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -159,10 +159,9 @@ export interface SubprocessCollectedOutputs { * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. * - * Termination is tree-scoped everywhere: POSIX signals the detached process - * group (falling back to the direct child when the group is gone), Windows - * terminates the tree via `taskkill /T`, so helper processes cannot outlive - * the handle unnoticed. + * Termination and {@link SubprocessHandle.waitForExit} use the same managed + * range. Supported Linux and Windows hosts use an OS-owned scope or Job; + * weaker platform fallbacks are disclosed by the provider. */ export interface SubprocessHandle { /** Process id (tree root); -1 when the spawn itself failed. */ diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index d9bb79be51..eed6cf69f7 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 0005416bdfac6101090a3dc87defd71e15ec7537 -README.zh.md: 2c505ea5a1ec2fe2a930eca035b8a64ca3d4ba4f +README.md: aa79c8cf46d3729959e9ea79832c1b38a607d732 +README.zh.md: f8389e0735730769a998988b2ab087b99a4d2f48 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 0005416bdf..aa79c8cf46 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -2,15 +2,16 @@ English | [中文](README.zh.md) -Low-level Win32 process library consumed by the Windows ACL sandbox. It owns the repository's one Koffi binding table for reusable restricted-process, stdio, and Job Object operations; it is not a Cordis service and does not choose sandbox policy or public child behavior. +Low-level Win32 process library consumed by the Windows ACL sandbox and the ordinary subprocess Job runner. It owns the repository's one Koffi binding table for reusable process, stdio, and Job Object operations; it is not a Cordis service and does not choose sandbox policy or public child behavior. ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by the sandbox process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes the process handle. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. The sandbox retains its existing scheduling, result composition, and caller-owned Job closure. +- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Zero-time process and Job probes let the runner publish the direct exit separately and stay alive until default-inheritance descendants leave the Job. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner polling and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -20,7 +21,7 @@ The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child polic #### What the model sees -Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox, which owns all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. +Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox and ordinary runner, which own all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. #### Token effect @@ -35,6 +36,6 @@ The package contributes no stable request prefix, so it does not invalidate mode - **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. - **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. -- **Restricted-token consumer only** — ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement are absent until an ordinary process consumer requires them. +- **No standalone process API** — the package exposes the operations current sandbox and ordinary-runner consumers need, but it does not own Node streams, public handles, output policy, cancellation, or durable state. - **Create-to-assignment interruption** — the target starts suspended and cannot execute before Job assignment, but an external termination of the runner in the narrow interval between process creation and assignment can leave the suspended target behind. The package does not claim atomic Job attachment. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 2c505ea5a1..f8389e0735 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -2,15 +2,16 @@ [English](README.md) | 中文 -供 Windows ACL 沙箱消费的底层 Win32 进程库。它唯一拥有仓库中可复用 restricted-process、stdio 与 Job Object 操作的 Koffi 绑定表;它不是 Cordis 服务,也不决定沙箱策略或公共 child 行为。 +供 Windows ACL sandbox 与 ordinary subprocess Job runner 消费的底层 Win32 进程库。它唯一拥有仓库中可复用 process、stdio 与 Job Object 操作的 Koffi 绑定表;它不是 Cordis 服务,也不决定 sandbox policy 或公共 child 行为。 ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有 sandbox process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭进程句柄。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。sandbox 保留既有调度、result 组合与调用方拥有的 Job 关闭行为。 +- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 与 Job 的 zero-time probe 让 runner 分别发布 direct exit,并一直存活到默认继承 descendant 离开 Job。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner polling 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -20,7 +21,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 #### 模型看到什么 -没有直接内容。本包向 sandbox 提供 `Win32ProcessBindings` 与进程原语;sandbox 拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 +没有直接内容。本包向 sandbox 与 ordinary runner 提供 `Win32ProcessBindings` 与进程原语;两者拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 #### Token 影响 @@ -35,6 +36,6 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 - **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 - **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 -- **只有 restricted-token 消费方** — ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process 消费方出现前均不提供。 +- **没有 standalone process API** — 本包只暴露当前 sandbox 与 ordinary-runner consumer 所需的操作,不拥有 Node streams、公共 handle、output policy、cancellation 或 durable state。 - **创建到分配之间的中断** — 目标以 suspended 状态启动,不能在 Job 分配前执行,但 runner 若在进程创建到分配之间的极窄区间被外力终止,可能留下 suspended target。本包不声明原子 Job 附加保证。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 7d6257d692..f5efda0250 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-win32-process", - "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", + "description": "Shared low-level Win32 process, stdio, and Job Object primitives", "version": "0.1.0-rc.7", "publishConfig": { "access": "public" diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index fbdda9059f..e46304657c 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,6 +6,8 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF +/** WaitForSingleObject returned because a zero-time probe is not signalled. */ +export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index a2171d0023..1fff943741 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -81,6 +81,18 @@ export interface Win32ProcessBindings { startupInfo: NativePtr, processInfo: NativePtr, ): number + createProcessW( + applicationName: null, + commandLine: string, + processAttributes: null, + threadAttributes: null, + inheritHandles: number, + creationFlags: number, + environment: null, + currentDirectory: string | null, + startupInfo: NativePtr, + processInfo: NativePtr, + ): number readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, @@ -97,6 +109,7 @@ export interface Win32ProcessBindings { assignProcessToJobObject(job: NativePtr, process: NativePtr): number resumeThread(thread: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number + terminateJobObject(job: NativePtr, exitCode: number): number getStdHandle(stdHandle: number): NativePtr } @@ -241,6 +254,10 @@ function bindings(): Win32ProcessBindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + createProcessW: bind(kernel32, 'CreateProcessW', 'int', [ + 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [ PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32'), @@ -252,6 +269,7 @@ function bindings(): Win32ProcessBindings { assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), + terminateJobObject: bind(kernel32, 'TerminateJobObject', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32ProcessBindings return cached @@ -267,6 +285,14 @@ export function extendWin32ProcessBindings( ): Win32ProcessBindings & Extension { return { ...bindings(), ...create(bindingContext()) } } + +/** + * Load the generic process binding table without policy-specific extensions. + * @returns shared Win32 process, stdio, and Job operations. + */ +export function loadWin32ProcessBindings(): Win32ProcessBindings { + return bindings() +} /* v8 ignore stop */ /** diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index d6dee59d58..b27a72690c 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -9,6 +9,7 @@ export { decodeUint32, extendWin32ProcessBindings, isNullPtr, + loadWin32ProcessBindings, throwLastError, throwWin32, } from './ffi.ts' @@ -17,12 +18,18 @@ export type { Win32ProcessBindings, } from './ffi.ts' export { + closeHandleChecked, drainPipe, + isJobEmpty, + pollProcessExit, spawnInheritedJobProcess, + spawnOrdinaryJobProcess, spawnPipedProcess, + terminateJob, waitForProcessExit, } from './process.ts' export type { + OrdinaryProcessSpawnOptions, SpawnedJobProcess, SpawnedPipedProcess, } from './process.ts' diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7c676e1f7f..82988cfc54 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -53,14 +53,18 @@ export function buildCommandLine(program: string, args: readonly string[]): stri return [program, ...args].map(quoteArg).join(' ') } -/** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ -export interface RestrictedProcessSpawnOptions { - /** Executable argv entry passed through CreateProcessAsUserW. */ +/** Ordinary process creation inputs used by the local Win32 runner. */ +export interface OrdinaryProcessSpawnOptions { + /** Executable argv entry passed through CreateProcess. */ command: string /** Arguments excluding the executable. */ args: readonly string[] /** Existing child working directory. */ cwd: string +} + +/** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ +export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ token: NativePtr } @@ -316,19 +320,12 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { return job } -/** - * Spawn suspended, assign the child to a kill-on-close Job, then resume it. - * @param api - active binding table. - * @param options - command, cwd, args, and restricted primary token. - * @returns caller-owned process and Job handles after successful resume. - * @remarks Node clears stdio handle inheritability at startup through - * uv_disable_stdio_inheritance. This operation temporarily restores the bits - * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: - * failure must not replace the already-created child's outcome. - */ -export function spawnInheritedJobProcess( +/** Shared suspended-create, Job-assignment, and resume lifecycle. */ +function spawnJobProcess( api: Win32ProcessBindings, - options: RestrictedProcessSpawnOptions, + options: OrdinaryProcessSpawnOptions, + createName: 'CreateProcessAsUserW' | 'CreateProcessW', + create: (startupInfo: NativePtr, processInfo: NativePtr) => number, ): SpawnedJobProcess { const job = createKillOnCloseJob(api) const getStdHandle = (selector: number, label: string): NativePtr => { @@ -366,14 +363,7 @@ export function spawnInheritedJobProcess( hStdError: stdErr, }) processInfo = allocProcessInfo() - created = createRestrictedProcess( - api, - options, - buildCommandLine(options.command, options.args), - abi.CREATE_SUSPENDED, - startupInfo, - processInfo, - ) + created = create(startupInfo, processInfo) if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) @@ -391,7 +381,7 @@ export function spawnInheritedJobProcess( api.closeHandle(job) throwWin32( api, - 'CreateProcessAsUserW', + createName, createFailureCode, `command: ${options.command}, cwd: ${options.cwd}`, ) @@ -407,7 +397,7 @@ export function spawnInheritedJobProcess( api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) - throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + throw new Error(`${createName} succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) } if (api.assignProcessToJobObject(job, info.hProcess) === 0) { const win32Code = api.getLastError() @@ -427,3 +417,107 @@ export function spawnInheritedJobProcess( closeBestEffort(api, info.hThread) return { pid: info.dwProcessId, process: info.hProcess, job } } + +/** + * Spawn a restricted-token process suspended, assign its Job, then resume it. + * @param api - active binding table. + * @param options - command, cwd, args, and restricted primary token. + * @returns caller-owned process and Job handles after successful resume. + * @remarks Node clears stdio handle inheritability at startup through + * uv_disable_stdio_inheritance. This operation temporarily restores the bits + * required by STARTF_USESTDHANDLES. Restoring them afterward is best-effort: + * failure must not replace the already-created child's outcome. + */ +export function spawnInheritedJobProcess( + api: Win32ProcessBindings, + options: RestrictedProcessSpawnOptions, +): SpawnedJobProcess { + const commandLine = buildCommandLine(options.command, options.args) + return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => + createRestrictedProcess( + api, + options, + commandLine, + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + )) +} + +/** + * Spawn an ordinary process suspended, assign its Job, then resume it. + * @param api - active binding table. + * @param options - command, cwd, and argv. + * @returns caller-owned process and Job handles after successful resume. + */ +export function spawnOrdinaryJobProcess( + api: Win32ProcessBindings, + options: OrdinaryProcessSpawnOptions, +): SpawnedJobProcess { + const commandLine = buildCommandLine(options.command, options.args) + return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => + api.createProcessW( + null, + commandLine, + null, + null, + 1, + abi.CREATE_SUSPENDED, + null, + options.cwd, + startupInfo, + processInfo, + )) +} + +/** + * Poll one process handle without blocking the runner event loop. + * @param api - active binding table. + * @param process - caller-owned process handle. + * @returns the direct exit code when signalled, or undefined while running. + */ +export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr): number | undefined { + const waitResult = api.waitForSingleObject(process, 0) + if (waitResult === abi.WAIT_TIMEOUT) return undefined + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + try { + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + return decodeUint32(exitCodeSlot) + } finally { + koffi.free(exitCodeSlot) + } +} + +/** + * Return whether a Job has no active processes. + * @param api - active binding table. + * @param job - caller-owned Job handle. + * @returns true once the Job object is signalled. + */ +export function isJobEmpty(api: Win32ProcessBindings, job: NativePtr): boolean { + const waitResult = api.waitForSingleObject(job, 0) + if (waitResult === abi.WAIT_TIMEOUT) return false + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject', 'Job object') + return true +} + +/** + * Terminate every process in a Job. + * @param api - active binding table. + * @param job - caller-owned Job handle. + * @param exitCode - direct Windows exit code assigned to members. + */ +export function terminateJob(api: Win32ProcessBindings, job: NativePtr, exitCode: number): void { + if (api.terminateJobObject(job, exitCode) === 0) throwLastError(api, 'TerminateJobObject') +} + +/** + * Close a caller-owned handle and report a labelled Win32 failure. + * @param api - active binding table. + * @param handle - handle to close. + * @param detail - lifecycle label for diagnostics. + */ +export function closeHandleChecked(api: Win32ProcessBindings, handle: NativePtr, detail: string): void { + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', detail) +} diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts new file mode 100644 index 0000000000..61064a42bd --- /dev/null +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -0,0 +1,124 @@ +import koffi from 'koffi' +import { describe, expect, it, vi } from 'vitest' +import { + closeHandleChecked, + isJobEmpty, + pollProcessExit, + spawnOrdinaryJobProcess, + terminateJob, + Win32Error, +} from '../src/index.ts' +import { CREATE_SUSPENDED, WAIT_TIMEOUT } from '../src/abi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' +import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' + +function api(overrides: Partial = {}): Win32ProcessBindings { + return { + createJobObjectW: vi.fn(() => 50n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + setHandleInformation: vi.fn(() => 1), + createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { + koffi.encode(info, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0), + terminateProcess: vi.fn(() => 1), + terminateJobObject: vi.fn(() => 1), + waitForSingleObject: vi.fn(() => 0), + getExitCodeProcess: vi.fn((_process, slot) => { + koffi.encode(slot, 'uint32', 42) + return 1 + }), + closeHandle: vi.fn(() => 1), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + ...overrides, + } as unknown as Win32ProcessBindings +} + +describe('ordinary Job process operations', () => { + it('creates suspended, assigns the Job, and resumes before returning', () => { + const events: string[] = [] + const createProcessW = vi.fn(( + _app: unknown, + _line: unknown, + _pa: unknown, + _ta: unknown, + _inherit: unknown, + _flags: unknown, + _env: unknown, + _cwd: unknown, + _startup: unknown, + info: NativePtr, + ) => { + events.push('create') + koffi.encode(info, PROCESS_INFORMATION, { hProcess: 60n, hThread: 61n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }) + const bindings = api({ + createProcessW, + assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }), + resumeThread: vi.fn(() => { events.push('resume'); return 0 }), + closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), + }) + expect(spawnOrdinaryJobProcess(bindings, { + command: 'probe.exe', + args: ['literal $VALUE', 'a b'], + cwd: 'C:\\work', + })).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(createProcessW).toHaveBeenCalledWith( + null, + 'probe.exe "literal $VALUE" "a b"', + null, + null, + 1, + CREATE_SUSPENDED, + null, + 'C:\\work', + expect.anything(), + expect.anything(), + ) + expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) + expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) + expect(events).toContain('close:61') + }) + + it('reports CreateProcessW failure without replaying another creator', () => { + const bindings = api({ createProcessW: vi.fn(() => 0) }) + let caught: unknown + try { + spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) + } catch (error) { + caught = error + } + expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) + }) + + it('polls direct exit and Job emptiness without blocking', () => { + const running = api({ waitForSingleObject: vi.fn(() => WAIT_TIMEOUT) }) + expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined() + expect(isJobEmpty(running, 50n as NativePtr)).toBe(false) + + const exited = api() + expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) + expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) + }) + + it('checks Job termination and caller-owned handle closure', () => { + const terminateJobObject = vi.fn(() => 1) + const bindings = api({ terminateJobObject }) + expect(() => { terminateJob(bindings, 50n as NativePtr, 1) }).not.toThrow() + expect(() => { closeHandleChecked(bindings, 50n as NativePtr, 'test Job') }).not.toThrow() + expect(terminateJobObject).toHaveBeenCalledWith(50n, 1) + + const failing = api({ terminateJobObject: vi.fn(() => 0) }) + expect(() => { terminateJob(failing, 50n as NativePtr, 1) }).toThrow(Win32Error) + }) +}) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 1c9480105d..bb7fb29cfe 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,6 +21,7 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); + P(WAIT_TIMEOUT); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); P(STD_ERROR_HANDLE); @@ -39,6 +40,7 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff08b09c3d..84cd47be51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7836,6 +7836,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + '@deepseek-ai/dsh-win32-process': + specifier: workspace:^ + version: link:../win32-process koffi: specifier: ^3.1.0 version: 3.1.1 From 4c164cd163a7712143b7e5a78a3616b56c7fd559 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 17:27:48 +0800 Subject: [PATCH 002/110] fix(subprocess): close native containment CI gaps --- .../subprocess/subprocess-local/package.json | 1 - .../subprocess-local/src/linux-scope.ts | 2 - .../subprocess/subprocess-local/src/spawn.ts | 8 +- .../tests/fixtures/fake-job-runner.ts | 17 +- .../tests/linux-scope.spec.ts | 154 ++++++++++++++ .../subprocess-local/tests/local.spec.ts | 108 ++++++++++ .../tests/managed-spawn.spec.ts | 59 +++++- ...ment.e2e.ts => native-containment.spec.ts} | 0 ...-windows.e2e.ts => native-windows.spec.ts} | 0 .../tests/spawn-runner.spec.ts | 200 +++++++++++++++++- .../subprocess-local/tests/spawn.spec.ts | 27 +++ .../tests/windows-job.spec.ts | 115 ++++++++++ .../tests/ordinary-process.spec.ts | 14 ++ scripts/check-workspace-constraints.ts | 8 +- vitest.config.ts | 18 ++ 15 files changed, 717 insertions(+), 14 deletions(-) rename packages/subprocess/subprocess-local/tests/{native-containment.e2e.ts => native-containment.spec.ts} (100%) rename packages/subprocess/subprocess-local/tests/{native-windows.e2e.ts => native-windows.spec.ts} (100%) diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 04913db865..e736afd8a9 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -57,7 +57,6 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-win32-process": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 10b9501bba..cbfeb4361d 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -49,7 +49,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { '--user', '--scope', '--quiet', - '--wait', '--collect', '--pipe', '--expand-environment=no', @@ -147,7 +146,6 @@ export function launchLinuxScope( '--user', '--scope', '--quiet', - '--wait', '--collect', '--pipe', '--expand-environment=no', diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 84064f0390..0965951a87 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -338,11 +338,14 @@ function directChildResult(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let completed = false child.once('error', (error) => { + /* v8 ignore next -- ChildProcess may report a later operational error after its + terminal exit event; the first terminal event owns the result. */ if (completed) return completed = true - reject(error instanceof Error ? error : new Error(String(error))) + reject(error) }) child.once('exit', (exitCode, signal) => { + /* v8 ignore next -- a spawn/kill error may be followed by exit; a Promise can publish only the first terminal event. */ if (completed) return completed = true resolve({ exitCode, signal }) @@ -392,6 +395,8 @@ function fallbackOwner( signalTree(platform, pid, signal, child, taskkill) }, waitForExit: async (signal) => { + /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only + protects direct internal re-entry after signal() observed absence. */ if (stopped) return true observation ??= (async () => { while (alive()) await sleepTick() @@ -501,6 +506,7 @@ export function bindManagedProcess( pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) if (wrapperClosed) settle(outcome) }, (error: unknown) => { + /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */ if (settled) return settled = true stdoutCollector?.seal() diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index f6d2b149ae..c3971b5077 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -14,12 +14,17 @@ if (Number.isSafeInteger(configuredExit)) { }, 10) } else { const hold = setInterval(() => {}, 1_000) + let terminated = false + const terminate = (): void => { + if (terminated) return + terminated = true + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) + clearInterval(hold) + if (process.connected) process.disconnect() + process.exitCode = 1 + } process.on('message', (message: unknown) => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') { - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) - clearInterval(hold) - process.disconnect() - process.exitCode = 1 - } + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() }) + process.on('disconnect', terminate) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 98c0389e48..88cf32741a 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -23,12 +23,21 @@ describe('Linux systemd scope adapter', () => { }) as unknown as typeof spawnSync expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true) expect(calls[1]).toContain('--expand-environment=no') + expect(calls[1]).not.toContain('--wait') const oldSystemd = vi.fn((command: string) => ({ status: command === 'systemctl' ? 0 : 1, error: undefined, })) as unknown as typeof spawnSync expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false) + + const managerError = new Error('missing user manager') + expect(probeLinuxScope({ + spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync, + })).toBe(false) + expect(probeLinuxScope({ + spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, + })).toBe(false) }) it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { @@ -60,6 +69,7 @@ describe('Linux systemd scope adapter', () => { launch.owner.signal('SIGKILL') expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) expect(systemdArgs).toContain('--expand-environment=no') + expect(systemdArgs).not.toContain('--wait') expect(systemdArgs).not.toContain('literal $VALUE') }) @@ -110,4 +120,148 @@ describe('Linux systemd scope adapter', () => { await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus') }) + + it('propagates systemctl execution failures and unknown active states', async () => { + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + return spawn(args[separator + 1] as string, args.slice(separator + 2), options) + }) as unknown as typeof spawn + const failure = new Error('systemctl execution failed') + const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { + spawn: run, + spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(failedRead.owner.waitForExit()).rejects.toBe(failure) + await expect(failedRead.direct).resolves.toEqual({ exitCode: 0, signal: null }) + + const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { + spawn: run, + spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState') + await expect(unknownState.direct).resolves.toEqual({ exitCode: 0, signal: null }) + + const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { + spawn: run, + spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1') + await expect(blankFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) + }) + + it.each(['activating', 'deactivating', 'failed'])( + 'recognizes the %s scope state', + async (initialState) => { + let wrapper: ReturnType | undefined + let reads = 0 + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options) + return wrapper + }) as unknown as typeof spawn + const runSync = vi.fn(() => { + reads += 1 + return { + status: 0, + stdout: reads === 1 ? `${initialState}\n` : 'inactive\n', + stderr: '', + error: undefined, + } + }) as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + }, + ) + + it('uses runner liveness when systemd has already forgotten the scope', async () => { + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + return spawn(args[separator + 1] as string, args.slice(separator + 2), options) + }) as unknown as typeof spawn + const runSyncMock = vi.fn(() => ({ + status: 1, + stdout: '', + stderr: 'Unit could not be found', + error: undefined, + })) + const runSync = runSyncMock as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + expect(runSyncMock.mock.calls.length).toBeGreaterThan(1) + }) + + it('does not fabricate a direct outcome after a non-forced scope signal', async () => { + let wrapper: ReturnType | undefined + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), { ...options, detached: true }) + return wrapper + }) as unknown as typeof spawn + const runSync = vi.fn((command: string, args: readonly string[]) => { + if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) { + process.kill(-wrapper.pid, 'SIGKILL') + } + if (command === 'systemctl' && args[1] === 'show') { + const active = wrapper?.exitCode === null && wrapper.signalCode === null + return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } + } + return { status: 0, stdout: '', stderr: '', error: undefined } + }) as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + launch.owner.signal('SIGTERM') + await expect(launch.direct).rejects.toThrow('exited without a direct-command result') + await expect(launch.owner.waitForExit()).resolves.toBe(true) + }) + + it('uses the production command defaults when no Linux internals are supplied', async () => { + let wrapper: ReturnType | undefined + const run = vi.fn() + const runSync = vi.fn() + vi.resetModules() + vi.doMock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + run.mockImplementation((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options) + return wrapper + }) + runSync.mockImplementation((command: string, args: readonly string[]) => { + if (command === 'systemctl' && args[1] === 'show') { + const active = wrapper?.exitCode === null && wrapper.signalCode === null + return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } + } + return { status: 0, stdout: '', stderr: '', error: undefined } + }) + return { ...actual, spawn: run, spawnSync: runSync } + }) + try { + const defaults = await import('../src/linux-scope.ts') + expect(defaults.probeLinuxScope()).toBe(true) + const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) + expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) + } finally { + vi.doUnmock('node:child_process') + vi.resetModules() + } + }) }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 6b6d8e93c4..6d1ab9cee9 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -421,6 +421,114 @@ describe('LocalSubprocessRuntime', () => { } }) + it('reports the platform-specific reason for every fallback mode', async () => { + const warning = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessRuntime) + const runtime = ctx.subprocess as unknown as { + fallbackWarned: boolean + warnFallback(platform: NodeJS.Platform): void + } + try { + for (const [platform, reason] of [ + ['darwin', 'macOS has no supported persistent process-range owner'], + ['linux', 'a modern readable user-systemd scope is unavailable'], + ['win32', 'the Win32 Job runner is unavailable'], + ['freebsd', 'platform freebsd has no native managed range'], + ] as const) { + runtime.fallbackWarned = false + runtime.warnFallback(platform) + expect(warning).toHaveBeenLastCalledWith( + expect.stringContaining(reason), + { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, + ) + } + const calls = warning.mock.calls.length + runtime.warnFallback('linux') + expect(warning).toHaveBeenCalledTimes(calls) + } finally { + warning.mockRestore() + await fiber.dispose() + } + }) + + it('selects each available native owner once and contains release-observer failures', async () => { + const linuxLaunch = { kind: 'linux' } + const windowsLaunch = { kind: 'windows' } + const launchLinuxScope = vi.fn(() => linuxLaunch) + const launchWindowsJob = vi.fn(() => windowsLaunch) + const probeLinuxScope = vi.fn(() => true) + const probeWindowsJob = vi.fn(() => true) + let nextPid = 100 + const handles = [true, false, false].map((failFirstWait) => { + let waits = 0 + return { + pid: nextPid++, + collected: {}, + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate: vi.fn(), + terminateForHostExit: vi.fn(), + waitForExit: vi.fn(async () => { + waits += 1 + if (failFirstWait && waits === 1) throw new Error('release observation failed') + return true + }), + } + }) + const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown) => { + const handle = handles.shift() + if (handle === undefined) throw new Error('missing fake handle') + return handle + }) + const spawnSubprocess = vi.fn() + + vi.resetModules() + vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope, probeLinuxScope })) + vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob })) + vi.doMock('../src/spawn.ts', async importOriginal => ({ + ...await importOriginal(), + bindManagedProcess, + spawnSubprocess, + })) + const fibers: Array<{ dispose(): Promise }> = [] + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const linuxContext = new Context() + const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime) + fibers.push(linuxFiber) + const linuxRuntime = linuxContext.subprocess as InstanceType + linuxRuntime.internals = { platform: 'linux' } + await linuxRuntime.spawn(spec('true')).done + await new Promise(resolve => setImmediate(resolve)) + await linuxRuntime.spawn(spec('true')).done + await new Promise(resolve => setImmediate(resolve)) + expect(probeLinuxScope).toHaveBeenCalledOnce() + expect(launchLinuxScope).toHaveBeenCalledTimes(2) + + const windowsContext = new Context() + const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime) + fibers.push(windowsFiber) + const windowsRuntime = windowsContext.subprocess as InstanceType + windowsRuntime.internals = { platform: 'win32' } + await windowsRuntime.spawn(spec('true')).done + await new Promise(resolve => setImmediate(resolve)) + expect(probeWindowsJob).toHaveBeenCalledOnce() + expect(launchWindowsJob).toHaveBeenCalledOnce() + expect(bindManagedProcess.mock.calls.map(([, launch]) => launch)).toEqual([ + linuxLaunch, + linuxLaunch, + windowsLaunch, + ]) + expect(spawnSubprocess).not.toHaveBeenCalled() + } finally { + for (const fiber of fibers.reverse()) await fiber.dispose() + vi.doUnmock('../src/linux-scope.ts') + vi.doUnmock('../src/windows-job.ts') + vi.doUnmock('../src/spawn.ts') + vi.resetModules() + } + }) + it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 4485343b5f..8ef8567acd 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner } from '../src/managed-owner.ts' -import { observeChildClose } from '../src/managed-owner.ts' +import { observeChildClose, waitWithAbort } from '../src/managed-owner.ts' import { bindManagedProcess } from '../src/spawn.ts' function spec(graceMs = 30): SubprocessSpawnSpec { @@ -15,6 +15,22 @@ function spec(graceMs = 30): SubprocessSpawnSpec { } describe('managed process binding', () => { + it('closes the abort race after installing the wait listener', async () => { + let reads = 0 + const removeEventListener = vi.fn() + const signal = { + get aborted() { + reads += 1 + return reads > 1 + }, + addEventListener: vi.fn(), + removeEventListener, + } as unknown as AbortSignal + + await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) + expect(removeEventListener).toHaveBeenCalledOnce() + }) + it('keeps direct outcome separate from managed-range quiescence', async () => { const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'pipe'], @@ -79,4 +95,45 @@ describe('managed process binding', () => { handle.terminateForHostExit() expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') }) + + it('settles when the wrapper closes before the direct outcome arrives', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() + const handle = bindManagedProcess(spec(), { + child: wrapper, + pid: wrapper.pid as number, + direct: direct.promise, + closed: Promise.resolve(), + owner: { signal: vi.fn(), waitForExit: async () => true }, + }) + try { + await new Promise(resolve => setImmediate(resolve)) + direct.resolve({ exitCode: 23, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null }) + } finally { + wrapper.kill('SIGKILL') + } + }) + + it('normalizes a non-Error direct rejection', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + const rejection: unknown = 'runner failed' + const direct = Promise.resolve().then(() => { throw rejection }) + const handle = bindManagedProcess(spec(), { + child: wrapper, + pid: wrapper.pid as number, + direct, + closed: new Promise(() => {}), + owner: { signal: vi.fn(), waitForExit: async () => true }, + }) + try { + await expect(handle.done).rejects.toThrow('runner failed') + } finally { + wrapper.kill('SIGKILL') + } + }) }) diff --git a/packages/subprocess/subprocess-local/tests/native-containment.e2e.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts similarity index 100% rename from packages/subprocess/subprocess-local/tests/native-containment.e2e.ts rename to packages/subprocess/subprocess-local/tests/native-containment.spec.ts diff --git a/packages/subprocess/subprocess-local/tests/native-windows.e2e.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts similarity index 100% rename from packages/subprocess/subprocess-local/tests/native-windows.e2e.ts rename to packages/subprocess/subprocess-local/tests/native-windows.spec.ts diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index fa8824b020..e9d98a9350 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,9 +1,19 @@ import { spawnSync } from 'node:child_process' -import { existsSync, statSync } from 'node:fs' +import type { ChildProcess } from 'node:child_process' +import { existsSync, statSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { + cleanupAfterRunner, + runnerDirectResult, + runnerFiles, + runnerStdio, +} from '../src/runner-launch.ts' +import { + appendRunnerEvent, cleanupRunnerFiles, + consumeRunnerRequest, createRunnerFiles, deserializeSpawnError, readRunnerEvents, @@ -18,6 +28,20 @@ const sourceInvocation = [ ] const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) +function spec(overrides: Partial = {}): SubprocessSpawnSpec { + return { + argv: [process.execPath, '-e', ''], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + graceMs: 100, + ...overrides, + } +} + +function fakeChild(pid: number | undefined): ChildProcess { + return { pid } as ChildProcess +} + function runRunner(invocation: string[], requestPath: string, eventsPath: string) { const [command, ...prefix] = invocation return spawnSync(command as string, [ @@ -32,6 +56,102 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string } describe('spawn runner transport', () => { + it('selects built and source runner entries according to artifact availability', async () => { + vi.resetModules() + vi.doMock('node:fs', async importOriginal => ({ + ...await importOriginal(), + existsSync: () => true, + })) + try { + const built = await import('../src/runner-launch.ts') + expect(built.spawnRunnerInvocation()).toEqual([process.execPath, builtEntry]) + } finally { + vi.doUnmock('node:fs') + vi.resetModules() + } + + vi.doMock('node:fs', async importOriginal => ({ + ...await importOriginal(), + existsSync: () => false, + })) + try { + const source = await import('../src/runner-launch.ts') + expect(source.spawnRunnerInvocation()).toEqual(sourceInvocation) + } finally { + vi.doUnmock('node:fs') + vi.resetModules() + } + }) + + it('maps every target stdio disposition and optional IPC channel', () => { + expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) + expect(runnerStdio(spec({ + stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, + }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc']) + }) + + it('materializes and consumes the exact runner request once', () => { + const removed = `DSH_RUNNER_REMOVED_${process.pid}` + const files = runnerFiles(spec({ + argv: [process.execPath, 'literal $HOME'], + env: { RUNNER_VALUE: 'explicit', [removed]: undefined }, + })) + try { + const request = consumeRunnerRequest(files.requestPath) + expect(request.argv).toEqual([process.execPath, 'literal $HOME']) + expect(request.cwd).toBe(process.cwd()) + expect(request.env.RUNNER_VALUE).toBe('explicit') + expect(request.env).not.toHaveProperty(removed) + expect(existsSync(files.requestPath)).toBe(false) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + ['non-object request', null, 'no executable'], + ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'], + ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'], + ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'], + ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'], + ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'], + ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'], + ])('rejects an invalid %s', (_label, request, message) => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + writeFileSync(files.requestPath, JSON.stringify(request)) + expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('reads only complete known event records and propagates file errors', () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + expect(readRunnerEvents(files.eventsPath)).toEqual([]) + appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 }) + appendRunnerEvent(files.eventsPath, { + type: 'runner-error', + error: { name: 'Error', message: 'runner failed' }, + }) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 123 }, + { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } }, + ]) + + writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"') + expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }]) + for (const event of [null, [], { type: 'unknown' }]) { + writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) + expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event') + } + expect(() => readRunnerEvents(files.directory)).toThrow() + } finally { + cleanupRunnerFiles(files) + } + }) + it('creates private request files and preserves Node-shaped error fields', () => { const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} }) try { @@ -55,11 +175,87 @@ describe('spawn runner transport', () => { path: 'missing', spawnargs: ['literal $VALUE'], }) + const minimal = serializeSpawnError('plain failure') + expect(minimal).toEqual({ name: 'Error', message: 'plain failure' }) + expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' }) } finally { cleanupRunnerFiles(files) } }) + it('maps runner failures and wrapper-close fallback outcomes', async () => { + const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(runnerFailure.eventsPath, { + type: 'runner-error', + error: { name: 'Error', message: 'runner setup failed', code: 'EIO' }, + }) + const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise(() => {})) + expect(result.pid).toBe(-1) + await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' }) + } finally { + cleanupRunnerFiles(runnerFailure) + } + + const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) + const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve()) + expect(result.pid).toBe(456) + await expect(result.direct).rejects.toThrow('exited without a direct-command result') + } finally { + 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('reports runner startup failure and handshake timeout without leaking request files', async () => { + const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise(() => {})) + expect(missingResult.pid).toBe(-1) + await expect(missingResult.direct).rejects.toThrow('runner failed to start') + expect(existsSync(missingChild.directory)).toBe(false) + + const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) + try { + const timedOutResult = runnerDirectResult(fakeChild(123), timedOut, new Promise(() => {})) + expect(timedOutResult.pid).toBe(-1) + await expect(timedOutResult.direct).rejects.toThrow('did not report target start') + expect(existsSync(timedOut.directory)).toBe(false) + } finally { + now.mockRestore() + cleanupRunnerFiles(timedOut) + } + }) + + it('cleans runner files only after both direct and owner lifecycles settle', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + const exited = Promise.withResolvers() + cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), { + signal: vi.fn(), + waitForExit: async () => { await exited.promise; return true }, + }) + await new Promise(resolve => setImmediate(resolve)) + expect(existsSync(files.directory)).toBe(true) + exited.resolve(undefined) + await new Promise(resolve => setImmediate(resolve)) + expect(existsSync(files.directory)).toBe(false) + }) + it('reports the direct target pid and exit outcome from the source entry', () => { const files = createRunnerFiles({ argv: [process.execPath, '-e', 'process.exit(7)'], diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1f21dfaaa1..0c54234051 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -829,6 +829,24 @@ describe('coverage seams', () => { } }) + it('treats an EPERM group probe as still alive', async () => { + const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0 && signal === 0) { + throw Object.assign(new Error('simulated permission denial'), { code: 'EPERM' }) + } + return realKill(target, signal) + }) + try { + await expect(running.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) + } finally { + killSpy.mockRestore() + realKill(-running.pid, 'SIGKILL') + await running.done + } + }) + it('childEnv keeps the POSIX spread on non-Windows hosts', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') try { @@ -917,6 +935,15 @@ describe('coverage seams', () => { await running.waitForExit() }) + it('waitForExit is immediate after host-exit finalization observes an absent tree', async () => { + const taskkill = vi.fn() + const running = spawnSubprocess(spec('true'), { platform: 'win32', taskkill }) + await running.done + running.terminateForHostExit() + await expect(running.waitForExit()).resolves.toBe(true) + expect(taskkill).not.toHaveBeenCalled() + }) + it('repeated terminate after exit never probes or signals a reused process group', async () => { const running = spawnSubprocess(spec('sleep 60')) running.terminate() diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 1e8c4d7331..0d69a52331 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -1,7 +1,10 @@ import { spawn, spawnSync } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { appendRunnerEvent } from '../src/runner-protocol.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) @@ -25,6 +28,15 @@ describe('Windows Job runner adapter', () => { [...invocation.slice(1), '--mode', 'probe-win32'], expect.objectContaining({ stdio: 'ignore' }), ) + expect(probeWindowsJob({ runnerInvocation: [] })).toBe(false) + expect(probeWindowsJob({ + spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, + runnerInvocation: invocation, + })).toBe(false) + expect(probeWindowsJob({ + spawnSync: vi.fn(() => ({ status: 0, error: new Error('probe failed') })) as unknown as typeof spawnSync, + runnerInvocation: invocation, + })).toBe(false) }) it('reports direct outcome separately from runner settlement', async () => { @@ -47,4 +59,107 @@ describe('Windows Job runner adapter', () => { await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') }) + + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { + for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { + if (mode === 'throw') throw new Error('send threw') + callback(mode === 'callback-error' ? new Error('send failed') : null) + return true + }) + Object.assign(child, { + pid: 321, + connected: mode !== 'disconnected', + kill, + send, + }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + + launch.owner.signal('SIGTERM') + if (mode === 'callback-error' || mode === 'throw' || mode === 'disconnected') { + expect(kill).toHaveBeenCalledOnce() + } + if (mode === 'disconnected') expect(send).not.toHaveBeenCalled() + + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + const sends = send.mock.calls.length + const kills = kill.mock.calls.length + launch.owner.signal('SIGKILL') + expect(send).toHaveBeenCalledTimes(sends) + expect(kill).toHaveBeenCalledTimes(kills) + } + + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { + callback(null) + return true + }) + Object.assign(child, { pid: 654, connected: true, kill, send }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 654 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + launch.owner.signal('SIGTERM') + expect(send).toHaveBeenCalledOnce() + expect(kill).not.toHaveBeenCalled() + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await launch.direct + await launch.owner.waitForExit() + }) + + it('uses production runner defaults and rejects an empty invocation', async () => { + expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] })) + .toThrow('Windows runner invocation is empty') + + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + pid: 987, + connected: true, + kill: vi.fn(() => true), + send: vi.fn(), + }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 987 }) + return child + }) + const runSync = vi.fn(() => ({ status: 0, error: undefined })) + vi.resetModules() + vi.doMock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn: run, + spawnSync: runSync, + })) + try { + const defaults = await import('../src/windows-job.ts') + expect(defaults.probeWindowsJob()).toBe(true) + const launch = defaults.launchWindowsJob(spec(['fake-target'])) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(runSync).toHaveBeenCalledOnce() + } finally { + vi.doUnmock('node:child_process') + vi.resetModules() + } + }) }) diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 61064a42bd..b7a732b2fd 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -111,6 +111,17 @@ describe('ordinary Job process operations', () => { expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) }) + it('reports wait and exit-code query failures', () => { + const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) + expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error) + + const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) }) + expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error) + + const jobWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) + expect(() => isJobEmpty(jobWait, 50n as NativePtr)).toThrow(Win32Error) + }) + it('checks Job termination and caller-owned handle closure', () => { const terminateJobObject = vi.fn(() => 1) const bindings = api({ terminateJobObject }) @@ -120,5 +131,8 @@ describe('ordinary Job process operations', () => { const failing = api({ terminateJobObject: vi.fn(() => 0) }) expect(() => { terminateJob(failing, 50n as NativePtr, 1) }).toThrow(Win32Error) + + const closeFailure = api({ closeHandle: vi.fn(() => 0) }) + expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error) }) }) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e87106ed14..8d4355d32f 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -161,7 +161,13 @@ const packageFileExtras: Readonly> = { // SQLite loads every statement from immutable package resources at runtime. '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'], '@deepseek-ai/dsh-skill-badge': ['assets'], - '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], + // Ordinary native containment ships a path-loaded runner and its shared + // result-protocol chunk beside the existing node-pty permission repair. + '@deepseek-ai/dsh-subprocess-local': [ + 'lib/spawn-runner.js', + 'lib/runner-protocol-*.js', + 'scripts/ensure-spawn-helper.mjs', + ], } function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { diff --git a/vitest.config.ts b/vitest.config.ts index bc34d7c186..cce205c03a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -61,6 +61,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' // executes only on win32; its decision logic is unit-pinned on every // host through the injected-internals suites. 'packages/subprocess/subprocess-local/src/windows-inspector.ts', + 'packages/subprocess/subprocess-local/src/windows-job.ts', + ] + : [] + +const linuxOnlyCoverageExclusions = process.platform !== 'linux' + ? [ + // Native scope ownership executes only on Linux; its command and result + // decisions are unit-pinned on every host through injected runners. + 'packages/subprocess/subprocess-local/src/linux-scope.ts', ] : [] @@ -73,6 +82,13 @@ const windowsRunnerCoverageExclusions = process.platform === 'win32' ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] : [] +// The ordinary subprocess runner is a source/built child-process entry on +// every platform. Its real-entry smoke tests execute it out of process, where +// the parent Vitest coverage provider cannot instrument the module. +const subprocessRunnerCoverageExclusions = [ + 'packages/subprocess/subprocess-local/src/spawn-runner.ts', +] + // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -181,6 +197,7 @@ export default defineConfig({ 'packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', + ...subprocessRunnerCoverageExclusions, // Dynamic Host/Client composition is covered by its focused lifecycle // tests and assembled application checks rather than per-file coverage. 'packages/self-modification/*/src/**/*.{ts,tsx}', @@ -275,6 +292,7 @@ export default defineConfig({ ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, + ...linuxOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From d778e83b2bfa4c3add8b9deada1dafbdc820ccf1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 17:38:01 +0800 Subject: [PATCH 003/110] fix(subprocess): route fallback warning through logger --- packages/subprocess/subprocess-local/src/index.ts | 3 +-- packages/subprocess/subprocess-local/tests/local.spec.ts | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 0ddbcfec31..a45c25986e 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -187,9 +187,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { : platform === 'win32' ? 'the Win32 Job runner is unavailable' : `platform ${platform} has no native managed range` - process.emitWarning( + this.ctx.logger.warn( `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`, - { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, ) } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 6d1ab9cee9..770acaf1d6 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -401,8 +401,8 @@ describe('LocalSubprocessRuntime', () => { }) it('warns once when ordinary spawns use the weaker macOS fallback', async () => { - const warning = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) 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: 'darwin' } @@ -413,7 +413,6 @@ describe('LocalSubprocessRuntime', () => { expect(warning).toHaveBeenCalledOnce() expect(warning).toHaveBeenCalledWith( expect.stringContaining('descendants that escape the process group'), - { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, ) } finally { warning.mockRestore() @@ -422,8 +421,8 @@ describe('LocalSubprocessRuntime', () => { }) it('reports the platform-specific reason for every fallback mode', async () => { - const warning = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}) const ctx = new Context() + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const fiber = await ctx.plugin(LocalSubprocessRuntime) const runtime = ctx.subprocess as unknown as { fallbackWarned: boolean @@ -440,7 +439,6 @@ describe('LocalSubprocessRuntime', () => { runtime.warnFallback(platform) expect(warning).toHaveBeenLastCalledWith( expect.stringContaining(reason), - { code: 'DSH_SUBPROCESS_WEAK_CONTAINMENT' }, ) } const calls = warning.mock.calls.length From 7185a62d58dbcde3c327d4c8c3f109990125dc06 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 18:38:08 +0800 Subject: [PATCH 004/110] fix(subprocess): harden native runner lifecycle --- knip.json | 10 ++ .../subprocess-local/src/linux-scope.ts | 10 +- .../subprocess-local/src/runner-launch.ts | 9 +- .../subprocess-local/src/runner-protocol.ts | 55 +++++++++-- .../subprocess/subprocess-local/src/spawn.ts | 3 +- .../subprocess-local/src/windows-job.ts | 2 +- .../tests/managed-spawn.spec.ts | 33 ++++++- .../tests/spawn-runner-built.e2e.ts | 39 ++++++++ .../tests/spawn-runner.spec.ts | 92 +++++++++++++------ scripts/run-gates.spec.ts | 5 + scripts/run-gates.ts | 6 +- 11 files changed, 213 insertions(+), 51 deletions(-) create mode 100644 packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts diff --git a/knip.json b/knip.json index 280d10a1f0..719cc61f47 100644 --- a/knip.json +++ b/knip.json @@ -286,6 +286,16 @@ "tests/**/*.ts" ] }, + "packages/subprocess/subprocess-local": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/session/session-telemetry-otel": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index cbfeb4361d..e007e918e3 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -68,7 +68,7 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | undefined - private lastSignal: NodeJS.Signals | undefined + private killConfirmed = false constructor( private readonly unit: string, @@ -79,14 +79,14 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: NodeJS.Signals): void { if (this.stopped) return - this.lastSignal = signal - this.runSync(this.systemctl, [ + const result = this.runSync(this.systemctl, [ '--user', 'kill', '--kill-whom=all', `--signal=${signal}`, this.unit, ], { stdio: 'ignore', timeout: 5_000 }) + if (signal === 'SIGKILL' && result.error === undefined && result.status === 0) this.killConfirmed = true } private active(): boolean { @@ -121,7 +121,7 @@ class SystemdScopeOwner implements BoundProcessOwner { } forcedOutcome(): { exitCode: null; signal: 'SIGKILL' } | undefined { - return this.lastSignal === 'SIGKILL' ? { exitCode: null, signal: 'SIGKILL' } : undefined + return this.killConfirmed ? { exitCode: null, signal: 'SIGKILL' } : undefined } } @@ -165,6 +165,6 @@ export function launchLinuxScope( const closed = observeChildClose(child) const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, child) const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome()) - cleanupAfterRunner(files, result.direct, owner) + cleanupAfterRunner(files, result.direct, closed) return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 29d66c4519..4f9a404fce 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -5,7 +5,6 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type { BoundProcessOwner } from './managed-owner.ts' import { cleanupRunnerFiles, createRunnerFiles, @@ -133,15 +132,15 @@ export function runnerDirectResult( } /** - * Remove request/result files after both direct and managed-range lifecycles settle. + * Remove request/result files after their reader settles and writer closes. * @param files - private request and result paths. * @param direct - target result promise. - * @param owner - bound scope or Job owner. + * @param closed - runner close observation. */ export function cleanupAfterRunner( files: RunnerFiles, direct: Promise, - owner: BoundProcessOwner, + closed: Promise, ): void { - void Promise.allSettled([direct, owner.waitForExit()]).then(() => { cleanupRunnerFiles(files) }) + void Promise.allSettled([direct, closed]).then(() => { cleanupRunnerFiles(files) }) } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index a328c6bcff..4cba127ba0 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -8,7 +8,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' -import { tmpdir } from 'node:os' +import { constants as osConstants, tmpdir } from 'node:os' import { join } from 'node:path' /** One direct command request consumed exactly once by the runner. */ @@ -47,6 +47,50 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string' +} + +function isSerializedSpawnError(value: unknown): value is SerializedSpawnError { + return isRecord(value) + && typeof value.name === 'string' + && typeof value.message === 'string' + && isOptionalString(value.code) + && (value.errno === undefined || typeof value.errno === 'number') + && isOptionalString(value.syscall) + && isOptionalString(value.path) + && (value.spawnargs === undefined + || (Array.isArray(value.spawnargs) && value.spawnargs.every(item => typeof item === 'string'))) +} + +function parseRunnerEvent(line: string): RunnerEvent { + const event: unknown = JSON.parse(line) + if (!isRecord(event)) throw new Error(`subprocess runner emitted invalid event: ${line}`) + if (event.type === 'started') { + if (typeof event.pid !== 'number' || !Number.isSafeInteger(event.pid) || event.pid <= 0) { + throw new Error(`subprocess runner emitted invalid event: ${line}`) + } + return { type: 'started', pid: event.pid } + } + if (event.type === 'exit') { + const validExitCode = event.exitCode === null + || (typeof event.exitCode === 'number' && Number.isSafeInteger(event.exitCode) && event.exitCode >= 0) + const validSignal = event.signal === null + || (typeof event.signal === 'string' && Object.hasOwn(osConstants.signals, event.signal)) + if (!validExitCode || !validSignal) throw new Error(`subprocess runner emitted invalid event: ${line}`) + return { + type: 'exit', + exitCode: event.exitCode as number | null, + signal: event.signal as NodeJS.Signals | null, + } + } + if (event.type === 'spawn-error' || event.type === 'runner-error') { + if (!isSerializedSpawnError(event.error)) throw new Error(`subprocess runner emitted invalid event: ${line}`) + return { type: event.type, error: event.error } + } + throw new Error(`subprocess runner emitted unknown event: ${line}`) +} + /** * Materialize one private runner request. * @param request - exact target argv, cwd, and environment. @@ -107,14 +151,7 @@ export function readRunnerEvents(eventsPath: string): RunnerEvent[] { } const lines = content.split('\n') if (lines.at(-1) !== '') lines.pop() - return lines.filter(line => line.length > 0).map((line) => { - const event: unknown = JSON.parse(line) - if (isRecord(event) - && (event.type === 'started' || event.type === 'exit' || event.type === 'spawn-error' || event.type === 'runner-error')) { - return event as unknown as RunnerEvent - } - throw new Error(`subprocess runner emitted unknown event: ${line}`) - }) + return lines.filter(line => line.length > 0).map(parseRunnerEvent) } /** diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 0965951a87..22b023a2fb 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -454,6 +454,7 @@ export function bindManagedProcess( rangeExitObserved = true if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined + spec.signal?.removeEventListener('abort', onAbort) })() return rangeExitObservation } @@ -509,6 +510,7 @@ export function bindManagedProcess( /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */ if (settled) return settled = true + terminate() stdoutCollector?.seal() stderrCollector?.seal() cleanup() @@ -522,7 +524,6 @@ export function bindManagedProcess( // graceTimer deliberately NOT cleared: the SIGKILL escalation must be // able to reach tree survivors after the direct child settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) - spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index e33841e9e0..3de46915da 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -100,6 +100,6 @@ export function launchWindowsJob( const closed = observeChildClose(child) const owner = new WindowsJobOwner(child) const result = runnerDirectResult(child, files, closed) - cleanupAfterRunner(files, result.direct, owner) + cleanupAfterRunner(files, result.direct, closed) return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 8ef8567acd..ff66eaa542 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -123,17 +123,48 @@ describe('managed process binding', () => { }) const rejection: unknown = 'runner failed' const direct = Promise.resolve().then(() => { throw rejection }) + const signal = vi.fn() const handle = bindManagedProcess(spec(), { child: wrapper, pid: wrapper.pid as number, direct, closed: new Promise(() => {}), - owner: { signal: vi.fn(), waitForExit: async () => true }, + owner: { signal, waitForExit: async () => true }, }) try { await expect(handle.done).rejects.toThrow('runner failed') + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM') } finally { wrapper.kill('SIGKILL') } }) + + it('keeps abort ownership after direct exit until the managed range is empty', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() + const stopped = Promise.withResolvers() + const signal = vi.fn((requested: NodeJS.Signals) => { + if (requested !== 'SIGTERM') return + wrapper.kill('SIGTERM') + stopped.resolve(undefined) + }) + const controller = new AbortController() + const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { + child: wrapper, + pid: wrapper.pid as number, + direct: direct.promise, + closed: Promise.resolve(), + owner: { + signal, + waitForExit: async () => { await stopped.promise; return true }, + }, + }) + direct.resolve({ exitCode: 0, signal: null }) + await handle.done + controller.abort() + await expect(handle.waitForExit()).resolves.toBe(true) + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM') + }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts new file mode 100644 index 0000000000..7b8703b215 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { cleanupRunnerFiles, createRunnerFiles, readRunnerEvents } from '../src/runner-protocol.ts' + +const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) +const required = process.env.DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER === '1' + +describe.skipIf(!existsSync(builtEntry) && !required)('built subprocess runner entry', () => { + it('reports the direct target outcome through the published entry', () => { + if (!existsSync(builtEntry)) throw new Error(`required built subprocess runner is missing: ${builtEntry}`) + const files = createRunnerFiles({ + argv: [process.execPath, '-e', 'process.exit(11)'], + cwd: process.cwd(), + env: {}, + }) + try { + const result = spawnSync(process.execPath, [ + builtEntry, + '--mode', + 'node', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { encoding: 'utf8', timeout: 10_000 }) + expect(result.error).toBeUndefined() + const events = readRunnerEvents(files.eventsPath) + expect(events).toHaveLength(2) + expect(events[0]?.type).toBe('started') + if (events[0]?.type !== 'started') throw new Error('expected started event') + expect(events[0].pid).toBeGreaterThan(0) + expect(events[1]).toEqual({ type: 'exit', exitCode: 11, signal: null }) + } finally { + cleanupRunnerFiles(files) + } + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index e9d98a9350..334b4d9d6b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -135,23 +135,81 @@ describe('spawn runner transport', () => { type: 'runner-error', error: { name: 'Error', message: 'runner failed' }, }) + appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: null, signal: 'SIGTERM' }) + appendRunnerEvent(files.eventsPath, { + type: 'spawn-error', + error: { + name: 'Error', + message: 'spawn failed', + code: 'ENOENT', + errno: -2, + syscall: 'spawn missing', + path: 'missing', + spawnargs: ['argument'], + }, + }) expect(readRunnerEvents(files.eventsPath)).toEqual([ { type: 'started', pid: 123 }, { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } }, + { type: 'exit', exitCode: null, signal: 'SIGTERM' }, + { + type: 'spawn-error', + error: { + name: 'Error', + message: 'spawn failed', + code: 'ENOENT', + errno: -2, + syscall: 'spawn missing', + path: 'missing', + spawnargs: ['argument'], + }, + }, ]) writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"') expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }]) - for (const event of [null, [], { type: 'unknown' }]) { + for (const event of [null, []]) { writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) - expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event') + expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event') } + writeFileSync(files.eventsPath, '{"type":"unknown"}\n') + expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event') expect(() => readRunnerEvents(files.directory)).toThrow() } finally { cleanupRunnerFiles(files) } }) + it.each([ + ['started without a pid', { type: 'started' }], + ['started with a non-number pid', { type: 'started', pid: '1' }], + ['started with a fractional pid', { type: 'started', pid: 1.5 }], + ['started with a non-positive pid', { type: 'started', pid: 0 }], + ['exit with a missing code', { type: 'exit', signal: null }], + ['exit with a non-number code', { type: 'exit', exitCode: '0', signal: null }], + ['exit with a fractional code', { type: 'exit', exitCode: 1.5, signal: null }], + ['exit with a negative code', { type: 'exit', exitCode: -1, signal: null }], + ['exit with a non-string signal', { type: 'exit', exitCode: 0, signal: 9 }], + ['exit with an unknown signal', { type: 'exit', exitCode: 0, signal: 'NOT_A_SIGNAL' }], + ['spawn error without an object', { type: 'spawn-error', error: null }], + ['spawn error without a name', { type: 'spawn-error', error: { message: 'failed' } }], + ['spawn error without a message', { type: 'spawn-error', error: { name: 'Error' } }], + ['spawn error with a numeric code', { type: 'spawn-error', error: { name: 'Error', message: 'failed', code: 1 } }], + ['spawn error with a string errno', { type: 'spawn-error', error: { name: 'Error', message: 'failed', errno: '1' } }], + ['spawn error with a numeric syscall', { type: 'spawn-error', error: { name: 'Error', message: 'failed', syscall: 1 } }], + ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }], + ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }], + ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }], + ])('rejects an invalid event payload: %s', (_label, event) => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) + expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event') + } finally { + cleanupRunnerFiles(files) + } + }) + it('creates private request files and preserves Node-shaped error fields', () => { const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} }) try { @@ -242,16 +300,13 @@ describe('spawn runner transport', () => { } }) - it('cleans runner files only after both direct and owner lifecycles settle', async () => { + it('cleans runner files only after the direct result and runner close settle', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const exited = Promise.withResolvers() - cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), { - signal: vi.fn(), - waitForExit: async () => { await exited.promise; return true }, - }) + const closed = Promise.withResolvers() + cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise) await new Promise(resolve => setImmediate(resolve)) expect(existsSync(files.directory)).toBe(true) - exited.resolve(undefined) + closed.resolve(undefined) await new Promise(resolve => setImmediate(resolve)) expect(existsSync(files.directory)).toBe(false) }) @@ -317,23 +372,4 @@ describe('spawn runner transport', () => { } }) - it.skipIf(!existsSync(builtEntry))('reports direct outcome from the built entry', () => { - const files = createRunnerFiles({ - argv: [process.execPath, '-e', 'process.exit(11)'], - cwd: process.cwd(), - env: {}, - }) - try { - const result = runRunner([process.execPath, builtEntry], files.requestPath, files.eventsPath) - expect(result.error).toBeUndefined() - const events = readRunnerEvents(files.eventsPath) - expect(events).toHaveLength(2) - expect(events[0]?.type).toBe('started') - if (events[0]?.type !== 'started') throw new Error('expected started event') - expect(events[0].pid).toBeGreaterThan(0) - expect(events[1]).toEqual({ type: 'exit', exitCode: 11, signal: null }) - } finally { - cleanupRunnerFiles(files) - } - }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 8de5d1e095..3f6053f5db 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -367,10 +367,15 @@ describe('Node 24 lane ownership', () => { }) expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual( expect.arrayContaining([ + 'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', ]), ) + expect(subject.find(item => item.id === 'built-bin-smoke')?.env).toEqual({ + DSH_EXAMPLE_MODE: 'lib', + DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER: '1', + }) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ee021782f9..5978958219 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -695,6 +695,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/sdk/server/tests/built-scope-carrier.e2e.ts', + 'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', 'packages/api/remotes/tests/built-lib.e2e.ts', @@ -707,7 +708,10 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { ], { label: 'built-bin smoke', needs, - env: { DSH_EXAMPLE_MODE: 'lib' }, + env: { + DSH_EXAMPLE_MODE: 'lib', + DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER: '1', + }, }) } From 1ada79af816ad408e13306cda63989093636572e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 18:58:36 +0800 Subject: [PATCH 005/110] test(subprocess): cover settled scope waits --- packages/subprocess/subprocess-local/tests/linux-scope.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 88cf32741a..4015decee7 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -65,6 +65,7 @@ describe('Linux systemd scope adapter', () => { }) await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBe(true) const callsBeforeStaleSignal = runSyncMock.mock.calls.length launch.owner.signal('SIGKILL') expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) From 6532fff45861698e50885b8b24fb6892400c77eb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 20:02:57 +0800 Subject: [PATCH 006/110] test(subagent-acp): allow provider fallback warning --- packages/subagent/subagent-acp/tests/subagent-acp.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c534b8e949..80b8e88af0 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -784,9 +784,9 @@ describe('dsh-subagent-acp', () => { const run = await ctx.subagents.start('acp', request()) const result = await run.result expect(result.stopReason).toBe('error') - expect(warnings).toEqual([ + expect(warnings).toContainEqual( expect.stringContaining('subagent-acp "acp": child run failed (error):'), - ]) + ) await run.dispose() }) From ad44ba547a8d4ee34c94ac964bf71e7876e173fb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 20:35:08 +0800 Subject: [PATCH 007/110] test(subagent-acp): explain fallback warning coexistence --- packages/subagent/subagent-acp/tests/subagent-acp.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 80b8e88af0..26ec4cd0b3 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -784,6 +784,8 @@ describe('dsh-subagent-acp', () => { const run = await ctx.subagents.start('acp', request()) const result = await run.result expect(result.stopReason).toBe('error') + // Unsupported hosts can emit the subprocess provider's one-time fallback + // warning before this provider-specific failure reaches the same logger. expect(warnings).toContainEqual( expect.stringContaining('subagent-acp "acp": child run failed (error):'), ) From 8b81d77d54ace076659247a8f848bb03f6fb2dc9 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 20:58:33 +0800 Subject: [PATCH 008/110] refactor(subprocess): use mode as fallback warning latch --- packages/subprocess/subprocess-local/src/index.ts | 3 --- packages/subprocess/subprocess-local/tests/local.spec.ts | 5 ----- 2 files changed, 8 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index a45c25986e..b60200ef57 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -45,7 +45,6 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { internals: SpawnInternals = {} /** Ordinary native containment mode, selected once before its first user command. */ private ordinaryMode: 'linux-scope' | 'windows-job' | 'fallback' | undefined - private fallbackWarned = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -178,8 +177,6 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } private warnFallback(platform: NodeJS.Platform): void { - if (this.fallbackWarned) return - this.fallbackWarned = true const reason = platform === 'darwin' ? 'macOS has no supported persistent process-range owner' : platform === 'linux' diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 770acaf1d6..2696e986ad 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -425,7 +425,6 @@ describe('LocalSubprocessRuntime', () => { const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const fiber = await ctx.plugin(LocalSubprocessRuntime) const runtime = ctx.subprocess as unknown as { - fallbackWarned: boolean warnFallback(platform: NodeJS.Platform): void } try { @@ -435,15 +434,11 @@ describe('LocalSubprocessRuntime', () => { ['win32', 'the Win32 Job runner is unavailable'], ['freebsd', 'platform freebsd has no native managed range'], ] as const) { - runtime.fallbackWarned = false runtime.warnFallback(platform) expect(warning).toHaveBeenLastCalledWith( expect.stringContaining(reason), ) } - const calls = warning.mock.calls.length - runtime.warnFallback('linux') - expect(warning).toHaveBeenCalledTimes(calls) } finally { warning.mockRestore() await fiber.dispose() From eb60d74155e8e293e06b97d0442a91855797f746 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:16:24 +0800 Subject: [PATCH 009/110] fix(subprocess): harden native range settlement --- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 4 +- ...-08-20-subprocess-native-containment.zh.md | 4 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 5 +- docs/subsystems/subprocess.zh.md | 5 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 3 +- .../subprocess/subprocess-local/README.zh.md | 3 +- .../subprocess-local/src/linux-scope.ts | 76 ++++++++++++++++--- .../subprocess-local/src/managed-owner.ts | 2 +- .../subprocess-local/src/runner-launch.ts | 35 +++++++-- .../subprocess-local/src/runner-protocol.ts | 28 ++++++- .../subprocess/subprocess-local/src/spawn.ts | 8 +- .../tests/linux-scope.spec.ts | 51 +++++++++++-- .../tests/managed-spawn.spec.ts | 48 ++++++++++++ .../tests/spawn-runner.spec.ts | 14 +++- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 4 +- packages/subprocess/subprocess/README.zh.md | 4 +- packages/subprocess/subprocess/src/types.ts | 3 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 6 +- .../subprocess/win32-process/README.zh.md | 6 +- packages/subprocess/win32-process/src/abi.ts | 6 ++ packages/subprocess/win32-process/src/ffi.ts | 10 +++ .../subprocess/win32-process/src/process.ts | 17 +++-- .../tests/ordinary-process.spec.ts | 32 +++++++- .../win32-process/verify/abi-probe.cpp | 6 ++ 29 files changed, 333 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 885a4dbca3..54dcc901ff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: bbe36a39580316e7bee34e6e22321d6eb46c9580 -2026-08-20-subprocess-native-containment.zh.md: 9b882f6369d3c7ebc1a2595fc23fbd37cc9eec6c +2026-08-20-subprocess-native-containment.md: 3ccf78e158458de786ee424badcd5fdf4ff88676 +2026-08-20-subprocess-native-containment.zh.md: 2d223575ad2663ff581b4bc8c04c9c5533263a3f diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index bbe36a3958..3ccf78e158 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.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, TERM-to-KILL escalation, and host-exit registration. `.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. 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, while the runner remains until both the direct result is reported and the Job is empty. 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. 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, while the runner remains until both 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. @@ -34,4 +34,4 @@ Linux native evidence covers a real `setsid` descendant and a double-fork daemon ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The synchronous public spawn contract then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 9b882f6369..2d223575ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.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 处理、TERM-to-KILL 升级与 host-exit 注册。`.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。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;runner 会一直存活到 direct result 已报告且 Job 为空。parent IPC 断开会在 JavaScript-observable host exit 期间终止 Job。 +Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;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 重放。 @@ -34,4 +34,4 @@ Linux native 证据覆盖真实 `setsid` descendant,以及 direct parent 先 ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。同步公共 spawn 合同随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index a0852f4fb6..895f8b39c2 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 918c44c723d880a813837fcde47289cca8746fb4 -subprocess.zh.md: e94b6b0e96e22aa67419a7b08acb9dfe20524eb9 +subprocess.md: a2b0a5575b28d3a0ba859fc56d0a2ae7be008175 +subprocess.zh.md: b923cad98bb3dd999c6f4ca8fa5c7e160cebe774 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 918c44c723..a2b0a5575b 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -131,7 +131,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and managed-range termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). +A spawn returns a live handle synchronously after any provider-specific setup needed to publish its target pid. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -153,7 +153,7 @@ interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree @@ -167,6 +167,7 @@ interface SubprocessHandle { * child, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. * @returns `true` when the tree exited, `false` when the signal aborted first. + * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise } diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index e94b6b0e96..b923cad98b 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -131,7 +131,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与 managed-range 终止 -spawn 会立即返回一个活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 +spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步返回活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 ```ts type-equiv /** @@ -153,7 +153,7 @@ interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree @@ -167,6 +167,7 @@ interface SubprocessHandle { * child, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. * @returns `true` when the tree exited, `false` when the signal aborted first. + * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise } diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2984853d9d..4df1352204 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: d7986dbb3ef436cc572090fe89b8a2ee62916f0d -README.zh.md: bd710d5427fa62ecb1af37a20762079aea2727ca +README.md: 8cb0a4a6eae741c8073462ca4f861149e2a82597 +README.zh.md: e9726758761e969aa70feb595753ca37863783fc diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index d7986dbb3e..8cb0a4a6ea 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after the same scope or Job is empty. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and still-open collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, only collected pipes retain the existing bounded drain grace, and raw/inherited stdio does not delay direct settlement. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). @@ -27,6 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The public `spawn()` contract returns a numeric target pid, so each native launch then waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index bd710d5427..e972675876 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL,`waitForExit()` 只在同一 scope 或 Job 为空后成功。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;仍打开的 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期,raw/inherited stdio 不会延迟 direct settlement。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 @@ -27,6 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。公共 `spawn()` 合同返回数值 target pid,因此每次 native launch 随后会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index e007e918e3..f1991865d5 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -1,7 +1,7 @@ /** Linux user-systemd scope launch and managed-range ownership. */ import { randomBytes } from 'node:crypto' -import { spawn, spawnSync } from 'node:child_process' +import { execFile, spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -20,11 +20,50 @@ import { export interface LinuxScopeInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync + systemctlQuery?: (command: string, args: readonly string[]) => Promise systemdRun?: string systemctl?: string runnerInvocation?: string[] } +interface SystemctlResult { + status: number | null + stdout: string + stderr: string + error?: Error +} + +const SYSTEMCTL_TIMEOUT_MS = 5_000 +const SCOPE_POLL_INTERVAL_MS = 200 + +function querySystemctl(command: string, args: readonly string[]): Promise { + return new Promise((resolve) => { + execFile(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }, (error, stdout, stderr) => { + const code = error === null ? 0 : (error as Error & { code?: string | number }).code + resolve({ + status: typeof code === 'number' ? code : error === null ? 0 : null, + stdout, + stderr, + ...error === null ? {} : { error }, + }) + }) + }) +} + +function syncQuerySystemctl( + runSync: typeof spawnSync, + command: string, + args: readonly string[], +): Promise { + const result = runSync(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }) + return Promise.resolve({ + status: result.status, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + ...result.error === undefined ? {} : { error: result.error }, + }) +} + function unitStem(prefix: string): string { return `${prefix}-${process.pid}-${randomBytes(6).toString('hex')}` } @@ -69,11 +108,13 @@ class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | undefined private killConfirmed = false + private killFailure: Error | undefined constructor( private readonly unit: string, private readonly systemctl: string, private readonly runSync: typeof spawnSync, + private readonly query: (command: string, args: readonly string[]) => Promise, private readonly runner: ChildProcess, ) {} @@ -85,24 +126,38 @@ class SystemdScopeOwner implements BoundProcessOwner { '--kill-whom=all', `--signal=${signal}`, this.unit, - ], { stdio: 'ignore', timeout: 5_000 }) - if (signal === 'SIGKILL' && result.error === undefined && result.status === 0) this.killConfirmed = true + ], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }) + if (result.error === undefined && result.status === 0) { + if (signal === 'SIGKILL') this.killConfirmed = true + return + } + const output = `${result.stdout}\n${result.stderr}` + if (/not found|could not be found|no such/iu.test(output)) { + this.stopped = true + return + } + if (signal === 'SIGKILL') { + this.killFailure = result.error ?? new Error( + `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`, + ) + } } - private active(): boolean { - const result = this.runSync(this.systemctl, [ + private async active(): Promise { + if (this.killFailure !== undefined) throw this.killFailure + const result = await this.query(this.systemctl, [ '--user', 'show', this.unit, '--property=ActiveState', '--value', - ], { encoding: 'utf8', timeout: 5_000 }) - if (result.error !== undefined) throw result.error + ]) const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { if (/not found|could not be found|no such/iu.test(output)) { return this.runner.exitCode === null && this.runner.signalCode === null } + if (result.error !== undefined) throw result.error throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) } const state = result.stdout.trim() @@ -114,7 +169,7 @@ class SystemdScopeOwner implements BoundProcessOwner { async waitForExit(signal?: AbortSignal): Promise { if (this.stopped) return true this.observation ??= (async () => { - while (this.active()) await sleepMs(15) + while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS) this.stopped = true })() return waitWithAbort(this.observation, signal) @@ -137,6 +192,9 @@ export function launchLinuxScope( ): ManagedProcessLaunch { const run = internals.spawn ?? spawn const runSync = internals.spawnSync ?? spawnSync + const query = internals.systemctlQuery ?? (internals.spawnSync === undefined + ? querySystemctl + : (command, args) => syncQuerySystemctl(runSync, command, args)) const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() @@ -163,7 +221,7 @@ export function launchLinuxScope( stdio: runnerStdio(spec), }) const closed = observeChildClose(child) - const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, child) + const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child) const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome()) cleanupAfterRunner(files, result.direct, closed) return { child, pid: result.pid, direct: result.direct, closed, owner } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 66a87a7a05..a622b36429 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -7,7 +7,7 @@ import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' export interface BoundProcessOwner { /** Signal the established managed range; a confirmed-stopped owner stays inert. */ signal(signal: NodeJS.Signals): void - /** Wait for the same managed range to become empty. */ + /** Wait for the same managed range to become empty; reject when its owner cannot be observed. */ waitForExit(signal?: AbortSignal): Promise } diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 4f9a404fce..95d091d3fe 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,7 +1,7 @@ /** Parent-side launch and direct-result transport for native runners. */ import type { ChildProcess, StdioOptions } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -10,11 +10,14 @@ import { createRunnerFiles, deserializeSpawnError, readRunnerEvents, + readRunnerEventsAsync, } from './runner-protocol.ts' import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' import { childEnv } from './spawn.ts' const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) +const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 +const RUNNER_EVENT_POLL_MS = 100 /** * Resolve the built runner in production or its source entry in repository execution. @@ -61,18 +64,40 @@ interface RunnerHandshake { events: RunnerEvent[] } +/** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ +function runnerExited(child: ChildProcess): boolean { + if (child.exitCode !== null || child.signalCode !== null) return true + if (child.pid === undefined) return true + if (process.platform === 'linux') { + try { + const stat = readFileSync(`/proc/${String(child.pid)}/stat`, 'utf8') + const suffix = stat.slice(stat.lastIndexOf(')') + 2) + if (suffix.startsWith('Z') || suffix.startsWith('X')) return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + } + } + try { + process.kill(child.pid, 0) + return false + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } +} + /** Wait synchronously only until the runner reports target start or spawn failure. */ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): RunnerHandshake { - const deadline = Date.now() + 10_000 + const deadline = Date.now() + RUNNER_HANDSHAKE_TIMEOUT_MS while (Date.now() < deadline) { const events = readRunnerEvents(files.eventsPath) const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') if (terminal?.type === 'started') return { pid: terminal.pid, events } if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') + if (runnerExited(child)) throw new Error('native subprocess runner exited before reporting target start') Atomics.wait(handshakeWait, 0, 0, 5) } - throw new Error('native subprocess runner did not report target start within 10000ms') + throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) } async function waitForDirectResult( @@ -85,7 +110,7 @@ async function waitForDirectResult( let wrapperClosed = false void closed.then(() => { wrapperClosed = true }) for (;;) { - const events = readRunnerEvents(files.eventsPath) + const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) @@ -97,7 +122,7 @@ async function waitForDirectResult( if (known !== undefined) return known throw new Error('native subprocess runner exited without a direct-command result') } - await sleepMs(10) + await sleepMs(RUNNER_EVENT_POLL_MS) } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 4cba127ba0..0eab839b9f 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -8,6 +8,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' +import { readFile } from 'node:fs/promises' import { constants as osConstants, tmpdir } from 'node:os' import { join } from 'node:path' @@ -136,6 +137,13 @@ export function appendRunnerEvent(eventsPath: string, event: RunnerEvent): void appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { mode: 0o600 }) } +/** Parse complete newline-terminated runner records. */ +function parseRunnerEvents(content: string): RunnerEvent[] { + const lines = content.split('\n') + if (lines.at(-1) !== '') lines.pop() + return lines.filter(line => line.length > 0).map(parseRunnerEvent) +} + /** * Parse every complete event record currently present. * @param eventsPath - private event file. @@ -149,9 +157,23 @@ export function readRunnerEvents(eventsPath: string): RunnerEvent[] { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] throw error } - const lines = content.split('\n') - if (lines.at(-1) !== '') lines.pop() - return lines.filter(line => line.length > 0).map(parseRunnerEvent) + return parseRunnerEvents(content) +} + +/** + * Asynchronously parse every complete event record currently present. + * @param eventsPath - private event file. + * @returns complete records in append order. + */ +export async function readRunnerEventsAsync(eventsPath: string): Promise { + let content: string + try { + content = await readFile(eventsPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + return parseRunnerEvents(content) } /** diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 22b023a2fb..665bdc9bde 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -466,7 +466,9 @@ export function bindManagedProcess( const terminate = (): void => { if (rangeExitObserved || graceTimer !== undefined) return - void observeRangeExit() + // Keep the shared observation rejection available to waitForExit() without + // leaking an unhandled rejection when a caller only invokes terminate(). + void observeRangeExit().catch(() => {}) kill('SIGTERM') graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } @@ -504,6 +506,10 @@ export function bindManagedProcess( } launch.direct.then((outcome) => { directOutcome = outcome + if (stdoutCollector === undefined && stderrCollector === undefined) { + settle(outcome) + return + } pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) if (wrapperClosed) settle(outcome) }, (error: unknown) => { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 4015decee7..0b2a419d51 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -231,10 +231,43 @@ describe('Linux systemd scope adapter', () => { await expect(launch.owner.waitForExit()).resolves.toBe(true) }) + it('reports a failed scope KILL through the shared wait', async () => { + let wrapper: ReturnType | undefined + const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { + const separator = args.indexOf('--') + wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options) + return wrapper + }) as unknown as typeof spawn + const runSync = vi.fn((command: string, args: readonly string[]) => { + if (command === 'systemctl' && args[1] === 'kill') { + return { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined } + } + return { status: 0, stdout: 'active\n', stderr: '', error: undefined } + }) as unknown as typeof spawnSync + const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { + spawn: run, + spawnSync: runSync, + runnerInvocation: spawnRunnerInvocation(), + }) + void launch.direct.catch(() => {}) + try { + launch.owner.signal('SIGKILL') + await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus') + expect(runSync).toHaveBeenCalledWith( + 'systemctl', + expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']), + expect.any(Object), + ) + } finally { + wrapper?.kill('SIGKILL') + } + }) + it('uses the production command defaults when no Linux internals are supplied', async () => { let wrapper: ReturnType | undefined const run = vi.fn() const runSync = vi.fn() + const runAsync = vi.fn() vi.resetModules() vi.doMock('node:child_process', async (importOriginal) => { const actual = await importOriginal() @@ -243,14 +276,19 @@ describe('Linux systemd scope adapter', () => { wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options) return wrapper }) - runSync.mockImplementation((command: string, args: readonly string[]) => { - if (command === 'systemctl' && args[1] === 'show') { - const active = wrapper?.exitCode === null && wrapper.signalCode === null - return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } - } + runSync.mockImplementation((_command: string, _args: readonly string[]) => { return { status: 0, stdout: '', stderr: '', error: undefined } }) - return { ...actual, spawn: run, spawnSync: runSync } + runAsync.mockImplementation(( + _command: string, + args: readonly string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const active = wrapper?.exitCode === null && wrapper.signalCode === null + callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '') + }) + return { ...actual, execFile: runAsync, spawn: run, spawnSync: runSync } }) try { const defaults = await import('../src/linux-scope.ts') @@ -260,6 +298,7 @@ describe('Linux systemd scope adapter', () => { await expect(launch.owner.waitForExit()).resolves.toBe(true) expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) + expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) } finally { vi.doUnmock('node:child_process') vi.resetModules() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index ff66eaa542..4ea1581fa3 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -117,6 +117,54 @@ describe('managed process binding', () => { } }) + it('publishes direct outcome immediately when no collected stream needs draining', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'ignore', 'ignore'], + }) + const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() + const handle = bindManagedProcess({ + ...spec(1_000), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }, { + child: wrapper, + pid: wrapper.pid as number, + direct: direct.promise, + closed: new Promise(() => {}), + owner: { signal: vi.fn(), waitForExit: async () => true }, + }) + try { + direct.resolve({ exitCode: 23, signal: null }) + const outcome = await Promise.race([ + handle.done, + new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 50)), + ]) + expect(outcome).toEqual({ exitCode: 23, signal: null }) + } finally { + wrapper.kill('SIGKILL') + } + }) + + it('contains background range-observation rejection until waitForExit observes it', async () => { + const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + const failure = new Error('range observation failed') + const handle = bindManagedProcess(spec(), { + child: wrapper, + pid: wrapper.pid as number, + direct: new Promise(() => {}), + closed: new Promise(() => {}), + owner: { signal: vi.fn(), waitForExit: async () => { throw failure } }, + }) + try { + handle.terminate() + await new Promise(resolve => setImmediate(resolve)) + await expect(handle.waitForExit()).rejects.toBe(failure) + } finally { + wrapper.kill('SIGKILL') + } + }) + it('normalizes a non-Error direct rejection', async () => { const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 334b4d9d6b..2c4d323f76 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -17,6 +17,7 @@ import { createRunnerFiles, deserializeSpawnError, readRunnerEvents, + readRunnerEventsAsync, serializeSpawnError, } from '../src/runner-protocol.ts' @@ -126,7 +127,7 @@ describe('spawn runner transport', () => { } }) - it('reads only complete known event records and propagates file errors', () => { + it('reads only complete known event records and propagates file errors', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { expect(readRunnerEvents(files.eventsPath)).toEqual([]) @@ -165,6 +166,7 @@ describe('spawn runner transport', () => { }, }, ]) + await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual(readRunnerEvents(files.eventsPath)) writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"') expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }]) @@ -174,7 +176,9 @@ describe('spawn runner transport', () => { } writeFileSync(files.eventsPath, '{"type":"unknown"}\n') expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event') + await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event') expect(() => readRunnerEvents(files.directory)).toThrow() + await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow() } finally { cleanupRunnerFiles(files) } @@ -287,10 +291,16 @@ describe('spawn runner transport', () => { await expect(missingResult.direct).rejects.toThrow('runner failed to start') expect(existsSync(missingChild.directory)).toBe(false) + const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise(() => {})) + expect(exitedResult.pid).toBe(-1) + await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start') + expect(existsSync(exitedChild.directory)).toBe(false) + const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) try { - const timedOutResult = runnerDirectResult(fakeChild(123), timedOut, new Promise(() => {})) + const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise(() => {})) expect(timedOutResult.pid).toBe(-1) await expect(timedOutResult.direct).rejects.toThrow('did not report target start') expect(existsSync(timedOut.directory)).toBe(false) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 74768ea0f9..762819dcf5 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 5e80bc7205c2f06b3528bbcfcb775941d2fa4545 -README.zh.md: 4744b4aeea07e18a8d149865050f7487f8816061 +README.md: 7972d933cfbb70627cf29e8e44d1f41d873f3075 +README.zh.md: a55ef79e664e4b837e8326bd4b513bfc6a844b75 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 5e80bc7205..7972d933cf 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -6,11 +6,11 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl ## Contract -- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. +- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures. - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 4744b4aeea..a55ef79e66 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,11 +6,11 @@ ## 约定 -- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 +- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 09b56eb76a..0eb0d472b6 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -174,7 +174,7 @@ export interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ + /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree @@ -188,6 +188,7 @@ export interface SubprocessHandle { * child, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. * @returns `true` when the tree exited, `false` when the signal aborted first. + * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise } diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 3b0476965d..a8b66f662e 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: d541b90448a4f42879a62a015374d139aa502395 -README.zh.md: 69bc93ee93d825a03a337976df7302e8f6fffcb6 +README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8 +README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index d541b90448..5d14ead5a8 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Zero-time process and Job probes let the runner publish the direct exit separately and stay alive until default-inheritance descendants leave the Job. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner polling and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -23,7 +23,7 @@ The process, stdio, and Job constants plus selected structure sizes and offsets g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe remains the evidence for the other recorded offsets and constants. +The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants. ## Model Experience diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 69bc93ee93..faa2dc829d 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 与 Job 的 zero-time probe 让 runner 分别发布 direct exit,并一直存活到默认继承 descendant 离开 Job。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner polling 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一直存活到 `ActiveProcesses` 归零。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -25,7 +25,7 @@ process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`ve g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小;其余已记录偏移和常量由该探针提供证据。 +Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。 ## Model Experience diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index e46304657c..d3b2eafcb4 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -28,8 +28,14 @@ export const ERROR_BROKEN_PIPE = 109 export const ERROR_NO_DATA = 232 /** Job limit that terminates every member when the final Job handle closes. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +/** QueryInformationJobObject class for basic accounting and active-process count. */ +export const JobObjectBasicAccountingInformation = 1 /** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */ export const JobObjectExtendedLimitInformation = 9 +/** x64 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION byte size. */ +export const JOBOBJECT_BASIC_ACCOUNTING_SIZE = 48 +/** Byte offset of ActiveProcesses in JOBOBJECT_BASIC_ACCOUNTING_INFORMATION. */ +export const JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET = 40 /** x64 JOBOBJECT_EXTENDED_LIMIT_INFORMATION byte size. */ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 /** Byte offset of BasicLimitInformation.LimitFlags in the extended Job record. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 1fff943741..b1adeff700 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -106,6 +106,13 @@ export interface Win32ProcessBindings { getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + queryInformationJobObject( + job: NativePtr, + cls: number, + information: Buffer, + length: number, + returnLength: null, + ): number assignProcessToJobObject(job: NativePtr, process: NativePtr): number resumeThread(thread: NativePtr): number terminateProcess(process: NativePtr, exitCode: number): number @@ -266,6 +273,9 @@ function bindings(): Win32ProcessBindings { getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + queryInformationJobObject: bind(kernel32, 'QueryInformationJobObject', 'int', [ + PVOID, 'int', PVOID, 'uint32', PVOID, + ]), assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 82988cfc54..76260b5f4f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -493,13 +493,20 @@ export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr): * Return whether a Job has no active processes. * @param api - active binding table. * @param job - caller-owned Job handle. - * @returns true once the Job object is signalled. + * @returns true once the Job reports zero active processes. */ export function isJobEmpty(api: Win32ProcessBindings, job: NativePtr): boolean { - const waitResult = api.waitForSingleObject(job, 0) - if (waitResult === abi.WAIT_TIMEOUT) return false - if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject', 'Job object') - return true + const information = Buffer.alloc(abi.JOBOBJECT_BASIC_ACCOUNTING_SIZE) + if (api.queryInformationJobObject( + job, + abi.JobObjectBasicAccountingInformation, + information, + information.length, + null, + ) === 0) { + throwLastError(api, 'QueryInformationJobObject', 'active process count') + } + return information.readUInt32LE(abi.JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) === 0 } /** diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index b7a732b2fd..3c8f182e27 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -8,7 +8,13 @@ import { terminateJob, Win32Error, } from '../src/index.ts' -import { CREATE_SUSPENDED, WAIT_TIMEOUT } from '../src/abi.ts' +import { + CREATE_SUSPENDED, + JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, + JOBOBJECT_BASIC_ACCOUNTING_SIZE, + JobObjectBasicAccountingInformation, + WAIT_TIMEOUT, +} from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -16,6 +22,10 @@ function api(overrides: Partial = {}): Win32ProcessBinding return { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), + queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { + information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) + return 1 + }), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), setHandleInformation: vi.fn(() => 1), createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { @@ -102,9 +112,23 @@ describe('ordinary Job process operations', () => { }) it('polls direct exit and Job emptiness without blocking', () => { - const running = api({ waitForSingleObject: vi.fn(() => WAIT_TIMEOUT) }) + const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { + information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) + return 1 + }) + const running = api({ + waitForSingleObject: vi.fn(() => WAIT_TIMEOUT), + queryInformationJobObject, + }) expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined() expect(isJobEmpty(running, 50n as NativePtr)).toBe(false) + expect(queryInformationJobObject).toHaveBeenCalledWith( + 50n, + JobObjectBasicAccountingInformation, + expect.objectContaining({ length: JOBOBJECT_BASIC_ACCOUNTING_SIZE }), + JOBOBJECT_BASIC_ACCOUNTING_SIZE, + null, + ) const exited = api() expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) @@ -118,8 +142,8 @@ describe('ordinary Job process operations', () => { const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) }) expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error) - const jobWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) - expect(() => isJobEmpty(jobWait, 50n as NativePtr)).toThrow(Win32Error) + const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) }) + expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error) }) it('checks Job termination and caller-owned handle closure', () => { diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index bb7fb29cfe..3cbb883ccf 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -30,6 +30,9 @@ int wmain() P(ERROR_INSUFFICIENT_BUFFER); P(ERROR_BROKEN_PIPE); P(ERROR_NO_DATA); + P(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION)); + P(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses)); + P((int)JobObjectBasicAccountingInformation); P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); P((int)JobObjectExtendedLimitInformation); @@ -41,6 +44,9 @@ int wmain() static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); + static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); + static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); + static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class"); static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); From b9c0028f95100ccade815d937505302227eba14f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:28:30 +0800 Subject: [PATCH 010/110] fix(subprocess): launch systemd scopes without pipe mode --- packages/subprocess/subprocess-local/src/linux-scope.ts | 2 -- packages/subprocess/subprocess-local/tests/linux-scope.spec.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index f1991865d5..144c2cee49 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -89,7 +89,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { '--scope', '--quiet', '--collect', - '--pipe', '--expand-environment=no', `--unit=${unitStem('dsh-subprocess-probe')}`, '--', @@ -205,7 +204,6 @@ export function launchLinuxScope( '--scope', '--quiet', '--collect', - '--pipe', '--expand-environment=no', `--unit=${unitBase}`, '--', diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 0b2a419d51..c8db25397b 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -23,6 +23,7 @@ describe('Linux systemd scope adapter', () => { }) as unknown as typeof spawnSync expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true) expect(calls[1]).toContain('--expand-environment=no') + expect(calls[1]).not.toContain('--pipe') expect(calls[1]).not.toContain('--wait') const oldSystemd = vi.fn((command: string) => ({ @@ -70,6 +71,7 @@ describe('Linux systemd scope adapter', () => { launch.owner.signal('SIGKILL') expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) expect(systemdArgs).toContain('--expand-environment=no') + expect(systemdArgs).not.toContain('--pipe') expect(systemdArgs).not.toContain('--wait') expect(systemdArgs).not.toContain('literal $VALUE') }) From 0f3db7990294ebf71ce1d38fa248546ea2a102a8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:32:06 +0800 Subject: [PATCH 011/110] docs(subprocess): record native systemd evidence --- .../2026-08-20-subprocess-native-containment.i18n.yaml | 4 ++-- .../bug-fix/2026-08-20-subprocess-native-containment.md | 2 +- .../bug-fix/2026-08-20-subprocess-native-containment.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 54dcc901ff..68b5090748 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 3ccf78e158458de786ee424badcd5fdf4ff88676 -2026-08-20-subprocess-native-containment.zh.md: 2d223575ad2663ff581b4bc8c04c9c5533263a3f +2026-08-20-subprocess-native-containment.md: d51b675556715aa7fe213d72a5f8fd62536fdd69 +2026-08-20-subprocess-native-containment.zh.md: d556efa217e622f2b7123e07497f966dbef21ece diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 3ccf78e158..d51b675556 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -20,7 +20,7 @@ When native capability is unavailable before target execution, the provider warn ## Verification -Linux native evidence covers a real `setsid` descendant and a double-fork daemon whose direct parent exits first. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, Node-shaped spawn failures, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries. +Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 2d223575ad..d556efa217 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -20,7 +20,7 @@ native capability 在目标执行前不可用时,provider 只告警一次并 ## Verification -Linux native 证据覆盖真实 `setsid` descendant,以及 direct parent 先退出的 double-fork daemon。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、Node-shaped spawn failure、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。 +Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。 ## Alternatives considered From b25e790e369ca65623febe68bdb8ebb07e635b45 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:37:52 +0800 Subject: [PATCH 012/110] test(subprocess): cover runner liveness polling --- packages/subprocess/subprocess-local/src/runner-launch.ts | 3 +++ .../subprocess/subprocess-local/tests/spawn-runner.spec.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 95d091d3fe..e83dca6eaa 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -68,6 +68,7 @@ interface RunnerHandshake { function runnerExited(child: ChildProcess): boolean { if (child.exitCode !== null || child.signalCode !== null) return true if (child.pid === undefined) return true + /* v8 ignore start -- Linux zombie detection is exercised by the real user-systemd test environment. */ if (process.platform === 'linux') { try { const stat = readFileSync(`/proc/${String(child.pid)}/stat`, 'utf8') @@ -77,10 +78,12 @@ function runnerExited(child: ChildProcess): boolean { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true } } + /* v8 ignore stop */ try { process.kill(child.pid, 0) return false } catch (error) { + /* v8 ignore next -- EPERM means the known process still exists but is not signalable. */ return (error as NodeJS.ErrnoException).code === 'ESRCH' } } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 2c4d323f76..7cd0baa027 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { existsSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -131,6 +132,7 @@ describe('spawn runner transport', () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { expect(readRunnerEvents(files.eventsPath)).toEqual([]) + await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([]) appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 }) appendRunnerEvent(files.eventsPath, { type: 'runner-error', From f6a56d4882888daed62ec3f6ccb95e7f51ead88c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:41:03 +0800 Subject: [PATCH 013/110] test(subprocess): align native platform fixtures --- .../subprocess-local/tests/linux-scope.spec.ts | 2 +- .../subprocess-local/tests/native-windows.spec.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index c8db25397b..c607f4c439 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -14,7 +14,7 @@ function spec(argv: string[]): SubprocessSpawnSpec { } } -describe('Linux systemd scope adapter', () => { +describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => { it('requires a readable user manager and literal-argument systemd support', () => { const calls: string[][] = [] const runSync = vi.fn((command: string, args: readonly string[]) => { diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 21e7cede91..de955f3e61 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -111,15 +111,15 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { } }) - it('preserves missing-target and direct cmd rejection errors', async () => { + it('preserves missing-target and invalid-executable rejection errors', async () => { const missing = spec([`missing-native-target-${Date.now()}.exe`]) const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing)) await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) - const cmd = join(scratch, `direct-${Date.now()}.cmd`) - writeFileSync(cmd, '@exit /b 0\r\n') - const directCmd = spec([cmd]) - const cmdHandle = bindManagedProcess(directCmd, launchWindowsJob(directCmd)) - await expect(cmdHandle.done).rejects.toMatchObject({ code: 'EINVAL' }) + const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) + writeFileSync(invalidExecutable, 'not a Windows executable\r\n') + const invalid = spec([invalidExecutable]) + const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) + await expect(invalidHandle.done).rejects.toMatchObject({ code: 'EINVAL' }) }) }) From 6df3ef4f97a145adef0494118012d33097dad945 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 22:46:49 +0800 Subject: [PATCH 014/110] refactor(subprocess): pass runner pid to liveness probe --- .../subprocess/subprocess-local/src/runner-launch.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index e83dca6eaa..084d9c1053 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -65,13 +65,12 @@ interface RunnerHandshake { } /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ -function runnerExited(child: ChildProcess): boolean { +function runnerExited(child: ChildProcess, pid: number): boolean { if (child.exitCode !== null || child.signalCode !== null) return true - if (child.pid === undefined) return true /* v8 ignore start -- Linux zombie detection is exercised by the real user-systemd test environment. */ if (process.platform === 'linux') { try { - const stat = readFileSync(`/proc/${String(child.pid)}/stat`, 'utf8') + const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8') const suffix = stat.slice(stat.lastIndexOf(')') + 2) if (suffix.startsWith('Z') || suffix.startsWith('X')) return true } catch (error) { @@ -80,7 +79,7 @@ function runnerExited(child: ChildProcess): boolean { } /* v8 ignore stop */ try { - process.kill(child.pid, 0) + process.kill(pid, 0) return false } catch (error) { /* v8 ignore next -- EPERM means the known process still exists but is not signalable. */ @@ -97,7 +96,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner if (terminal?.type === 'started') return { pid: terminal.pid, events } if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') - if (runnerExited(child)) throw new Error('native subprocess runner exited before reporting target start') + if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') Atomics.wait(handshakeWait, 0, 0, 5) } throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) From 436f9e96861271d464ac2d321030683477b80b2d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 23:24:22 +0800 Subject: [PATCH 015/110] fix(subprocess): preserve native failure semantics --- .../subprocess-local/src/linux-scope.ts | 34 ++++-------- .../subprocess-local/src/spawn-runner.ts | 4 +- .../tests/linux-scope.spec.ts | 54 ++++++++++++++++--- .../tests/native-windows.spec.ts | 15 ++++-- 4 files changed, 71 insertions(+), 36 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 144c2cee49..b479ee9223 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -50,20 +50,6 @@ function querySystemctl(command: string, args: readonly string[]): Promise { - const result = runSync(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }) - return Promise.resolve({ - status: result.status, - stdout: typeof result.stdout === 'string' ? result.stdout : '', - stderr: typeof result.stderr === 'string' ? result.stderr : '', - ...result.error === undefined ? {} : { error: result.error }, - }) -} - function unitStem(prefix: string): string { return `${prefix}-${process.pid}-${randomBytes(6).toString('hex')}` } @@ -130,12 +116,8 @@ class SystemdScopeOwner implements BoundProcessOwner { if (signal === 'SIGKILL') this.killConfirmed = true return } - const output = `${result.stdout}\n${result.stderr}` - if (/not found|could not be found|no such/iu.test(output)) { - this.stopped = true - return - } if (signal === 'SIGKILL') { + const output = `${result.stdout}\n${result.stderr}` this.killFailure = result.error ?? new Error( `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`, ) @@ -143,7 +125,6 @@ class SystemdScopeOwner implements BoundProcessOwner { } private async active(): Promise { - if (this.killFailure !== undefined) throw this.killFailure const result = await this.query(this.systemctl, [ '--user', 'show', @@ -154,14 +135,19 @@ class SystemdScopeOwner implements BoundProcessOwner { const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { if (/not found|could not be found|no such/iu.test(output)) { - return this.runner.exitCode === null && this.runner.signalCode === null + const runnerActive = this.runner.exitCode === null && this.runner.signalCode === null + if (runnerActive && this.killFailure !== undefined) throw this.killFailure + return runnerActive } if (result.error !== undefined) throw result.error throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) } const state = result.stdout.trim() if (state === 'inactive' || state === 'failed') return false - if (state === 'active' || state === 'activating' || state === 'deactivating') return true + if (state === 'active' || state === 'activating' || state === 'deactivating') { + if (this.killFailure !== undefined) throw this.killFailure + return true + } throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) } @@ -191,9 +177,7 @@ export function launchLinuxScope( ): ManagedProcessLaunch { const run = internals.spawn ?? spawn const runSync = internals.spawnSync ?? spawnSync - const query = internals.systemctlQuery ?? (internals.spawnSync === undefined - ? querySystemctl - : (command, args) => syncQuerySystemctl(runSync, command, args)) + const query = internals.systemctlQuery ?? querySystemctl const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 04a4df5ffa..99c9968796 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -46,9 +46,9 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 ? 'ENOENT' : error.win32Code === 5 - ? 'EACCES' + ? 'EPERM' : error.win32Code === 193 - ? 'EINVAL' + ? 'EFTYPE' : 'UNKNOWN' const program = request.argv[0] as string return { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index c607f4c439..2cef1b9be6 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -14,6 +14,18 @@ function spec(argv: string[]): SubprocessSpawnSpec { } } +function asyncQuery(runSync: typeof spawnSync) { + return async (command: string, args: readonly string[]) => { + const result = runSync(command, [...args], { encoding: 'utf8', timeout: 5_000 }) + return { + status: result.status, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + stderr: typeof result.stderr === 'string' ? result.stderr : '', + ...result.error === undefined ? {} : { error: result.error }, + } + } +} + describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => { it('requires a readable user manager and literal-argument systemd support', () => { const calls: string[][] = [] @@ -62,6 +74,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null }) @@ -76,7 +89,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(systemdArgs).not.toContain('literal $VALUE') }) - it('uses the authoritative SIGKILL scope signal when the runner cannot report after force kill', async () => { + it('still escalates after a missing-unit TERM response and uses the authoritative scope KILL', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') @@ -85,8 +98,11 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () return wrapper }) as unknown as typeof spawn const runSync = vi.fn((command: string, args: readonly string[]) => { - if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) { - process.kill(-wrapper.pid, 'SIGKILL') + if (command === 'systemctl' && args[1] === 'kill') { + if (args.includes('--signal=SIGTERM')) { + return { status: 1, stdout: '', stderr: 'Unit could not be found', error: undefined } + } + if (wrapper?.pid !== undefined) process.kill(-wrapper.pid, 'SIGKILL') } if (command === 'systemctl' && args[1] === 'show') { const active = wrapper?.exitCode === null && wrapper.signalCode === null @@ -97,8 +113,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) + launch.owner.signal('SIGTERM') launch.owner.signal('SIGKILL') await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(launch.owner.waitForExit()).resolves.toBe(true) @@ -118,6 +136,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) @@ -133,6 +152,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync, + systemctlQuery: async () => ({ status: null, stdout: '', stderr: '', error: failure }), runnerInvocation: spawnRunnerInvocation(), }) await expect(failedRead.owner.waitForExit()).rejects.toBe(failure) @@ -141,6 +161,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync, + systemctlQuery: async () => ({ status: 0, stdout: 'reloading\n', stderr: '' }), runnerInvocation: spawnRunnerInvocation(), }) await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState') @@ -149,6 +170,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync, + systemctlQuery: async () => ({ status: 1, stdout: '', stderr: '' }), runnerInvocation: spawnRunnerInvocation(), }) await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1') @@ -177,6 +199,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.owner.waitForExit()).resolves.toBe(true) @@ -199,6 +222,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.owner.waitForExit()).resolves.toBe(true) @@ -226,6 +250,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) launch.owner.signal('SIGTERM') @@ -233,7 +258,23 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () await expect(launch.owner.waitForExit()).resolves.toBe(true) }) - it('reports a failed scope KILL through the shared wait', async () => { + it.each([ + [ + 'execution error', + { status: null, stdout: '', stderr: '', error: new Error('systemctl execution failed') }, + 'systemctl execution failed', + ], + [ + 'stderr', + { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined }, + 'Failed to connect to bus', + ], + [ + 'exit status', + { status: 1, stdout: '', stderr: '', error: undefined }, + 'exit 1', + ], + ])('reports a failed scope KILL through the shared wait: %s', async (_label, failure, message) => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') @@ -242,19 +283,20 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) as unknown as typeof spawn const runSync = vi.fn((command: string, args: readonly string[]) => { if (command === 'systemctl' && args[1] === 'kill') { - return { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined } + return failure } return { status: 0, stdout: 'active\n', stderr: '', error: undefined } }) as unknown as typeof spawnSync const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { spawn: run, spawnSync: runSync, + systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) void launch.direct.catch(() => {}) try { launch.owner.signal('SIGKILL') - await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus') + await expect(launch.owner.waitForExit()).rejects.toThrow(message) expect(runSync).toHaveBeenCalledWith( 'systemctl', expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index de955f3e61..d35e060769 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -55,6 +55,14 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } +function directSpawnFailure(argv: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) + child.once('error', resolve) + child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) + }) +} + const windowsNative = process.platform === 'win32' && probeWindowsJob() describe.skipIf(!windowsNative)('Windows Job native containment', () => { @@ -63,7 +71,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const script = ` const { spawn } = require('node:child_process') const { writeFileSync } = require('node:fs') - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: true }) writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) setInterval(() => {}, 1000) ` @@ -118,8 +126,9 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) writeFileSync(invalidExecutable, 'not a Windows executable\r\n') + const directError = await directSpawnFailure([invalidExecutable]) const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) - await expect(invalidHandle.done).rejects.toMatchObject({ code: 'EINVAL' }) + await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) }) }) From 60c729250e5059e5f447058b8c012bac90ef5ead Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 23:34:13 +0800 Subject: [PATCH 016/110] test(subprocess): preserve Windows job descendant --- .../subprocess/subprocess-local/tests/native-windows.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index d35e060769..ba922f53b6 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -94,7 +94,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const script = ` const { spawn } = require('node:child_process') const { writeFileSync } = require('node:fs') - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }) + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: true }) writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) writeFileSync(${JSON.stringify(factsFile)}, JSON.stringify({ cwd: process.cwd(), value: process.env.TARGET_VALUE, arg: process.argv[1] })) child.unref() From 21820b6816930d26fd285115b38d2336294e0bf6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 23:47:19 +0800 Subject: [PATCH 017/110] test(subprocess): cover async scope failures --- .../subprocess-local/src/linux-scope.ts | 24 +++++++++---------- .../tests/linux-scope.spec.ts | 15 ++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index b479ee9223..b43e9d711e 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -135,20 +135,20 @@ class SystemdScopeOwner implements BoundProcessOwner { const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { if (/not found|could not be found|no such/iu.test(output)) { - const runnerActive = this.runner.exitCode === null && this.runner.signalCode === null - if (runnerActive && this.killFailure !== undefined) throw this.killFailure - return runnerActive + if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false + } else { + if (result.error !== undefined) throw result.error + throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) + } + } else { + const state = result.stdout.trim() + if (state === 'inactive' || state === 'failed') return false + if (state !== 'active' && state !== 'activating' && state !== 'deactivating') { + throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) } - if (result.error !== undefined) throw result.error - throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) } - const state = result.stdout.trim() - if (state === 'inactive' || state === 'failed') return false - if (state === 'active' || state === 'activating' || state === 'deactivating') { - if (this.killFailure !== undefined) throw this.killFailure - return true - } - throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) + if (this.killFailure !== undefined) throw this.killFailure + return true } async waitForExit(signal?: AbortSignal): Promise { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 2cef1b9be6..cc9c8d55a4 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -309,6 +309,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () it('uses the production command defaults when no Linux internals are supplied', async () => { let wrapper: ReturnType | undefined + let queryFailure: (Error & { code?: string | number }) | undefined const run = vi.fn() const runSync = vi.fn() const runAsync = vi.fn() @@ -329,6 +330,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void, ) => { + if (queryFailure !== undefined) { + callback(queryFailure, '', '') + return + } const active = wrapper?.exitCode === null && wrapper.signalCode === null callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '') }) @@ -343,6 +348,16 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) + + queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 }) + const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) + await expect(numericFailure.owner.waitForExit()).rejects.toBe(queryFailure) + await expect(numericFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) + + queryFailure = Object.assign(new Error('named systemctl failure'), { code: 'EQUERY' }) + const namedFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) + await expect(namedFailure.owner.waitForExit()).rejects.toBe(queryFailure) + await expect(namedFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) } finally { vi.doUnmock('node:child_process') vi.resetModules() From 1fc04feb771d99929e741ea7887a87faf2fb4d6f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 20 Aug 2026 23:59:55 +0800 Subject: [PATCH 018/110] fix(subprocess): close native platform CI gaps --- .../subprocess/subprocess-local/src/linux-scope.ts | 2 +- .../subprocess-local/tests/native-windows.spec.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index b43e9d711e..cd6f8d7deb 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -41,7 +41,7 @@ function querySystemctl(command: string, args: readonly string[]): Promise { const code = error === null ? 0 : (error as Error & { code?: string | number }).code resolve({ - status: typeof code === 'number' ? code : error === null ? 0 : null, + status: typeof code === 'number' ? code : null, stdout, stderr, ...error === null ? {} : { error }, diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index ba922f53b6..349e7e562a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -57,9 +57,13 @@ function cleanup(pid: number): void { function directSpawnFailure(argv: string[]): Promise { return new Promise((resolve, reject) => { - const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) - child.once('error', resolve) - child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) + try { + const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) + child.once('error', resolve) + child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) + } catch (error) { + resolve(error as NodeJS.ErrnoException) + } }) } From 273c2847e536e4a4710b2e7370660c48e6282eed Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 01:17:12 +0800 Subject: [PATCH 019/110] fix(subprocess): support packaged native runner --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 4 +- ...-08-20-subprocess-native-containment.zh.md | 4 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 2 +- docs/subsystems/subprocess.zh.md | 2 +- packages/examples/jsonrpc-demo/package.json | 3 +- .../examples/jsonrpc-demo/src/packaged-bin.ts | 12 +++- packages/examples/jsonrpc-demo/tsconfig.json | 3 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../subprocess-local/src/linux-scope.ts | 26 ++++++-- .../subprocess-local/src/runner-launch.ts | 2 + .../subprocess-local/src/spawn-runner.ts | 6 +- .../tests/linux-scope.spec.ts | 24 +++++-- .../tests/spawn-runner.spec.ts | 23 +++++++ packages/subprocess/subprocess/src/index.ts | 5 +- pnpm-lock.yaml | 3 + scripts/smoke-python-runtime.py | 65 ++++++++++++++++++- 21 files changed, 168 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index b8bcc4246e..439f9af151 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 9b2707b3298c0649b7b2aed94980c0b7acee5c5c +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f21e22cfe89106f24beedf22ff1904b5160201e8 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 40433d99e5..9b2707b329 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -28,7 +28,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Before config discovery, the packaged entry recognizes one reserved internal argument and dispatches it to the `dsh-subprocess-local` runner, allowing native subprocess containment to re-enter the same executable without assuming `process.execPath` is a general Node binary. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg first invokes the private subprocess runner through the built single-file executable, then drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cff72ae10e..f21e22cfe8 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -28,7 +28,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。在配置发现前,打包入口会识别一个保留的内部参数并转入 `dsh-subprocess-local` runner,使 native subprocess containment 可以重新进入同一个可执行文件,而不假定 `process.execPath` 是通用 Node 二进制。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都会先通过构建出的单文件可执行程序调用 private subprocess runner,再使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 68b5090748..a71c9f0293 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: d51b675556715aa7fe213d72a5f8fd62536fdd69 -2026-08-20-subprocess-native-containment.zh.md: d556efa217e622f2b7123e07497f966dbef21ece +2026-08-20-subprocess-native-containment.md: 45183c2c381da24433bfc20052a603beefd0afc5 +2026-08-20-subprocess-native-containment.zh.md: 4ead6b59502306117203b834b9151fc0fe2018b0 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index d51b675556..45183c2c38 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -14,13 +14,13 @@ 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, TERM-to-KILL escalation, and host-exit registration. `.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. 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, while the runner remains until both 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; scope KILL is itself authoritative when no runner result can survive. Windows target descendants inherit the Job by default, while the runner remains until both 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. ## Verification -Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and both source and built runner entries. +Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index d556efa217..4ead6b5950 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -14,13 +14,13 @@ Status: implemented common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.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。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;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 无法保留 runner result 时,该 KILL 事实本身就是权威结果。Windows target descendant 默认继承 Job;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 重放。 ## Verification -Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source 和 built runner entry。 +Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 ## Alternatives considered diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 895f8b39c2..159d9ace5f 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: a2b0a5575b28d3a0ba859fc56d0a2ae7be008175 -subprocess.zh.md: b923cad98bb3dd999c6f4ca8fa5c7e160cebe774 +subprocess.md: bb73f045cda88d3dd46443e9376e4a1991a08a42 +subprocess.zh.md: 063c125eb3270c5a493a4894919ea58c1a3de024 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index a2b0a5575b..bb73f045cd 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index b923cad98b..063c125eb3 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -282,7 +282,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 181ae8ab76..ed5ab27052 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -45,7 +45,8 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^" + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts index 4ca60cde4c..dea0bdb090 100644 --- a/packages/examples/jsonrpc-demo/src/packaged-bin.ts +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -8,5 +8,13 @@ import { runJsonrpcAgent } from './runner.ts' -/* v8 ignore next -- exercised through the built Python runtime carriers */ -await runJsonrpcAgent(import.meta.url) +/* v8 ignore start -- exercised through the built Python runtime carriers */ +const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' + +if (process.argv[2] === PACKAGED_RUNNER_ARG) { + process.argv.splice(2, 1) + await import('@deepseek-ai/dsh-subprocess-local/spawn-runner') +} else { + await runJsonrpcAgent(import.meta.url) +} +/* v8 ignore stop */ diff --git a/packages/examples/jsonrpc-demo/tsconfig.json b/packages/examples/jsonrpc-demo/tsconfig.json index ffd1ce9e41..ba3265f756 100644 --- a/packages/examples/jsonrpc-demo/tsconfig.json +++ b/packages/examples/jsonrpc-demo/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../boot/app-boot" }, + { + "path": "../../subprocess/subprocess-local" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c821662e85..f3c6fd979c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index cd6f8d7deb..89ef9a9f73 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -35,10 +35,19 @@ interface SystemctlResult { const SYSTEMCTL_TIMEOUT_MS = 5_000 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' } +} function querySystemctl(command: string, args: readonly string[]): Promise { return new Promise((resolve) => { - execFile(command, [...args], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }, (error, stdout, stderr) => { + execFile(command, [...args], { + encoding: 'utf8', + env: systemctlEnv(), + timeout: SYSTEMCTL_TIMEOUT_MS, + }, (error, stdout, stderr) => { const code = error === null ? 0 : (error as Error & { code?: string | number }).code resolve({ status: typeof code === 'number' ? code : null, @@ -61,11 +70,15 @@ function unitStem(prefix: string): string { */ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { const runSync = internals.spawnSync ?? spawnSync + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const [runnerCommand, ...runnerPrefix] = invocation + if (runnerCommand === undefined) return false const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' const timeout = 5_000 const manager = runSync(systemctl, ['--user', 'show-environment'], { encoding: 'utf8', + env: systemctlEnv(), stdio: 'ignore', timeout, }) @@ -78,9 +91,10 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { '--expand-environment=no', `--unit=${unitStem('dsh-subprocess-probe')}`, '--', - process.execPath, - '-e', - '', + runnerCommand, + ...runnerPrefix, + '--mode', + 'probe-node', ], { env: childEnv(), stdio: 'ignore', @@ -111,7 +125,7 @@ class SystemdScopeOwner implements BoundProcessOwner { '--kill-whom=all', `--signal=${signal}`, this.unit, - ], { encoding: 'utf8', timeout: SYSTEMCTL_TIMEOUT_MS }) + ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) if (result.error === undefined && result.status === 0) { if (signal === 'SIGKILL') this.killConfirmed = true return @@ -134,7 +148,7 @@ class SystemdScopeOwner implements BoundProcessOwner { ]) const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { - if (/not found|could not be found|no such/iu.test(output)) { + if (MISSING_UNIT.test(output)) { if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false } else { if (result.error !== undefined) throw result.error diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 084d9c1053..69f2d3880c 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -18,12 +18,14 @@ import { childEnv } from './spawn.ts' const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 +const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' /** * Resolve the built runner in production or its source entry in repository execution. * @returns Node executable and runner argv prefix. */ export function spawnRunnerInvocation(): string[] { + if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) if (existsSync(builtEntry)) return [process.execPath, builtEntry] const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 99c9968796..59bcf15d05 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -19,6 +19,7 @@ import { import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = + | { mode: 'probe-node' } | { mode: 'probe-win32' } | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } @@ -35,7 +36,7 @@ function parseArgs(argv: string[]): RunnerArgs { else if (key === '--events') eventsPath = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } - if (mode === 'probe-win32') return { mode } + if (mode === 'probe-node' || mode === 'probe-win32') return { mode } if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') return { mode, requestPath, eventsPath } @@ -167,6 +168,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { const args = parseArgs(process.argv.slice(2)) + if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { loadWin32ProcessBindings() return @@ -185,7 +187,7 @@ async function main(): Promise { main().catch((error: unknown) => { try { const args = parseArgs(process.argv.slice(2)) - if (args.mode !== 'probe-win32') { + if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) } } catch { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index cc9c8d55a4..3865713c7a 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -29,14 +29,25 @@ 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 calls: string[][] = [] - const runSync = vi.fn((command: string, args: readonly string[]) => { + const environments: Array = [] + const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { calls.push([command, ...args]) + environments.push(options?.env) return { status: 0, error: undefined } }) as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl' })).toBe(true) + 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') const oldSystemd = vi.fn((command: string) => ({ status: command === 'systemctl' ? 0 : 1, @@ -130,7 +141,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const runSync = vi.fn(() => ({ status: 1, stdout: '', - stderr: 'Failed to connect to bus', + stderr: 'Failed to connect to bus: No such file or directory', error: undefined, })) as unknown as typeof spawnSync const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { @@ -300,7 +311,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(runSync).toHaveBeenCalledWith( 'systemctl', expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']), - expect.any(Object), + expect.objectContaining({ env: expect.objectContaining({ LC_ALL: 'C' }) }), ) } finally { wrapper?.kill('SIGKILL') @@ -313,6 +324,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () const run = vi.fn() const runSync = vi.fn() const runAsync = vi.fn() + const queryEnvironments: Array = [] vi.resetModules() vi.doMock('node:child_process', async (importOriginal) => { const actual = await importOriginal() @@ -327,9 +339,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () runAsync.mockImplementation(( _command: string, args: readonly string[], - _options: unknown, + options: { env?: NodeJS.ProcessEnv }, callback: (error: Error | null, stdout: string, stderr: string) => void, ) => { + queryEnvironments.push(options.env) if (queryFailure !== undefined) { callback(queryFailure, '', '') return @@ -348,6 +361,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) + expect(queryEnvironments[0]?.LC_ALL).toBe('C') queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 }) const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 7cd0baa027..135aa419d4 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -10,6 +10,7 @@ import { runnerDirectResult, runnerFiles, runnerStdio, + spawnRunnerInvocation, } from '../src/runner-launch.ts' import { appendRunnerEvent, @@ -85,6 +86,28 @@ describe('spawn runner transport', () => { } }) + it('re-enters a packaged executable through its private runner dispatch', () => { + const packagedProcess = process as NodeJS.Process & { pkg?: unknown } + const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') + Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} }) + try { + expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner']) + } finally { + if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg') + else Object.defineProperty(packagedProcess, 'pkg', original) + } + }) + + it('supports the node runner capability probe', () => { + const result = spawnSync(sourceInvocation[0] as string, [ + ...sourceInvocation.slice(1), + '--mode', + 'probe-node', + ], { encoding: 'utf8', timeout: 10_000 }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + }) + it('maps every target stdio disposition and optional IPC channel', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index a7413159f9..2ba5baa9fc 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -80,8 +80,9 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Executable paths belong to one execution world shared with the mounted * filesystem provider. - * - {@link spawn} returns immediately with a live handle; `done` resolves at - * process close with exit facts and rejects only for spawn-level failures. + * - {@link spawn} returns a live handle synchronously after provider-specific + * setup needed to publish its target pid. `done` resolves with direct-process + * exit facts and may reject for spawn or selected provider-runner failures. * - Collect-mode readers are offset-based and non-consuming, so independent * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53dd8aa315..f89301f556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4088,6 +4088,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index f001adccb2..852a7df950 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -679,19 +679,26 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "runner", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "runner", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, fs-search, snapshot, runner, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") + if args.scenario in {"all", "runner"}: + assert args.exe is not None + smoke_packaged_runner(args.exe.resolve()) + if args.scenario == "runner": + print("smoke-python-runtime: runner passed") + return + with MockModel() as model: if args.scenario in {"all", "sdk-default"}: smoke_sdk_default(model.url) @@ -946,6 +953,58 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def smoke_packaged_runner(executable: Path) -> None: + """Exercise the private subprocess runner through the single-file entry.""" + with tempfile.TemporaryDirectory(prefix="dsh-packaged-runner-") as temporary: + root = Path(temporary).resolve() + request_path = root / "request.json" + events_path = root / "events.ndjson" + probe = subprocess.run( + [str(executable), "--dsh-internal-subprocess-runner", "--mode", "probe-node"], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if probe.returncode != 0: + raise AssertionError(f"packaged runner probe failed: {probe.stderr}") + + request_path.write_text(json.dumps({ + "argv": [sys.executable, "-c", "import sys; sys.exit(7)"], + "cwd": str(root), + "env": {}, + })) + result = subprocess.run( + [ + str(executable), + "--dsh-internal-subprocess-runner", + "--mode", + "node", + "--request", + str(request_path), + "--events", + str(events_path), + ], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 7: + raise AssertionError( + f"packaged runner returned {result.returncode}, expected 7; stderr: {result.stderr}" + ) + events = [json.loads(line) for line in events_path.read_text().splitlines()] + if len(events) != 2 or events[0].get("type") != "started" or events[1] != { + "type": "exit", + "exitCode": 7, + "signal": None, + }: + raise AssertionError(f"packaged runner emitted unexpected events: {events}") + + def is_idle_notification(message: dict[str, object]) -> bool: """Return whether a JSON-RPC notification marks a session idle.""" params = message.get("params") From 97e0c9607d8fdc7d4599fd9bedc9dcab078cfedd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 01:55:20 +0800 Subject: [PATCH 020/110] test(subprocess): cover native probe fallback --- .../tests/linux-scope.spec.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 3865713c7a..30847d2da3 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -62,6 +62,10 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(probeLinuxScope({ spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, })).toBe(false) + + const emptyInvocation = vi.fn() as unknown as typeof spawnSync + expect(probeLinuxScope({ spawnSync: emptyInvocation, runnerInvocation: [] })).toBe(false) + expect(emptyInvocation).not.toHaveBeenCalled() }) it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { @@ -292,12 +296,17 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options) return wrapper }) as unknown as typeof spawn - const runSync = vi.fn((command: string, args: readonly string[]) => { + const runSyncMock = vi.fn(( + command: string, + args: readonly string[], + _options?: { env?: NodeJS.ProcessEnv }, + ) => { if (command === 'systemctl' && args[1] === 'kill') { return failure } return { status: 0, stdout: 'active\n', stderr: '', error: undefined } - }) as unknown as typeof spawnSync + }) + const runSync = runSyncMock as unknown as typeof spawnSync const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { spawn: run, spawnSync: runSync, @@ -308,11 +317,11 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () try { launch.owner.signal('SIGKILL') await expect(launch.owner.waitForExit()).rejects.toThrow(message) - expect(runSync).toHaveBeenCalledWith( - 'systemctl', - expect.arrayContaining(['kill', '--kill-whom=all', '--signal=SIGKILL']), - expect.objectContaining({ env: expect.objectContaining({ LC_ALL: 'C' }) }), - ) + const killCall = runSyncMock.mock.calls.find(([, args]) => args.includes('--signal=SIGKILL')) + expect(killCall?.[0]).toBe('systemctl') + expect(killCall?.[1]).toContain('kill') + expect(killCall?.[1]).toContain('--kill-whom=all') + expect(killCall?.[2]?.env?.LC_ALL).toBe('C') } finally { wrapper?.kill('SIGKILL') } From 81374783d3ee6c2a3ad95d6ed3ead8af1bf71895 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 02:57:53 +0800 Subject: [PATCH 021/110] fix(subprocess): close native runner lifecycle gaps --- .../subprocess-local/src/managed-owner.ts | 10 ++- .../subprocess-local/src/runner-launch.ts | 22 +++-- .../tests/spawn-runner.spec.ts | 86 +++++++++++++------ 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index a622b36429..945701dbe8 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -21,12 +21,18 @@ export interface ManagedProcessLaunch { } /** - * Observe wrapper close from the moment it is spawned. + * Observe wrapper close from the moment it is spawned and contain its error + * event while the runner-result path converts launch failures into rejection. * @param child - direct child or native wrapper. * @returns promise settled by the ChildProcess close event. */ export function observeChildClose(child: ChildProcess): Promise { - return new Promise((resolve) => { child.once('close', () => { resolve() }) }) + return new Promise((resolve) => { + child.once('error', () => { + // runnerDirectResult reports the wrapper failure through the handle. + }) + child.once('close', () => { resolve() }) + }) } /** diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 69f2d3880c..725d5e2fbe 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,7 +1,8 @@ /** Parent-side launch and direct-result transport for native runners. */ import type { ChildProcess, StdioOptions } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' +import { extname } from 'node:path' import { fileURLToPath } from 'node:url' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -21,15 +22,18 @@ const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' /** - * Resolve the built runner in production or its source entry in repository execution. + * Resolve the runner entry from the current module's source or built plane. + * @param moduleUrl - executing module URL; defaults to this module. * @returns Node executable and runner argv prefix. */ -export function spawnRunnerInvocation(): string[] { +export function spawnRunnerInvocation(moduleUrl = import.meta.url): string[] { if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] + if (extname(fileURLToPath(moduleUrl)) === '.ts') { + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] + } const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) - if (existsSync(builtEntry)) return [process.execPath, builtEntry] - const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) - return [process.execPath, '--import', 'tsx/esm', sourceEntry] + return [process.execPath, builtEntry] } /** @@ -114,6 +118,10 @@ async function waitForDirectResult( let wrapperClosed = false void closed.then(() => { wrapperClosed = true }) for (;;) { + // A read started before close may return a stale snapshot after close has + // become visible. Only a read started after close can prove no terminal + // event was written before the runner exited. + const closedBeforeRead = wrapperClosed const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } @@ -121,7 +129,7 @@ async function waitForDirectResult( } seen = Math.max(seen, events.length, initial.length) // oxlint-disable-next-line typescript/no-unnecessary-condition -- child close mutates this flag asynchronously. - if (wrapperClosed) { + if (closedBeforeRead) { const known = missingResult?.() if (known !== undefined) return known throw new Error('native subprocess runner exited without a direct-command result') diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 135aa419d4..1481ae820c 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { existsSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' @@ -12,6 +12,7 @@ import { runnerStdio, spawnRunnerInvocation, } from '../src/runner-launch.ts' +import { observeChildClose } from '../src/managed-owner.ts' import { appendRunnerEvent, cleanupRunnerFiles, @@ -59,31 +60,13 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string } describe('spawn runner transport', () => { - it('selects built and source runner entries according to artifact availability', async () => { - vi.resetModules() - vi.doMock('node:fs', async importOriginal => ({ - ...await importOriginal(), - existsSync: () => true, - })) - try { - const built = await import('../src/runner-launch.ts') - expect(built.spawnRunnerInvocation()).toEqual([process.execPath, builtEntry]) - } finally { - vi.doUnmock('node:fs') - vi.resetModules() - } - - vi.doMock('node:fs', async importOriginal => ({ - ...await importOriginal(), - existsSync: () => false, - })) - try { - const source = await import('../src/runner-launch.ts') - expect(source.spawnRunnerInvocation()).toEqual(sourceInvocation) - } finally { - vi.doUnmock('node:fs') - vi.resetModules() - } + it('selects the runner entry from the current execution plane', () => { + expect(spawnRunnerInvocation(new URL('../lib/index.js', import.meta.url).href)).toEqual([ + process.execPath, + builtEntry, + ]) + expect(spawnRunnerInvocation(new URL('../src/runner-launch.ts', import.meta.url).href)).toEqual(sourceInvocation) + expect(spawnRunnerInvocation()).toEqual(sourceInvocation) }) it('re-enters a packaged executable through its private runner dispatch', () => { @@ -309,6 +292,57 @@ describe('spawn runner transport', () => { } }) + it('requires an event snapshot started after wrapper close before reporting a missing result', async () => { + const staleRead = Promise.withResolvers>>() + let readCount = 0 + vi.resetModules() + vi.doMock('../src/runner-protocol.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readRunnerEventsAsync: vi.fn(async (eventsPath: string) => { + readCount += 1 + if (readCount === 1) return staleRead.promise + return actual.readRunnerEvents(eventsPath) + }), + } + }) + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) + const closed = Promise.withResolvers() + const isolated = await import('../src/runner-launch.ts') + const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) + expect(readCount).toBe(1) + closed.resolve() + await Promise.resolve() + appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) + staleRead.resolve([{ type: 'started', pid: 456 }]) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + expect(readCount).toBe(2) + } finally { + cleanupRunnerFiles(files) + vi.doUnmock('../src/runner-protocol.ts') + vi.resetModules() + } + }) + + it('contains wrapper spawn errors while publishing the runner startup rejection', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], { + stdio: 'ignore', + }) + const closed = observeChildClose(child) + const result = runnerDirectResult(child, files, closed) + expect(result.pid).toBe(-1) + await expect(result.direct).rejects.toThrow('runner failed to start') + await expect(closed).resolves.toBeUndefined() + } finally { + cleanupRunnerFiles(files) + } + }) + it('reports runner startup failure and handshake timeout without leaking request files', async () => { const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise(() => {})) From 63b038e469c7a5f680fa5927bc29c42eab76091d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 02:59:09 +0800 Subject: [PATCH 022/110] fix(subprocess): preserve close snapshot state --- packages/subprocess/subprocess-local/src/runner-launch.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 725d5e2fbe..453b7cb76b 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -115,20 +115,19 @@ async function waitForDirectResult( missingResult?: () => SubprocessOutcome | undefined, ): Promise { let seen = 0 - let wrapperClosed = false - void closed.then(() => { wrapperClosed = true }) + const wrapperState = { closed: false } + void closed.then(() => { wrapperState.closed = true }) for (;;) { // A read started before close may return a stale snapshot after close has // become visible. Only a read started after close can prove no terminal // event was written before the runner exited. - const closedBeforeRead = wrapperClosed + const closedBeforeRead = wrapperState.closed const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) } seen = Math.max(seen, events.length, initial.length) - // oxlint-disable-next-line typescript/no-unnecessary-condition -- child close mutates this flag asynchronously. if (closedBeforeRead) { const known = missingResult?.() if (known !== undefined) return known From 2c0ebebdf8967ab56d3cea6e666629e0f320e76f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 03:00:48 +0800 Subject: [PATCH 023/110] test(subprocess): type close interleaving fixture --- packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 1481ae820c..41ac6c5e6e 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -314,7 +314,7 @@ describe('spawn runner transport', () => { const isolated = await import('../src/runner-launch.ts') const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) expect(readCount).toBe(1) - closed.resolve() + closed.resolve(undefined) await Promise.resolve() appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) staleRead.resolve([{ type: 'started', pid: 456 }]) From 7fec86d6a1080259eeb787b688f182bb67d02006 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 03:25:17 +0800 Subject: [PATCH 024/110] refactor(subprocess): derive runner execution plane --- .../subprocess-local/src/runner-launch.ts | 16 +++++++++------- .../subprocess-local/tests/spawn-runner.spec.ts | 9 +-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 453b7cb76b..f09da5adca 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -23,17 +23,19 @@ const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' /** * Resolve the runner entry from the current module's source or built plane. - * @param moduleUrl - executing module URL; defaults to this module. * @returns Node executable and runner argv prefix. */ -export function spawnRunnerInvocation(moduleUrl = import.meta.url): string[] { +export function spawnRunnerInvocation(): string[] { if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] - if (extname(fileURLToPath(moduleUrl)) === '.ts') { - const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) - return [process.execPath, '--import', 'tsx/esm', sourceEntry] + /* v8 ignore start -- source-plane coverage cannot execute the bundled module; + the required built-runner smoke executes its published entry. */ + if (extname(fileURLToPath(import.meta.url)) !== '.ts') { + const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) + return [process.execPath, builtEntry] } - const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) - return [process.execPath, builtEntry] + /* v8 ignore stop */ + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] } /** diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 41ac6c5e6e..c54d831c43 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -30,8 +30,6 @@ const sourceInvocation = [ 'tsx/esm', fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')), ] -const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) - function spec(overrides: Partial = {}): SubprocessSpawnSpec { return { argv: [process.execPath, '-e', ''], @@ -60,12 +58,7 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string } describe('spawn runner transport', () => { - it('selects the runner entry from the current execution plane', () => { - expect(spawnRunnerInvocation(new URL('../lib/index.js', import.meta.url).href)).toEqual([ - process.execPath, - builtEntry, - ]) - expect(spawnRunnerInvocation(new URL('../src/runner-launch.ts', import.meta.url).href)).toEqual(sourceInvocation) + it('selects the source runner from source-plane execution', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) }) From e6bc1f2e1d90dc2a36ab25a5995ebde82a75de6c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 05:08:36 +0800 Subject: [PATCH 025/110] fix(subprocess): close managed launch gaps --- ...20-subprocess-native-containment.i18n.yaml | 4 ++-- ...026-08-20-subprocess-native-containment.md | 2 +- ...-08-20-subprocess-native-containment.zh.md | 2 +- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 ++-- ...2026-07-22-cross-platform-test-fixtures.md | 4 ++-- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 ++-- docs/subsystems/subprocess.i18n.yaml | 4 ++-- docs/subsystems/subprocess.md | 20 +++++++++--------- docs/subsystems/subprocess.zh.md | 20 +++++++++--------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../subprocess-local/README.i18n.yaml | 4 ++-- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 21 +++++++++++++------ .../subprocess-local/src/linux-scope.ts | 2 +- .../subprocess-local/src/spawn-runner.ts | 16 ++++++++++++-- .../subprocess/subprocess-local/src/spawn.ts | 16 ++++++++++++-- .../tests/linux-scope.spec.ts | 16 ++++++++++++++ .../subprocess-local/tests/local.spec.ts | 9 +++++++- .../tests/native-windows.spec.ts | 14 ++++++++++++- .../subprocess/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess/README.md | 4 ++-- packages/subprocess/subprocess/README.zh.md | 4 ++-- packages/subprocess/subprocess/src/index.ts | 9 ++++---- packages/subprocess/subprocess/src/types.ts | 18 ++++++++-------- 25 files changed, 138 insertions(+), 69 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index a71c9f0293..57eb79d5c0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 45183c2c381da24433bfc20052a603beefd0afc5 -2026-08-20-subprocess-native-containment.zh.md: 4ead6b59502306117203b834b9151fc0fe2018b0 +2026-08-20-subprocess-native-containment.md: 18306a23f7e14ec0e06b7f96005473461dd390da +2026-08-20-subprocess-native-containment.zh.md: 440c62cfc31402b4dfaba10e99d1ec7f770cc31b diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 45183c2c38..18306a23f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.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, TERM-to-KILL escalation, and host-exit registration. `.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, while the runner remains until both 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; 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. 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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 4ead6b5950..440c62cfc3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.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 处理、TERM-to-KILL 升级与 host-exit 注册。`.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;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 无法保留 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。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index dcc666c622..fa6988b3fa 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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/testing/2026-07-22-cross-platform-test-fixtures.md -2026-07-22-cross-platform-test-fixtures.md: 9114481543d6cae1661cbed70868eddb2faa09fc -2026-07-22-cross-platform-test-fixtures.zh.md: 710ec5887f02b5c3c71d69f16ab726323501ac92 +2026-07-22-cross-platform-test-fixtures.md: f2fdbcac1d19089fcb2bd7f9f02c21a27b96f020 +2026-07-22-cross-platform-test-fixtures.zh.md: c3975c82c05d9b92553b76ba1f7549fab3e94547 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 9114481543..f2fdbcac1d 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown delegates to the subprocess provider's managed range: supported local Linux uses a user-systemd scope and Windows uses a kill-on-close Job, while explicit fallbacks use a negative process-group id or synchronous `taskkill /T /F`. See the [ordinary subprocess native-containment decision](../bug-fix/2026-08-20-subprocess-native-containment.md). Windows fallback suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Supported native Windows hosts use Job ownership; fallback Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed. A successful synchronous fallback result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 710ec5887f..c3975c82c0 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会委托给 subprocess provider 的 managed range:受支持的本地 Linux 使用 user-systemd scope,Windows 使用 kill-on-close Job;明确的 fallback 才使用负数进程组 ID 或同步 `taskkill /T /F`。参见[普通子进程 native containment 决策](../bug-fix/2026-08-20-subprocess-native-containment.zh.md)。Windows fallback 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受支持的原生 Windows 宿主使用 Job 所有权;fallback Windows 的资源清理则在协议级优雅关停失败后依赖宿主的 `taskkill` 命令。同步 fallback 成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 159d9ace5f..ad7a6f3674 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: bb73f045cda88d3dd46443e9376e4a1991a08a42 -subprocess.zh.md: 063c125eb3270c5a493a4894919ea58c1a3de024 +subprocess.md: 410bf5f25bcb8d15e826d66a0be1e1625cf524ce +subprocess.zh.md: 16b207db40adf22e4b4e345c566fc29e63c4240e diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index bb73f045cd..410bf5f25b 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -135,7 +135,7 @@ A spawn returns a live handle synchronously after any provider-specific setup ne ```ts type-equiv /** - * A live child process rooted in its own process tree. Collected output + * A live direct child and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed @@ -143,7 +143,7 @@ A spawn returns a live handle synchronously after any provider-specific setup ne * weaker platform fallbacks are disclosed by the provider. */ interface SubprocessHandle { - /** Process id (tree root); -1 when the spawn itself failed. */ + /** Direct target process id; -1 when the spawn itself failed. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined @@ -156,17 +156,17 @@ interface SubprocessHandle { /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), - * and also triggered by the spec's abort signal. + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed + * range (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once that range is gone, and also triggered by + * the spec's abort signal. */ terminate(): void /** - * Wait until the process tree has exited — the tree, not just the direct - * child, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the direct + * child exits, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. - * @returns `true` when the tree exited, `false` when the signal aborted first. + * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 063c125eb3..16b207db40 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -135,7 +135,7 @@ spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步 ```ts type-equiv /** - * A live child process rooted in its own process tree. Collected output + * A live direct child and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed @@ -143,7 +143,7 @@ spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步 * weaker platform fallbacks are disclosed by the provider. */ interface SubprocessHandle { - /** Process id (tree root); -1 when the spawn itself failed. */ + /** Direct target process id; -1 when the spawn itself failed. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined @@ -156,17 +156,17 @@ interface SubprocessHandle { /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), - * and also triggered by the spec's abort signal. + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed + * range (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once that range is gone, and also triggered by + * the spec's abort signal. */ terminate(): void /** - * Wait until the process tree has exited — the tree, not just the direct - * child, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the direct + * child exits, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. - * @returns `true` when the tree exited, `false` when the signal aborted first. + * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f3c6fd979c..4d470ea618 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 4df1352204..c55afea29e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 8cb0a4a6eae741c8073462ca4f861149e2a82597 -README.zh.md: e9726758761e969aa70feb595753ca37863783fc +README.md: 4b89aaa6c85c14cb74d54993a013762b8a868bf0 +README.zh.md: 7f939e32627c42443507f0519786c812a426e5a5 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 8cb0a4a6ea..4b89aaa6c8 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. `terminate()` sends TERM then KILL through that owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, only collected pipes retain the existing bounded drain grace, and raw/inherited stdio does not delay direct settlement. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. `terminate()` sends TERM then KILL through the owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e972675876..7f939e3262 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期,raw/inherited stdio 不会延迟 direct settlement。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index b60200ef57..575d778dd0 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -21,7 +21,13 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -import { bindManagedProcess, childEnv, spawnSubprocess, validateSubprocessSpec } from './spawn.ts' +import { + bindManagedProcess, + childEnv, + prepareManagedProcessBinding, + spawnSubprocess, + validateSubprocessSpec, +} from './spawn.ts' import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' @@ -150,11 +156,14 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { validateSubprocessSpec(spec) const mode = this.selectOrdinaryMode() - const handle = mode === 'linux-scope' - ? bindManagedProcess(spec, launchLinuxScope(spec), this.internals) - : mode === 'windows-job' - ? bindManagedProcess(spec, launchWindowsJob(spec), this.internals) - : spawnSubprocess(spec, this.internals) + let handle: LocalSubprocessHandle + if (mode === 'fallback') { + handle = spawnSubprocess(spec, this.internals) + } else { + const binding = prepareManagedProcessBinding(this.internals) + const launch = mode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec) + handle = bindManagedProcess(spec, launch, binding) + } this.live.add(handle) // Release ownership only once the whole TREE is gone, not at direct-child // settlement — a TERM-trapping helper that outlives the leader must stay diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 89ef9a9f73..24f7bb947b 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -149,7 +149,7 @@ class SystemdScopeOwner implements BoundProcessOwner { const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { if (MISSING_UNIT.test(output)) { - if (this.runner.exitCode !== null || this.runner.signalCode !== null) return false + if (this.runner.pid === undefined || this.runner.exitCode !== null || this.runner.signalCode !== null) return false } else { if (result.error !== undefined) throw result.error throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 59bcf15d05..1636ce0fe5 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,6 +1,7 @@ /** Native managed-range runner for ordinary local subprocesses. */ import { spawn } from 'node:child_process' +import { closeSync } from 'node:fs' import { closeHandleChecked, isJobEmpty, @@ -96,6 +97,17 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } +/** Release the runner's copies after the Windows target inherits its standard handles. */ +function releaseRunnerStdio(): void { + for (const fd of [0, 1, 2]) { + try { + closeSync(fd) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error + } + } +} + async function runWin32(request: RunnerRequest, eventsPath: string): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() @@ -114,8 +126,6 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { if (terminationRequested || jobHandle === undefined) return @@ -126,6 +136,8 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise((resolve, reject) => { const timer = setInterval(() => { diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 665bdc9bde..d8f7aef25f 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -93,6 +93,17 @@ function privateSpillDir(): string { return defaultSpillDir } +/** + * Prepare fallible output storage before starting a managed native process. + * @param internals - optional caller-owned spill directory. + * @returns binding inputs whose spill directory is ready for use. + */ +export function prepareManagedProcessBinding( + internals: Pick = {}, +): { spillDir: string } { + return { spillDir: internals.spillDir ?? privateSpillDir() } +} + /** * Collects one stream with a bounded in-memory tail. With a spill cap, on * first overflow a spill file is created and every chunk (including those @@ -420,7 +431,7 @@ export function bindManagedProcess( internals: Pick = {}, ): LocalSubprocessHandle { validateSubprocessSpec(spec) - const spillDir = internals.spillDir ?? privateSpillDir() + const { spillDir } = prepareManagedProcessBinding(internals) const child = launch.child const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => @@ -564,6 +575,7 @@ export function bindManagedProcess( */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { validateSubprocessSpec(spec) + const binding = prepareManagedProcessBinding(internals) const platform = internals.platform ?? process.platform const [program, ...args] = spec.argv const child = spawn(program as string, args, { @@ -587,5 +599,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, direct, ) - return bindManagedProcess(spec, { child, pid, direct, closed, owner }, internals) + return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 30847d2da3..ffbc047518 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -245,6 +245,22 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(runSyncMock.mock.calls.length).toBeGreaterThan(1) }) + it('settles a missing scope immediately when the wrapper never started', async () => { + const launch = launchLinuxScope(spec([process.execPath, '-e', '']), { + systemdRun: `missing-systemd-run-${String(process.pid)}-${String(Date.now())}`, + systemctlQuery: async () => ({ + status: 1, + stdout: '', + stderr: 'Unit dsh-subprocess-missing.scope could not be found', + }), + runnerInvocation: spawnRunnerInvocation(), + }) + expect(launch.child.pid).toBeUndefined() + await expect(launch.direct).rejects.toThrow('runner failed to start') + await expect(launch.owner.waitForExit()).resolves.toBe(true) + await launch.closed + }) + it('does not fabricate a direct outcome after a non-forced scope signal', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 2696e986ad..e7e24ca07b 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -452,6 +452,7 @@ describe('LocalSubprocessRuntime', () => { const launchWindowsJob = vi.fn(() => windowsLaunch) const probeLinuxScope = vi.fn(() => true) const probeWindowsJob = vi.fn(() => true) + const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' })) let nextPid = 100 const handles = [true, false, false].map((failFirstWait) => { let waits = 0 @@ -468,7 +469,7 @@ describe('LocalSubprocessRuntime', () => { }), } }) - const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown) => { + const bindManagedProcess = vi.fn((_spec: unknown, _launch: unknown, _binding: unknown) => { const handle = handles.shift() if (handle === undefined) throw new Error('missing fake handle') return handle @@ -481,6 +482,7 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/spawn.ts', async importOriginal => ({ ...await importOriginal(), bindManagedProcess, + prepareManagedProcessBinding, spawnSubprocess, })) const fibers: Array<{ dispose(): Promise }> = [] @@ -491,6 +493,10 @@ describe('LocalSubprocessRuntime', () => { fibers.push(linuxFiber) const linuxRuntime = linuxContext.subprocess as InstanceType linuxRuntime.internals = { platform: 'linux' } + const preparationFailure = new Error('spill directory unavailable') + prepareManagedProcessBinding.mockImplementationOnce(() => { throw preparationFailure }) + expect(() => linuxRuntime.spawn(spec('true'))).toThrow(preparationFailure) + expect(launchLinuxScope).not.toHaveBeenCalled() await linuxRuntime.spawn(spec('true')).done await new Promise(resolve => setImmediate(resolve)) await linuxRuntime.spawn(spec('true')).done @@ -512,6 +518,7 @@ describe('LocalSubprocessRuntime', () => { linuxLaunch, windowsLaunch, ]) + expect(prepareManagedProcessBinding).toHaveBeenCalledTimes(4) expect(spawnSubprocess).not.toHaveBeenCalled() } finally { for (const fiber of fibers.reverse()) await fiber.dispose() diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 349e7e562a..e796cd7886 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -104,11 +104,23 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { child.unref() process.exit(42) ` - const request = spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }) + const request = { + ...spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }), + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, + } const handle = bindManagedProcess(request, launchWindowsJob(request)) const descendant = await waitForPid(pidFile) try { + if (handle.stdout === undefined) throw new Error('expected piped stdout') + const stdoutEnded = Promise.race([ + new Promise((resolve, reject) => { + handle.stdout?.once('end', () => { resolve(true) }) + handle.stdout?.once('error', reject) + }), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + ]) await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) + await expect(stdoutEnded).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, value: 'explicit', diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 762819dcf5..bb7039ce5c 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 7972d933cfbb70627cf29e8e44d1f41d873f3075 -README.zh.md: a55ef79e664e4b837e8326bd4b513bfc6a844b75 +README.md: 174e9732d4e7fc2e2589d8f653c5f866873d277c +README.zh.md: 6336600888d2c4eb324d21a118b95d7ddb447811 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 7972d933cf..174e9732d4 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -6,11 +6,11 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl ## Contract -- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures. +- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves with direct-process exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures. - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. Supported local Linux and Windows providers use a user-systemd scope or kill-on-close Job; weaker fallbacks use a detached POSIX process group or `taskkill /T`. `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a55ef79e66..6336600888 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,11 +6,11 @@ ## 约定 -- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。 +- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 以 direct-process exit facts resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。受支持的本地 Linux 与 Windows provider 使用 user-systemd scope 或 kill-on-close Job;较弱 fallback 使用 detached POSIX 进程组或 `taskkill /T`。`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 2ba5baa9fc..73eccf5d69 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -88,10 +88,11 @@ declare module '@deepseek-ai/cordis' { * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL — the only termination verb — tree-scoped on every - * platform. {@link SubprocessHandle.waitForExit} observes whole-tree - * liveness, so a consumer-owned teardown ladder can hold each tier on real - * quiescence. + * SIGTERM→grace→SIGKILL — the only termination verb — against the provider's + * managed range. Supported local Linux and Windows providers use an OS-owned + * scope or Job; weaker fallbacks use a detached process group or direct-parent + * tree. {@link SubprocessHandle.waitForExit} observes that same range so a + * consumer-owned teardown ladder can hold each tier on real quiescence. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 0eb0d472b6..05330a4937 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -156,7 +156,7 @@ export interface SubprocessCollectedOutputs { } /** - * A live child process rooted in its own process tree. Collected output + * A live direct child and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed @@ -164,7 +164,7 @@ export interface SubprocessCollectedOutputs { * weaker platform fallbacks are disclosed by the provider. */ export interface SubprocessHandle { - /** Process id (tree root); -1 when the spawn itself failed. */ + /** Direct target process id; -1 when the spawn itself failed. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined @@ -177,17 +177,17 @@ export interface SubprocessHandle { /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), - * and also triggered by the spec's abort signal. + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed + * range (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once that range is gone, and also triggered by + * the spec's abort signal. */ terminate(): void /** - * Wait until the process tree has exited — the tree, not just the direct - * child, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the direct + * child exits, so a still-running helper is observable before teardown returns. * @param signal - optional bound for the wait. - * @returns `true` when the tree exited, `false` when the signal aborted first. + * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. */ waitForExit(signal?: AbortSignal): Promise From bf5a8e42a37d2f2124991826182c560aef26058f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 05:42:12 +0800 Subject: [PATCH 026/110] fix(subprocess): keep shared handle semantics provider-neutral --- docs/subsystems/subprocess.i18n.yaml | 4 ++-- docs/subsystems/subprocess.md | 14 +++++++------- docs/subsystems/subprocess.zh.md | 14 +++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../tests/native-windows.spec.ts | 16 ++++++++-------- packages/subprocess/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess/README.md | 8 ++++---- packages/subprocess/subprocess/README.zh.md | 8 ++++---- packages/subprocess/subprocess/src/index.ts | 14 +++++++------- packages/subprocess/subprocess/src/types.ts | 8 ++++---- 10 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index ad7a6f3674..3f486f9888 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 410bf5f25bcb8d15e826d66a0be1e1625cf524ce -subprocess.zh.md: 16b207db40adf22e4b4e345c566fc29e63c4240e +subprocess.md: 240788ab2b7e3d996093073c086bbcc847b5eebd +subprocess.zh.md: 73720cde04c7acc49dd1a734cdf71422ff401b44 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 410bf5f25b..240788ab2b 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -131,19 +131,19 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and managed-range termination -A spawn returns a live handle synchronously after any provider-specific setup needed to publish its target pid. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one managed range: supported local Linux and Windows providers use an OS-owned scope or Job, while weaker fallbacks are disclosed. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). +A spawn returns a live handle synchronously; the provider may publish its process identity later. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one provider-managed range whose identity, observation limits, and weaker fallbacks belong to the provider. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** - * A live direct child and its provider-managed process range. Collected output + * A live subprocess and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Supported Linux and Windows hosts use an OS-owned scope or Job; - * weaker platform fallbacks are disclosed by the provider. + * range. Each provider documents the process identity and range it can + * observe. */ interface SubprocessHandle { - /** Direct target process id; -1 when the spawn itself failed. */ + /** Provider-published process identifier; -1 while unavailable or after startup fails. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined @@ -282,9 +282,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. +- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 16b207db40..73720cde04 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -131,19 +131,19 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与 managed-range 终止 -spawn 会在完成发布 target pid 所需的 provider-specific setup 后同步返回活动句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 managed range:受支持的本地 Linux 与 Windows provider 使用 OS-owned scope 或 Job,并明确披露较弱 fallback。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 +spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 provider-managed range,其标识、观察限制与较弱 fallback 均归 provider 定义。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 ```ts type-equiv /** - * A live direct child and its provider-managed process range. Collected output + * A live subprocess and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Supported Linux and Windows hosts use an OS-owned scope or Job; - * weaker platform fallbacks are disclosed by the provider. + * range. Each provider documents the process identity and range it can + * observe. */ interface SubprocessHandle { - /** Direct target process id; -1 when the spawn itself failed. */ + /** Provider-published process identifier; -1 while unavailable or after startup fails. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined @@ -282,9 +282,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures. +- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or selected provider-runner failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4d470ea618..23db946081 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously after provider-specific setup needed to publish its target pid. `done` resolves with direct-process exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. Supported local Linux and Windows providers use an OS-owned scope or Job; weaker fallbacks use a detached process group or direct-parent tree. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index e796cd7886..e8f58c73ac 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -109,16 +109,16 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, } const handle = bindManagedProcess(request, launchWindowsJob(request)) + if (handle.stdout === undefined) throw new Error('expected piped stdout') + const stdoutEnded = Promise.race([ + new Promise((resolve, reject) => { + handle.stdout?.once('end', () => { resolve(true) }) + handle.stdout?.once('error', reject) + }), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + ]) const descendant = await waitForPid(pidFile) try { - if (handle.stdout === undefined) throw new Error('expected piped stdout') - const stdoutEnded = Promise.race([ - new Promise((resolve, reject) => { - handle.stdout?.once('end', () => { resolve(true) }) - handle.stdout?.once('error', reject) - }), - new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), - ]) await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(stdoutEnded).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index bb7039ce5c..1197fdd7fb 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 174e9732d4e7fc2e2589d8f653c5f866873d277c -README.zh.md: 6336600888d2c4eb324d21a118b95d7ddb447811 +README.md: 56b5a7e05013e7e57319e4a336dcd22f3f23747d +README.zh.md: 895f4cce14fdaa67ad08cf4e3e95a6fa783a5afe diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 174e9732d4..56b5a7e050 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -The subprocess seam (`ctx.subprocess`) is the process half of one execution world. The abstract `SubprocessRuntime` exposes executable lookup, ordinary managed `spawn`, and one terminal-process primitive; its vocabulary covers raw/collected stdio, process and terminal handles, exit facts, tree/session cleanup, and the managed `DSH_*` environment namespace. The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). +The subprocess seam (`ctx.subprocess`) is the process half of one execution world. The abstract `SubprocessRuntime` exposes executable lookup, ordinary managed `spawn`, and one terminal-process primitive; its vocabulary covers raw/collected stdio, process and terminal handles, exit facts, managed-range/session cleanup, and the managed `DSH_*` environment namespace. The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). ## Contract -- `spawn(spec)` returns a live handle synchronously; a native provider may first complete its bounded setup handshake so the handle exposes the target pid. `done` resolves with direct-process exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected native-runner failures. +- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`, which remains `-1` while unavailable or after startup fails. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected provider-runner failures. - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination and waiting use one provider-managed range. Supported local Linux and Windows providers use a user-systemd scope or kill-on-close Job; weaker fallbacks use a detached POSIX process group or `taskkill /T`. `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. The wait rejects when a selected native owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Each provider documents how it defines and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. @@ -28,4 +28,4 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **SDK-managed spawns remain outside** — an SDK transport that owns its internal spawn cannot route that call through this service; it can still import `scrubbedParentEnv` so environment policy stays single-sourced. -- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). +- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the managed-range wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 6336600888..895f4cce14 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -子进程 seam(`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessRuntime` 公开可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树/会话清理,以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.zh.md)。 +子进程 seam(`ctx.subprocess`)是一个执行世界的进程部分。抽象的 `SubprocessRuntime` 公开可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、受管范围/会话清理,以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.zh.md)。 ## 约定 -- `spawn(spec)` 同步返回活动句柄;native provider 可以先完成有界 setup handshake,使该句柄公开 target pid。`done` 以 direct-process exit facts resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 native runner 失败时 reject。 +- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;尚不可用或启动失败后,其值为 `-1`。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 provider runner 失败时 reject。 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止与等待使用同一个 provider-managed range。受支持的本地 Linux 与 Windows provider 使用 user-systemd scope 或 kill-on-close Job;较弱 fallback 使用 detached POSIX 进程组或 `taskkill /T`。`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。所选 native owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。每个 provider 记录该范围的定义、观察方式与较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 @@ -28,4 +28,4 @@ ## 已知限制与暂缓事项 - **由 SDK 管理的 spawn 仍在服务之外**:拥有内部 spawn 的 SDK 传输无法把该调用路由到本服务;它仍可导入 `scrubbedParentEnv`,使环境策略保持单一来源。 -- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合方式(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 +- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与受管范围等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合方式(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 73eccf5d69..086eac6d4d 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -80,19 +80,19 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Executable paths belong to one execution world shared with the mounted * filesystem provider. - * - {@link spawn} returns a live handle synchronously after provider-specific - * setup needed to publish its target pid. `done` resolves with direct-process - * exit facts and may reject for spawn or selected provider-runner failures. + * - {@link spawn} returns a live handle synchronously. Its pid is provider-owned + * and may remain unavailable during asynchronous startup. `done` resolves with + * the spawned command's exit facts and may reject for spawn or selected + * provider-runner failures. * - Collect-mode readers are offset-based and non-consuming, so independent * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates * SIGTERM→grace→SIGKILL — the only termination verb — against the provider's - * managed range. Supported local Linux and Windows providers use an OS-owned - * scope or Job; weaker fallbacks use a detached process group or direct-parent - * tree. {@link SubprocessHandle.waitForExit} observes that same range so a - * consumer-owned teardown ladder can hold each tier on real quiescence. + * managed range. {@link SubprocessHandle.waitForExit} observes that same range + * so a consumer-owned teardown ladder can hold each tier on real quiescence; + * each provider documents its identity and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 05330a4937..429b47e29a 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -156,15 +156,15 @@ export interface SubprocessCollectedOutputs { } /** - * A live direct child and its provider-managed process range. Collected output + * A live subprocess and its provider-managed process range. Collected output * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Supported Linux and Windows hosts use an OS-owned scope or Job; - * weaker platform fallbacks are disclosed by the provider. + * range. Each provider documents the process identity and range it can + * observe. */ export interface SubprocessHandle { - /** Direct target process id; -1 when the spawn itself failed. */ + /** Provider-published process identifier; -1 while unavailable or after startup fails. */ readonly pid: number /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined From c7d228c737a6a0485a30a0e1da605641fd451566 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 05:58:53 +0800 Subject: [PATCH 027/110] test(subprocess): make Windows EOF proof deterministic --- docs/subsystems/subprocess.i18n.yaml | 4 ++-- docs/subsystems/subprocess.md | 17 ++++++++--------- docs/subsystems/subprocess.zh.md | 17 ++++++++--------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../tests/native-windows.spec.ts | 16 ++++++++-------- packages/subprocess/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 4 ++-- packages/subprocess/subprocess/src/types.ts | 15 +++++++-------- 10 files changed, 40 insertions(+), 43 deletions(-) diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 3f486f9888..a3b4cfad4d 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 240788ab2b7e3d996093073c086bbcc847b5eebd -subprocess.zh.md: 73720cde04c7acc49dd1a734cdf71422ff401b44 +subprocess.md: 49101b0e8a4762bd975e837bb5c703d694b887a0 +subprocess.zh.md: 814474dabb91362f6ff7f5a7a9d38994fc5bb17b diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 240788ab2b..49101b0e8a 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -113,7 +113,7 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the process tree when + * Abort signal — starts the terminate escalation on the managed range when * it fires. The caller owns deadlines and cause classification; this seam * only reacts to the abort. */ @@ -153,18 +153,17 @@ interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ + /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed - * range (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once that range is gone, and also triggered by - * the spec's abort signal. + * Begin the provider's termination escalation on the managed range — the + * seam's only termination verb. Idempotent, a no-op once that range is gone, + * and also triggered by the spec's abort signal. */ terminate(): void /** - * Wait until the same managed range is empty — not just until the direct - * child exits, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the spawned + * command reports its outcome, so surviving work remains observable. * @param signal - optional bound for the wait. * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. @@ -282,7 +281,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or selected provider-runner failures. +- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 73720cde04..814474dabb 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -113,7 +113,7 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the process tree when + * Abort signal — starts the terminate escalation on the managed range when * it fires. The caller owns deadlines and cause classification; this seam * only reacts to the abort. */ @@ -153,18 +153,17 @@ interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ + /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed - * range (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once that range is gone, and also triggered by - * the spec's abort signal. + * Begin the provider's termination escalation on the managed range — the + * seam's only termination verb. Idempotent, a no-op once that range is gone, + * and also triggered by the spec's abort signal. */ terminate(): void /** - * Wait until the same managed range is empty — not just until the direct - * child exits, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the spawned + * command reports its outcome, so surviving work remains observable. * @param signal - optional bound for the wait. * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. @@ -282,7 +281,7 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or selected provider-runner failures. +- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. - SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 23db946081..71daad1d13 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or selected provider-runner failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index e8f58c73ac..c89b46581a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -110,17 +110,17 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { } const handle = bindManagedProcess(request, launchWindowsJob(request)) if (handle.stdout === undefined) throw new Error('expected piped stdout') - const stdoutEnded = Promise.race([ - new Promise((resolve, reject) => { - handle.stdout?.once('end', () => { resolve(true) }) - handle.stdout?.once('error', reject) - }), - new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), - ]) + const stdoutEnded = new Promise((resolve, reject) => { + handle.stdout?.once('end', resolve) + handle.stdout?.once('error', reject) + }) const descendant = await waitForPid(pidFile) try { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) - await expect(stdoutEnded).resolves.toBe(true) + await expect(Promise.race([ + stdoutEnded.then(() => true), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, value: 'explicit', diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 1197fdd7fb..5a6cd6846f 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 56b5a7e05013e7e57319e4a336dcd22f3f23747d -README.zh.md: 895f4cce14fdaa67ad08cf4e3e95a6fa783a5afe +README.md: b7d38f9ab415608de7f249b5ed9081e47cb1d4a1 +README.zh.md: 9f81c60abf0ce733dbe0e0c3c7704a57cf194321 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 56b5a7e050..b7d38f9ab4 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -6,7 +6,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl ## Contract -- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`, which remains `-1` while unavailable or after startup fails. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn-level or selected provider-runner failures. +- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`, which remains `-1` while unavailable or after startup fails. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn or provider failures. - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 895f4cce14..9f81c60abf 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,7 +6,7 @@ ## 约定 -- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;尚不可用或启动失败后,其值为 `-1`。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 层面或所选 provider runner 失败时 reject。 +- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;尚不可用或启动失败后,其值为 `-1`。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 或 provider 失败时 reject。 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 086eac6d4d..c91649cbb4 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -82,8 +82,8 @@ declare module '@deepseek-ai/cordis' { * filesystem provider. * - {@link spawn} returns a live handle synchronously. Its pid is provider-owned * and may remain unavailable during asynchronous startup. `done` resolves with - * the spawned command's exit facts and may reject for spawn or selected - * provider-runner failures. + * the spawned command's exit facts and may reject for spawn or provider + * failures. * - Collect-mode readers are offset-based and non-consuming, so independent * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 429b47e29a..6709e53118 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -88,7 +88,7 @@ export interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the process tree when + * Abort signal — starts the terminate escalation on the managed range when * it fires. The caller owns deadlines and cause classification; this seam * only reacts to the abort. */ @@ -174,18 +174,17 @@ export interface SubprocessHandle { readonly stderr: Readable | undefined /** Offset-based readers for collect-mode streams (also readable after exit). */ readonly collected: SubprocessCollectedOutputs - /** Resolves with direct-process exit facts; rejects for spawn or selected native-runner failures. */ + /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the provider-managed - * range (Windows force-terminates immediately) — the seam's only termination - * verb. Idempotent, a no-op once that range is gone, and also triggered by - * the spec's abort signal. + * Begin the provider's termination escalation on the managed range — the + * seam's only termination verb. Idempotent, a no-op once that range is gone, + * and also triggered by the spec's abort signal. */ terminate(): void /** - * Wait until the same managed range is empty — not just until the direct - * child exits, so a still-running helper is observable before teardown returns. + * Wait until the same managed range is empty — not just until the spawned + * command reports its outcome, so surviving work remains observable. * @param signal - optional bound for the wait. * @returns `true` when the managed range is empty, `false` when the signal aborted first. * @throws when the selected provider can no longer observe its managed range. From 3f2f381209ab4db701a8275bec6ed66d7952fdc0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 06:20:25 +0800 Subject: [PATCH 028/110] docs(subprocess): align provider-owned termination semantics --- ...20-subprocess-native-containment.i18n.yaml | 4 +-- ...026-08-20-subprocess-native-containment.md | 4 +-- ...-08-20-subprocess-native-containment.zh.md | 4 +-- docs/subsystems/subprocess.i18n.yaml | 4 +-- docs/subsystems/subprocess.md | 21 ++++++++------- docs/subsystems/subprocess.zh.md | 21 ++++++++------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../subprocess-local/README.i18n.yaml | 4 +-- .../subprocess/subprocess-local/README.md | 8 +++--- .../subprocess/subprocess-local/README.zh.md | 8 +++--- .../subprocess/subprocess-local/src/index.ts | 26 +++++++++---------- .../subprocess/subprocess-local/src/spawn.ts | 7 ++--- .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 12 ++++----- packages/subprocess/subprocess/src/types.ts | 17 ++++++------ 17 files changed, 77 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 57eb79d5c0..a3cf978af0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 18306a23f7e14ec0e06b7f96005473461dd390da -2026-08-20-subprocess-native-containment.zh.md: 440c62cfc31402b4dfaba10e99d1ec7f770cc31b +2026-08-20-subprocess-native-containment.md: d5a405cddaa2878ca67e7b057d78c79297de26ca +2026-08-20-subprocess-native-containment.zh.md: abd450fe840e4e1e2f388cf40cc24f6b5b7b8860 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 18306a23f7..d5a405cdda 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -12,7 +12,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct- `LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. -The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, TERM-to-KILL escalation, and host-exit registration. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. +The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; 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. @@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The synchronous public spawn contract then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 440c62cfc3..abd450fe84 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -12,7 +12,7 @@ Status: implemented `LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 -common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、TERM-to-KILL 升级与 host-exit 注册。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 +common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 无法保留 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。 @@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。同步公共 spawn 合同随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index a3b4cfad4d..8b51742982 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 49101b0e8a4762bd975e837bb5c703d694b887a0 -subprocess.zh.md: 814474dabb91362f6ff7f5a7a9d38994fc5bb17b +subprocess.md: 701d70629587c202c50047fb88158f39179920d1 +subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 49101b0e8a..701d706295 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -106,10 +106,11 @@ interface SubprocessSpawnSpec { stdio: SubprocessStdio /** * Positive finite grace period in milliseconds, no greater than - * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation - * and for draining still-open collected pipes after the process exits (an - * inherited descriptor held by a surviving descendant cannot hold the - * outcome open indefinitely). + * `MAX_TIMER_DELAY_MS`, available to the provider's termination procedure + * and used for draining still-open collected pipes after the process exits + * (an inherited descriptor held by a survivor cannot hold the outcome open + * indefinitely). Providers document whether range termination is staged or + * immediate. */ graceMs: number /** @@ -131,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and managed-range termination -A spawn returns a live handle synchronously; the provider may publish its process identity later. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` and `waitForExit()` use one provider-managed range whose identity, observation limits, and weaker fallbacks belong to the provider. The only termination verb escalates SIGTERM→grace→SIGKILL, so a consumer can build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). +A spawn returns a live handle synchronously; the provider may publish its process identity later. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` starts the provider's documented procedure, and `waitForExit()` observes the same provider-managed range; staged providers may use `graceMs`, while immediate providers do not delay. Consumers can build their own teardown ladders over those two operations (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -156,9 +157,9 @@ interface SubprocessHandle { /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the provider's termination escalation on the managed range — the - * seam's only termination verb. Idempotent, a no-op once that range is gone, - * and also triggered by the spec's abort signal. + * Begin the provider's documented termination procedure on the managed range + * — the seam's only termination verb. Idempotent, a no-op once that range is + * gone, and also triggered by the spec's abort signal. */ terminate(): void /** @@ -245,7 +246,7 @@ The terminal spec fully specifies argv, cwd, environment overrides, dimensions, ## Service behavior -The abstract [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition specifies execution-world coordinates, executable lookup, ordinary `spawn`, and `spawnTerminal`. [`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) provides them with detached process trees, per-disposition wiring, credential scrubbing, `node-pty`, platform process inspection, and terminate-and-join disposal. See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the Service Definition contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for local mechanics. +The abstract [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition specifies execution-world coordinates, executable lookup, ordinary `spawn`, and `spawnTerminal`. [`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) provides them with platform-selected managed ranges, per-disposition wiring, credential scrubbing, `node-pty`, platform process inspection, and terminate-and-join disposal. See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the Service Definition contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for local mechanics. @@ -283,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 814474dabb..643998ae02 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -106,10 +106,11 @@ interface SubprocessSpawnSpec { stdio: SubprocessStdio /** * Positive finite grace period in milliseconds, no greater than - * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation - * and for draining still-open collected pipes after the process exits (an - * inherited descriptor held by a surviving descendant cannot hold the - * outcome open indefinitely). + * `MAX_TIMER_DELAY_MS`, available to the provider's termination procedure + * and used for draining still-open collected pipes after the process exits + * (an inherited descriptor held by a survivor cannot hold the outcome open + * indefinitely). Providers document whether range termination is staged or + * immediate. */ graceMs: number /** @@ -131,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与 managed-range 终止 -spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 与 `waitForExit()` 使用同一个 provider-managed range,其标识、观察限制与较弱 fallback 均归 provider 定义。唯一的终止动词执行 SIGTERM→宽限期→SIGKILL 升级,因此消费方可以构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 +spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 启动 provider 记录的终止过程,`waitForExit()` 观察同一个 provider-managed range;分阶段 provider 可以使用 `graceMs`,立即终止的 provider 不会等待。消费方可以在这两项操作上构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 ```ts type-equiv /** @@ -156,9 +157,9 @@ interface SubprocessHandle { /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the provider's termination escalation on the managed range — the - * seam's only termination verb. Idempotent, a no-op once that range is gone, - * and also triggered by the spec's abort signal. + * Begin the provider's documented termination procedure on the managed range + * — the seam's only termination verb. Idempotent, a no-op once that range is + * gone, and also triggered by the spec's abort signal. */ terminate(): void /** @@ -245,7 +246,7 @@ interface SubprocessOutcome { ## 服务行为 -抽象的 [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition 规定执行世界坐标、可执行文件查找、普通 `spawn` 与 `spawnTerminal`。[`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) 以 detached 进程树、按处置方式接线、凭据清除、`node-pty`、平台进程检查,以及先终止再等待退出的资源释放提供这些能力。Service Definition 约定见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.zh.md),本地机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.zh.md)。 +抽象的 [`SubprocessRuntime`](../../packages/subprocess/subprocess/src/index.ts) Service Definition 规定执行世界坐标、可执行文件查找、普通 `spawn` 与 `spawnTerminal`。[`LocalSubprocessRuntime`](../../packages/subprocess/subprocess-local/src/index.ts) 以平台选择的 managed range、按处置方式接线、凭据清除、`node-pty`、平台进程检查,以及先终止再等待退出的资源释放提供这些能力。Service Definition 约定见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.zh.md),本地机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.zh.md)。 @@ -283,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider's managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 71daad1d13..488c2d756c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — against the provider\'s managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index c55afea29e..18ed126f0e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 4b89aaa6c85c14cb74d54993a013762b8a868bf0 -README.zh.md: 7f939e32627c42443507f0519786c812a426e5a5 +README.md: af971f2f9706117d9a7be8fdeb29eea4d33f72e9 +README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4b89aaa6c8..af971f2f97 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,15 +6,15 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. `terminate()` sends TERM then KILL through the owner, while `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. -- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. -- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). +- **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes. +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The public `spawn()` contract returns a numeric target pid, so each native launch then waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 7f939e3262..e829dce28d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,15 +6,15 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。`terminate()` 通过该 owner 发送 TERM 再发送 KILL;`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 ## 模型体验 @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。公共 `spawn()` 合同返回数值 target pid,因此每次 native launch 随后会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 575d778dd0..5d30fa50ba 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,10 +1,10 @@ /** - * Local Service Provider for the subprocess capability seam. Each spawn is a detached - * process tree with the spec's per-stream stdio dispositions. Normal disposal - * terminates and joins live trees; Node's synchronous exit phase force-stops - * any trees the service still owns. It has no config: every disposition and - * limit arrives on the spec, so the deployment-varying choices stay with the - * caller's config (the bash executor's, the LSP host's, …). + * Local Service Provider for the subprocess capability seam. Each spawn owns a + * platform-selected managed range with the spec's per-stream stdio dispositions. + * Normal disposal terminates and joins live ranges; Node's synchronous exit + * phase force-stops any ranges the service still owns. It has no config: every + * disposition and limit arrives on the spec, so deployment-varying choices + * stay with the caller's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local */ @@ -36,11 +36,11 @@ import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' /** - * Local subprocess service: detached process trees, Node-shaped stdio + * Local subprocess service: platform-selected managed ranges, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill - * files), credential-scrubbed environment, and tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during - * JavaScript-observable host exit. + * files), credential-scrubbed environment, and provider-owned range signalling. + * POSIX paths stage TERM before KILL; Windows paths terminate immediately. + * JavaScript-observable host exit also performs synchronous final termination. */ export class LocalSubprocessRuntime extends SubprocessRuntime { /** Live handles retained for normal disposal and synchronous host-exit finalization. */ @@ -73,7 +73,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { for (const handle of this.live) { try { handle.terminateForHostExit() - } catch (_ordinaryTreeTerminationFailed) { + } catch (_ordinaryRangeTerminationFailed) { // Host exit cannot await or report one target; continue with the rest. } } @@ -87,8 +87,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } private async disposeManagedProcesses(): Promise { - // Terminate (escalating), then await WHOLE-TREE exit — not just the - // direct child's settlement — so even a TERM-trapping descendant cannot + // Request termination, then await MANAGED-RANGE exit — not just the + // direct command's settlement — so even a surviving descendant cannot // outlive the fiber. Keep both sets authoritative while these waits are // pending so a shorter process-level exit bound can still force-kill them. const pending: Promise[] = [] diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index d8f7aef25f..b579fb022a 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,9 +1,10 @@ /** * Process plumbing for the local subprocess service: detached process-tree * spawn with per-stream stdio dispositions, tail-keep collection with spill - * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the - * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers - * own deadlines, teardown ladders, and cause classification. + * files, provider-owned range signalling, and common termination scheduling. + * POSIX owners stage TERM before KILL; Windows owners terminate immediately. + * This layer reacts to an abort signal; callers own deadlines, teardown + * ladders, and cause classification. * @module dsh-subprocess-local/spawn */ diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 5a6cd6846f..398e87e81c 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: b7d38f9ab415608de7f249b5ed9081e47cb1d4a1 -README.zh.md: 9f81c60abf0ce733dbe0e0c3c7704a57cf194321 +README.md: 61aec9024427a6530f4e877c2fd35cdf301af169 +README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index b7d38f9ab4..61aec90244 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Each provider documents how it defines and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 9f81c60abf..4740bef706 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。每个 provider 记录该范围的定义、观察方式与较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index c91649cbb4..8fc9773df7 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -1,6 +1,6 @@ /** * Service Definition for the subprocess capability seam (`ctx.subprocess`): execution-world executable lookup, - * fully specified managed process trees with raw or + * fully specified provider-managed process ranges with raw or * collected stdio, and one terminal-process primitive. Command defaulting, * shell semantics, deadlines, protocol framing, terminal readiness, and * presentation belong to consumers. The local implementation lives in @@ -88,11 +88,11 @@ declare module '@deepseek-ai/cordis' { * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. - * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL — the only termination verb — against the provider's - * managed range. {@link SubprocessHandle.waitForExit} observes that same range - * so a consumer-owned teardown ladder can hold each tier on real quiescence; - * each provider documents its identity and observability limits. + * - {@link SubprocessHandle.terminate} (and the spec's abort signal) starts the + * provider's documented procedure against its managed range. + * {@link SubprocessHandle.waitForExit} observes that same range so a + * consumer-owned teardown ladder can hold each tier on real quiescence; each + * provider documents its identity, signalling, and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 6709e53118..c2b43f919f 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -1,7 +1,7 @@ /** * Vocabulary for the subprocess Service Definition: fully-specified spawn requests with * Node-shaped per-stream stdio modes, bounded collected output with spill - * recovery, raw piped streams, and tree-scoped termination. Command + * recovery, raw piped streams, and managed-range termination. Command * defaulting, shell semantics, protocol framing, and presentation belong to * consumers such as the bash executor seam. * @module dsh-subprocess/types @@ -81,10 +81,11 @@ export interface SubprocessSpawnSpec { stdio: SubprocessStdio /** * Positive finite grace period in milliseconds, no greater than - * `MAX_TIMER_DELAY_MS`, for the {@link SubprocessHandle.terminate} escalation - * and for draining still-open collected pipes after the process exits (an - * inherited descriptor held by a surviving descendant cannot hold the - * outcome open indefinitely). + * `MAX_TIMER_DELAY_MS`, available to the provider's termination procedure + * and used for draining still-open collected pipes after the process exits + * (an inherited descriptor held by a survivor cannot hold the outcome open + * indefinitely). Providers document whether range termination is staged or + * immediate. */ graceMs: number /** @@ -177,9 +178,9 @@ export interface SubprocessHandle { /** Resolves with spawned-command exit facts; rejects for spawn or provider failures. */ readonly done: Promise /** - * Begin the provider's termination escalation on the managed range — the - * seam's only termination verb. Idempotent, a no-op once that range is gone, - * and also triggered by the spec's abort signal. + * Begin the provider's documented termination procedure on the managed range + * — the seam's only termination verb. Idempotent, a no-op once that range is + * gone, and also triggered by the spec's abort signal. */ terminate(): void /** From 36db13dfb3e2f08e67cb0df01a8548cc79b01352 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 07:20:43 +0800 Subject: [PATCH 029/110] fix(subprocess): require verified range settlement --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +-- .../2026-07-06-timeout-deadline-library.md | 4 +-- .../2026-07-06-timeout-deadline-library.zh.md | 4 +-- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +-- .../2026-07-15-lsp-capability-seam.md | 2 +- .../2026-07-15-lsp-capability-seam.zh.md | 2 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +-- .../2026-07-26-subprocess-seam.md | 4 +-- .../2026-07-26-subprocess-seam.zh.md | 4 +-- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 4 +-- .../2026-07-27-dispose-ladder-to-consumer.md | 4 +-- ...026-07-27-dispose-ladder-to-consumer.zh.md | 4 +-- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +-- .../2026-08-01-packaged-ripgrep-search.md | 4 +-- .../2026-08-01-packaged-ripgrep-search.zh.md | 4 +-- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +-- ...oduct-subagent-providers-in-shared-host.md | 4 +-- ...ct-subagent-providers-in-shared-host.zh.md | 4 +-- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +-- ...-11-synchronous-subprocess-exit-cleanup.md | 8 +++--- ...-synchronous-subprocess-exit-cleanup.zh.md | 8 +++--- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +-- ...typescript-sdk-and-sdk-subagent-backend.md | 2 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +-- ...claude-code-and-codex-subagent-backends.md | 22 +++++++-------- ...ude-code-and-codex-subagent-backends.zh.md | 22 +++++++-------- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +-- ...duct-subagent-one-shot-background-tasks.md | 6 ++--- ...t-subagent-one-shot-background-tasks.zh.md | 6 ++--- ...agent-noninteractive-permissions.i18n.yaml | 4 +-- ...uct-subagent-noninteractive-permissions.md | 4 +-- ...-subagent-noninteractive-permissions.zh.md | 4 +-- ...8-product-subagent-failure-facts.i18n.yaml | 4 +-- ...26-08-18-product-subagent-failure-facts.md | 6 ++--- ...08-18-product-subagent-failure-facts.zh.md | 6 ++--- ...product-subagent-named-instances.i18n.yaml | 4 +-- ...-08-18-product-subagent-named-instances.md | 2 +- ...-18-product-subagent-named-instances.zh.md | 2 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- AGENTS.md | 2 +- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 18 ++++++------- docs/config-catalog.zh.md | 18 ++++++------- docs/subsystems/subprocess.i18n.yaml | 4 +-- docs/subsystems/subprocess.md | 8 +++--- docs/subsystems/subprocess.zh.md | 8 +++--- packages/README.i18n.yaml | 4 +-- packages/README.md | 2 +- packages/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +-- packages/fs/tool-fs-search/README.md | 6 ++--- packages/fs/tool-fs-search/README.zh.md | 6 ++--- packages/fs/tool-fs-search/src/glob.ts | 2 +- packages/fs/tool-fs-search/src/grep.ts | 2 +- packages/fs/tool-fs-search/src/index.ts | 4 +-- packages/fs/tool-fs-search/src/search-core.ts | 6 ++--- .../fs/tool-fs-search/tests/tools.spec.ts | 6 ++--- packages/lsp/lsp-stdio/README.i18n.yaml | 4 +-- packages/lsp/lsp-stdio/README.md | 4 +-- packages/lsp/lsp-stdio/README.zh.md | 4 +-- packages/lsp/lsp-stdio/src/connection.ts | 18 ++++++------- packages/lsp/lsp-stdio/src/index.ts | 4 +-- packages/lsp/lsp-stdio/src/instance.ts | 13 +++++---- packages/lsp/lsp-stdio/tests/instance.spec.ts | 2 +- packages/shell/bash-local/README.i18n.yaml | 4 +-- packages/shell/bash-local/README.md | 8 +++--- packages/shell/bash-local/README.zh.md | 8 +++--- packages/shell/bash-local/src/index.ts | 10 +++---- packages/shell/pwsh-local/README.i18n.yaml | 4 +-- packages/shell/pwsh-local/README.md | 6 ++--- packages/shell/pwsh-local/README.zh.md | 6 ++--- packages/shell/pwsh-local/src/index.ts | 6 ++--- .../shell/pwsh-local/tests/executor.spec.ts | 4 +-- .../subagent/subagent-acp/README.i18n.yaml | 4 +-- packages/subagent/subagent-acp/README.md | 6 ++--- packages/subagent/subagent-acp/README.zh.md | 6 ++--- packages/subagent/subagent-acp/src/index.ts | 4 +-- packages/subagent/subagent-acp/src/run.ts | 24 ++++++++--------- .../subagent-claude-code/README.i18n.yaml | 4 +-- .../subagent/subagent-claude-code/README.md | 6 ++--- .../subagent-claude-code/README.zh.md | 6 ++--- .../subagent-claude-code/src/index.ts | 2 +- .../subagent-claude-code/src/invariant.ts | 2 +- .../subagent-claude-code/src/process.ts | 10 +++---- .../subagent/subagent-claude-code/src/run.ts | 12 ++++----- .../subagent/subagent-codex/README.i18n.yaml | 4 +-- packages/subagent/subagent-codex/README.md | 6 ++--- packages/subagent/subagent-codex/README.zh.md | 6 ++--- packages/subagent/subagent-codex/src/index.ts | 2 +- .../subagent/subagent-codex/src/invariant.ts | 2 +- packages/subagent/subagent-codex/src/run.ts | 12 ++++----- .../tests/subagent-codex.spec.ts | 2 +- .../subagent/subagent/src/out-of-process.ts | 2 +- packages/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/README.md | 4 +-- packages/subprocess/README.zh.md | 4 +-- .../subprocess/subprocess-local/src/index.ts | 7 +++-- .../subprocess/subprocess-local/src/spawn.ts | 10 +++---- .../subprocess-local/src/windows-job.ts | 24 +++++++++++++---- .../tests/fixtures/fake-job-runner.ts | 5 ++-- .../tests/native-windows.spec.ts | 2 ++ .../tests/windows-job.spec.ts | 27 +++++++++++++++++++ .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 6 ++--- packages/subprocess/subprocess/src/types.ts | 6 ++--- scripts/gen-doc-graphs.ts | 2 +- 115 files changed, 348 insertions(+), 308 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 580e74f217..0e523f1d20 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a -2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d +2026-07-06-timeout-deadline-library.md: 0c29c1d82c7ebced3d6b5ce6c6ae0ecafff8af43 +2026-07-06-timeout-deadline-library.zh.md: 4c56061f7430ec6d220b2110939ad29c0160bf21 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 38f048d16e..0c29c1d82c 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -97,7 +97,7 @@ The signal only *notifies*; termination is always the listener's job, and the li ## Consequences -- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. +- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The subprocess provider still owns termination of its managed range, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). @@ -113,4 +113,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing provider-owned managed-range termination path remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index c8d189c2a7..4c56061f74 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -97,7 +97,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): ## 后果 -- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 +- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。subprocess provider 继续拥有其 managed range 的终止过程,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 @@ -113,4 +113,4 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 provider-owned managed-range 终止路径保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 277e0b097d..8827fccdd1 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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-07-15-lsp-capability-seam.md -2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc -2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 +2026-07-15-lsp-capability-seam.md: b89b9afaa90aac8abf1f78c797f3219b6ef40248 +2026-07-15-lsp-capability-seam.zh.md: 40785612c3bfc219b0946ac09d6d0c36873b0c28 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 90f9daf4b8..b89b9afaa9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -108,7 +108,7 @@ The transport-neutral presenter uses `{ card: 'generic', kind: 'search', title, The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. -Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. +Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for request-cancel grace plus subprocess termination and output draining; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The LSP provider uses `deadline()` and `timeoutOf()` and owns request cancellation, while the subprocess provider owns range termination and observation; timeout notification alone does not terminate work. ## Workspace, filesystem, and document synchronization diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index bdb5e812e9..40785612c3 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -108,7 +108,7 @@ interface LspToolInput { seam 和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 -提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 +提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期,并提供给 subprocess 终止与输出排空;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。LSP provider 使用 `deadline()` 和 `timeoutOf()` 并拥有请求取消,subprocess provider 则拥有范围终止与观察;超时通知本身不会终止工作。 ## 工作区、文件系统与文档同步 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 166541f1f2..a2d9f7d146 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: 92d36bf6d522ab939ef6c0de1063e0accea2946f -2026-07-26-subprocess-seam.zh.md: d271ad95ce84bb34256d3bf2ee6d793e21623d2b +2026-07-26-subprocess-seam.md: 973d3b2f9c88033ac61016c42ed3fe3320f4e0f6 +2026-07-26-subprocess-seam.zh.md: e535369b50f8f3e3b74c96de4e0f4341c185f1a6 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 92d36bf6d5..973d3b2f9c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -39,6 +39,6 @@ Observed stream and lifecycle needs then moved the eligible process consumers on ## Consequences -Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; tree signalling, escalation, bounded collection, terminal mechanics, and credential scrubbing each have one implementation; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. +Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; managed-range signalling, termination scheduling, bounded collection, terminal mechanics, and credential scrubbing each have one owner; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. -Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, tree lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. +Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, managed-range lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index d271ad95ce..e535369b50 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -39,6 +39,6 @@ Status: implemented ## 后果 -换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;进程树信号、升级终止、有界收集、终端机制与凭据清除各自只剩一份实现;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 +换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;managed-range 信号、终止调度、有界收集、终端机制与凭据清除各自只有一个 owner;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 -代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、进程树生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 +代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、managed-range 生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index a81c4706e7..a96f2137a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-07-27-dispose-ladder-to-consumer.md -2026-07-27-dispose-ladder-to-consumer.md: 58d835864d2f6544152fdf09cf6e93380793a2ae -2026-07-27-dispose-ladder-to-consumer.zh.md: dc7413e85fa6e52de14f51fd175c7b37b43029a4 +2026-07-27-dispose-ladder-to-consumer.md: bcd6aa98f45fabda702e41738bbec929f145f739 +2026-07-27-dispose-ladder-to-consumer.zh.md: 4e0dc565e3c19f3780a04d00991490f7a56d5c0f diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index 58d835864d..bcd6aa98f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call the provider-owned `terminate()` procedure and await an unbounded `waitForExit()` for the subprocess owner's managed-range exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real range exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. +Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns termination and the final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before termination and an unbounded managed-range join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index dc7413e85f..4e0dc565e3 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 provider-owned `terminate()` 过程,再无界等待 `waitForExit()`,由子进程责任方证明 managed range 已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认范围真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 +买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止与最终的 managed-range 等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(终止前有界 `waitForExit` 返回假,终止后无界等待 managed range 退出),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index 870bf8ec05..b0f05e0482 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: c401bc1e30df0c3443ad58b37f64523b1c10cb31 -2026-08-01-packaged-ripgrep-search.zh.md: 6a195e59f77aa73b9d0918461cef45360e617098 +2026-08-01-packaged-ripgrep-search.md: bc73a6ebc6edfcbc2f4e4a932806217562d3e73d +2026-08-01-packaged-ripgrep-search.zh.md: 43e907bdc7114daa25ac56338c1be2999ed9c65a diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index c401bc1e30..bc73a6ebc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,9 +12,9 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The termination/output-drain grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. -Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. +Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess provider starts managed-range termination, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize). diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 6a195e59f7..43e907bdc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止/输出排空宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 -退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 +退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess provider 启动 managed-range 终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 `fs-glob-sampling` ACP(Agent Client Protocol)快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 66fa66d233..2f4dd87029 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 196e28c1263c4b6d71eaeb59b9ba8457b36f3ff4 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 1451890e1b250a2095e3366c59d6ce0873b55fe9 +2026-08-10-product-subagent-providers-in-shared-host.md: 7ea5a17fcdab23621b0017ede56e8bfb11507f35 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 4623dde43d0e47cc0bc9bf5c6714c8572d1a5f01 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 196e28c126..7ea5a17fcd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -14,13 +14,13 @@ The placement decision must preserve two independent facts. Loading a provider m Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. -Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. +Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, managed-range lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and managed-range quiescence. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index 1451890e1b..4623dde43d 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -14,13 +14,13 @@ Status: implemented 产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 -每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、managed-range 生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和 managed-range 完全停稳。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 216e14e98c..88f71563cf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 517b7e82f00ea16ec6d9f8731be67963a46035da -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: f9555bba3d7fb2dc9971aef21f8a2c8c17d25398 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 4f0603f6e2cb3c0cc4c0d4c0cde1b0da0ae82763 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: acb20c8989d21b1fd5e97fa44247cd2875fb4570 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 517b7e82f0..4f0603f6e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -6,7 +6,7 @@ English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) ## Problem -The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. +The local subprocess provider owns ordinary managed ranges and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and staged-termination timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. @@ -20,11 +20,11 @@ The listener uses local-only final operations that are absent from the public `S - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. -Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: each ordinary handle runs its provider-owned termination procedure, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. | Host path | Local provider action | Completion evidence | | --- | --- | --- | -| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| Normal Cordis disposal | Provider-owned termination and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | | `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | | Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` | @@ -32,7 +32,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. -Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. +Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, staged POSIX and immediate Windows disposal, live-set retention during pending disposal, and listener removal after disposal. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index f9555bba3d..acb20c8989 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 +本地 subprocess provider拥有普通 managed range和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与分阶段终止 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 @@ -20,11 +20,11 @@ Status: implemented - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 -正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:每个普通 handle 执行其 provider-owned termination procedure,并等待每个普通或 terminal 清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 | 宿主路径 | 本地 provider动作 | 完成证据 | | --- | --- | --- | -| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| 正常 Cordis dispose | provider-owned termination,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | | `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | | 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 | @@ -32,7 +32,7 @@ Status: implemented 父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 -单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 +单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、POSIX 分阶段与 Windows 立即终止、dispose等待期间保留存活集合,以及 dispose后移除 listener。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index a9a33f4ebf..76d7c3faa7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: f26c92fbda4fafe9e5bdc469dae7ed0ab530b0b9 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e72aff91daa7f9e96007a09c0ced25e56881bf2e diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index 84314eaf58..f26c92fbda 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -15,7 +15,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). - **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. -- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. +- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, managed-range teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. `dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index a822aac655..e72aff91da 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -15,7 +15,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 - **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 -- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 +- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、managed-range 拆卸)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 `dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 30ab9e2b09..e5b9b1d0f5 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8ba4d259872558ced89083bb53f9228f46c3d45c +2026-08-04-claude-code-and-codex-subagent-backends.md: 3c77321d3f5252a26f484fc78f586eb172e6d465 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f9b675735fa3e15693b558d8dfd0eaea38f33e88 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 9b47fcf49d..3c77321d3f 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,19 +8,19 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or managed ranges. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Decision The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, managed-range termination, and quiescence observation. ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> whole-tree exit + both -> provider disposal -> dsh-subprocess -> managed-range exit ``` ### Ownership and lifecycle @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | Delegation lifecycle | `ctx.subagents` | Resolve the named provider request and pair lifecycle events around the published `SubagentRun` | Unsupported context or malformed input fails before a run is published; start and terminal events remain paired | | Scheduling and adaptation | `dsh-tool-subagent` | Interpret `run_in_background`, choose foreground collection or one-shot Job registration, and map the shared stop reason | Foreground returns the product outcome; background returns a Job id after registration | | Job state and control | `ctx.jobs` and `dsh-tool-jobs` | Own Job state, output, cancellation, owner cleanup, completion notices, and model-facing controls | The exact parent can collect, list, or stop background work and receives its completion notice | -| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove process-tree exit | Foreground return and Job settlement both wait for idempotent disposal and whole-tree exit | +| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove managed-range exit | Foreground return and Job settlement both wait for idempotent disposal and managed-range exit | ## Codex provider @@ -42,7 +42,7 @@ Before publication, the provider validates a non-empty text-only task, starts th For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. +An unpublished startup failure closes the wire, invokes termination for any acquired managed range, waits for it to empty, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -54,7 +54,7 @@ The public configuration contains a non-empty `providerName`, an explicit `env` The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact. -Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke the subprocess provider's termination procedure, and wait for managed-range exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's quiescence proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit. @@ -62,13 +62,13 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native managed-range exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and managed-range exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. -The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. +The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves managed-range exit without calling the Messages API directly from the test. The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed. @@ -76,7 +76,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. -**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and managed-range concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. **A model-visible product selector.** Product availability, instance configuration, and authentication are deployment facts. Profile-bound tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. @@ -88,7 +88,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. +Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and managed-range quiescence. Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 8ba4d25987..f9b675735f 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,19 +8,19 @@ Status: implemented 命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.zh.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 +产品集成不得成为任务文本、cwd、取消、结果结算或 managed range 的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 ## 决策 harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、managed-range 终止与完全停稳观测。 ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> whole-tree exit + both -> provider disposal -> dsh-subprocess -> managed-range exit ``` ### 归属与生命周期 @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | 委派生命周期 | `ctx.subagents` | 解析具名提供方请求,并为已发布的 `SubagentRun` 配对生命周期事件 | 不受支持的上下文或格式错误的输入会在发布运行前报错;启动与终态事件保持成对 | | 调度与适配 | `dsh-tool-subagent` | 解释 `run_in_background`,选择前台收集或 one-shot Job 登记,并映射共享停止原因 | 前台返回产品结果;后台在登记完成后返回 Job id | | Job 状态与控制 | `ctx.jobs` 与 `dsh-tool-jobs` | 负责 Job 状态、输出、取消、owner 清理、完成通知与面向模型的控制工具 | 准确父级可以收集、列出或停止后台工作,并收到完成通知 | -| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明进程树退出 | 前台返回与 Job 结算都会等待幂等资源释放和整棵进程树退出 | +| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明 managed range 退出 | 前台返回与 Job 结算都会等待幂等资源释放和 managed range 退出 | ## Codex 提供方 @@ -42,7 +42,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 +若启动在发布前失败,提供方会关闭协议连接、请求终止所有已取得的 managed range、等待范围为空、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -54,7 +54,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 -启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用 subprocess provider 的终止过程,并等待 managed range 退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的完全停稳证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。 @@ -62,13 +62,13 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生 managed range 退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及 managed range 退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 -带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 +带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明 managed range 退出,且测试不会直接调用 Messages API。 项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。 @@ -76,7 +76,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD **直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。 -**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和 managed range 的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 **面向模型的产品选择器。** 产品可用性、实例配置和身份验证属于部署事实。由 Profile 绑定的工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 @@ -88,7 +88,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD ## 后果 -用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 +用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与 managed-range 完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 48455ab7b7..434fd6f19a 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 5e9522f6fac6eadb874ba2d1d4f45100f962b9e2 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: fdf7fa58e49f6aa9b6fe7a21eb21e6208e56a86f +2026-08-12-product-subagent-one-shot-background-tasks.md: 8fc35ad82d75114dccd39a1176996665d0c28376 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 3c6bb848688f891b29a94683573f3479e50da6e2 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 5e9522f6fa..8fc35ad82d 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -16,7 +16,7 @@ Production `dsh` does not install the optional product providers. A Profile that The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for either product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and managed-range quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. @@ -39,7 +39,7 @@ product tool call | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | -| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal | +| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one managed range | Job settlement and foreground return consume the same result and both wait for disposal | ## Published composition @@ -69,4 +69,4 @@ The Web composition test explicitly mounts both optional providers from the repo Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one. -Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. +Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and managed-range exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index fdf7fa58e4..3c6bb84868 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -16,7 +16,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 [命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)允许两个产品分别拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与 managed-range 完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 @@ -39,7 +39,7 @@ product tool call | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | -| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 | +| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一个 managed range | Job 结算与前台返回消费同一结果,且都会等待资源释放 | ## 发布组装 @@ -69,4 +69,4 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供 agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。 -每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 +每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和 managed-range 退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 89bfdef747..44f4718609 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd -2026-08-15-product-subagent-noninteractive-permissions.zh.md: cbf3c3cd14fcecd2c24e335a8b71cc3b5370e247 +2026-08-15-product-subagent-noninteractive-permissions.md: aa69d1a296f55df01db2632b105511d24ad6b067 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 8525969f189dcb74dec55d19fc869c47f3a1e992 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index 8788fba349..aa69d1a296 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -59,11 +59,11 @@ The foreground consumer presents the stop-reason headline, then the optional dia | Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. | | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | -| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | +| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent managed-range disposal. | ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and managed-range quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native managed range exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index cbf3c3cd14..8525969f18 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -59,11 +59,11 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 | 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 | | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | -| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | +| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的 managed-range 资源释放。 | ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与 managed-range 完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native managed range 会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml index dca365d854..2bace94aa2 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.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/feature/2026-08-18-product-subagent-failure-facts.md -2026-08-18-product-subagent-failure-facts.md: 50d8e918f288a6b8a9b90474499b2ed20731643f -2026-08-18-product-subagent-failure-facts.zh.md: abdbf8c0ebddb1fb30cef3c7e80fcf7040e45d2d +2026-08-18-product-subagent-failure-facts.md: 79947c57a267b23e9f73dd595ebf89e209fed066 +2026-08-18-product-subagent-failure-facts.zh.md: 01a88322081b048ee1bf044ffb40ad16e88a1693 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md index 50d8e918f2..79947c57a2 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_ | `query-start` | SDK query construction, native platform-payload startup, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback | | `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category | | `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | -| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | +| `teardown` | Query close and managed-range release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | ### Codex facts @@ -48,7 +48,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian | `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | | `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | | `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | -| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | +| `teardown` | Wire close and managed-range release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | `contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. @@ -64,7 +64,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian ## Verification -Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and managed-range quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md index abdbf8c0eb..01a8832208 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`erro | `query-start` | SDK query 构造、原生平台载荷启动与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 | | `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 | | `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | -| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | +| `teardown` | Query 关闭与 managed-range 释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | ### Codex 事实 @@ -48,7 +48,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant | `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | | `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | | `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | -| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | +| `teardown` | Wire 关闭与 managed-range 释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | `contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 @@ -64,7 +64,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant ## Verification -Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与 managed-range 完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml index 614f036a6a..2387488610 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.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/feature/2026-08-18-product-subagent-named-instances.md -2026-08-18-product-subagent-named-instances.md: 759d3941ff8404138954c409f0fd4949e357200e -2026-08-18-product-subagent-named-instances.zh.md: 6faf0e70f639cbc6528e27b800b8e5f99f0d6c86 +2026-08-18-product-subagent-named-instances.md: 997c555699e8de8c47be9a68bea729a596dbda10 +2026-08-18-product-subagent-named-instances.zh.md: 96b2bc34c082bbc372e7af43f86b1ee76289e83e diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md index 759d3941ff..997c555699 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md @@ -29,7 +29,7 @@ Removing one provider row blocks new starts and removes only tools bound to that ## Verification -Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and process-tree quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. +Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and managed-range quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md index 6faf0e70f6..96b2bc34c0 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md @@ -29,7 +29,7 @@ Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 ## 验证 -两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 +两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与 managed-range 完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 5fb38dbf24..5338856f63 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10 -2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312 +2026-08-08-native-windows-pull-request-ci.md: 8c3e7b0788a8816143b15aad148eb927e66297d0 +2026-08-08-native-windows-pull-request-ci.zh.md: 57fa551291adde0c98eb198b344aef85186f7b7e diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index d4883cf136..8c3e7b0788 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,7 @@ Windows durable JSONL paths keep drive roots in native spelling and apply the ex Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. -Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and managed-range exit on each host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 0ba9629e87..57fa551291 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,7 @@ Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 -Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和 managed-range 退出。 ## 曾考虑的替代方案 diff --git a/AGENTS.md b/AGENTS.md index a66d6cdc54..2024b12749 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local process-tree provider + shared Win32 library + subprocess/ subprocess capability + local managed-range provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index dbafe8c766..55343b9635 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c -capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d +capability-seams.md: 263e870061467b89961f7e7026e567962acbfdf4 +capability-seams.zh.md: d5def25b4dc5b6a3c6bba7b419c0512cf51552ea diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9e99ebbdc0..263e870061 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -460,7 +460,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination. | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 611902310e..d5def25b4d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -462,7 +462,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、managed-range/session 生命周期、stdio 处置、终端机制和 provider-defined termination。 | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | 面向模型的 shell 工具和钩子桥接消费此 seam;沙箱、远程或 PowerShell 执行器可以替换 bash-local,而无需改动这些消费方。 | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | 插件声明限定于 effect 作用域的 DSH_* 事实;每个 shell 工具在每次执行时收集一份可信快照,其执行器据此重建命名空间。 | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | 注册表负责精确到 Agent 的会话身份和清理;后端负责终端机制,tool-terminal 则提供限定于所有者作用域的模型接口。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 432da9e27f..a124170716 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 3595bc650153be25a4156f103af4980c9452f4f0 -config-catalog.zh.md: 66f35350640d5fcea7c9cb90d65bf0878ac68806 +config-catalog.md: 879484a1f5b6641cd22eef0a95ff46228b8e6d14 +config-catalog.zh.md: 65c9e1f9041d30e0e0ace6ef2da8fe999a60cc7f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3595bc6501..879484a1f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -361,7 +361,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1323,9 +1323,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ + /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1520,7 +1520,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2171,11 +2171,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. Must not exceed + * before the parent invokes provider termination. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2208,7 +2208,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code process-tree termination. */ + /** Grace in milliseconds for Claude Code termination and output draining. */ disposeGraceMs?: number } @@ -2236,7 +2236,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds for app-server termination and output draining. */ disposeGraceMs?: number } @@ -2553,7 +2553,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 66f3535064..65c9e1f904 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -363,7 +363,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1325,9 +1325,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ + /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1523,7 +1523,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2174,11 +2174,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. Must not exceed + * before the parent invokes provider termination. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2211,7 +2211,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code process-tree termination. */ + /** Grace in milliseconds for Claude Code termination and output draining. */ disposeGraceMs?: number } @@ -2239,7 +2239,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds for app-server termination and output draining. */ disposeGraceMs?: number } @@ -2556,7 +2556,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 8b51742982..8966af73e1 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 701d70629587c202c50047fb88158f39179920d1 -subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b +subprocess.md: 1cc704a30eeb7587c784886d31b7ca65e085ad93 +subprocess.zh.md: b861d99742956f5cf469851f8f78bd235706ebc8 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 701d706295..1cc704a30e 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the managed range when - * it fires. The caller owns deadlines and cause classification; this seam - * only reacts to the abort. + * Abort signal — starts the provider's termination procedure on the managed + * range when it fires. The caller owns deadlines and cause classification; + * this seam only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 643998ae02..b861d99742 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the managed range when - * it fires. The caller owns deadlines and cause classification; this seam - * only reacts to the abort. + * Abort signal — starts the provider's termination procedure on the managed + * range when it fires. The caller owns deadlines and cause classification; + * this seam only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 8129d42c0a..8697b9cfbb 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.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 packages/README.md -README.md: ad88e895cec423b71dbc3c0e961ddb8901e0f66d -README.zh.md: 81557ab3b9f3d7a00af6a5ad45a9e04cc7ea063b +README.md: 287e59a54dcd088894bcfe3946727169749220bd +README.zh.md: 6407754a10d1ca441df4c6138125efa4e6356fdc diff --git a/packages/README.md b/packages/README.md index ad88e895ce..287e59a54d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | | [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local managed-range provider, and shared Win32 process library | Product — stable API | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 81557ab3b9..6407754a10 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`identity/`](identity/README.zh.md) | 共享匿名身份 | 产品:稳定 API | | [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | | [`e2b/`](e2b/README.zh.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API | +| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地 managed-range 提供方与共享 Win32 进程库 | 产品:稳定 API | | [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | | [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | | [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 488c2d756c..432015ade2 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index b1a2080662..9976f2a8b6 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.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 packages/fs/tool-fs-search/README.md -README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 -README.zh.md: d13581d7e584f97e0779eb232a47d2d119404647 +README.md: ae232afa161f7b8f0d4b28f4da5c0992643a5f4f +README.zh.md: 4b9372bbce129259a6e01694f2964a5528c6c19e diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 84a3adc31f..ae232afa16 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. +Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, managed-range termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. ## Deployment requirement: no host rg, co-located workdir/filesystem @@ -29,8 +29,8 @@ Node deployments receive the `@vscode/ripgrep` platform package on supported mac | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | -| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | -| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; abort starts the subprocess provider's termination procedure. | +| `graceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index d13581d7e5..4b9372bbce 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 +采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、managed-range 终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 ## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 @@ -29,8 +29,8 @@ Node 部署在受支持的 macOS、Linux 与 Windows x64/arm64 目标上获得 ` | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | -| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | -| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;中止会启动 subprocess provider 的终止过程。 | +| `graceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | | `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 871dc16ae8..60ff5b4d80 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -47,7 +47,7 @@ export interface GlobToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Terminate-escalation grace period (ms) for the search process. */ + /** Grace for subprocess termination and output draining (ms). */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 38f54aa2a5..439dde2310 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -45,7 +45,7 @@ export interface GrepToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Terminate-escalation grace period (ms) for the search process. */ + /** Grace for subprocess termination and output draining (ms). */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 1765d1b781..fabd12df43 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -13,7 +13,7 @@ * ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} / * {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing, * retention, formatted-result spill, and timeout declaration; the subprocess - * seam owns spawn execution, process-tree termination, environment scrubbing, + * seam owns spawn execution, managed-range termination, environment scrubbing, * and raw output capture. The package injects `tools`, `systemPrompt`, and * `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read * opportunistically with `ctx.get()` because formatted-result spill is optional. @@ -83,7 +83,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 5ac5521033..0852cbb36d 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -182,8 +182,8 @@ export function resolveRgPath(): Promise { * complete raw stdout. The working directory is the calling agent's session * cwd (`exec.agent.session.header.cwd`) when available, else * `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout - * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation terminate the - * process tree. + * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation start the + * provider's managed-range termination procedure. * * The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` * is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the @@ -208,7 +208,7 @@ export function resolveRgPath(): Promise { * @param toolName - `glob` or `grep`, used in error messages. * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. - * @param graceMs - the seam's terminate-escalation grace period. + * @param graceMs - grace supplied to subprocess termination and output draining. * @param stderrMaxBytes - cap on the retained stderr diagnostic tail. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index abb7e75773..3a237e648d 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -93,7 +93,7 @@ class FakeReader implements SubprocessOutputReader { * A scriptable subprocess handle: `done` resolves with the scripted outcome * (or rejects with the scripted error), `terminate()` records the call, and * the spec's abort signal marks the handle terminated — mirroring the seam's - * abort→terminate escalation. + * abort-triggered provider termination. */ class FakeHandle implements SubprocessHandle { readonly pid = 4242 @@ -434,8 +434,8 @@ describe('workdir derivation and signal forwarding', () => { it('reports an abort fired during the run as SEARCH_ABORTED', async () => { // The cooperative tool timeout or caller cancellation aborts exec.signal; - // the subprocess seam then kills the process tree. The tool classifies - // the first cause it owns: the abort. + // the subprocess provider then starts managed-range termination. The tool + // classifies the first cause it owns: the abort. const { ctx, subprocess } = await setup() const controller = new AbortController() subprocess.handler = () => { diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 6ca3618083..e08a01a19f 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.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 packages/lsp/lsp-stdio/README.md -README.md: beadc34ec738ae3f6511cae764240c0e9d8c61d7 -README.zh.md: 2d6516a25147fb7593e8d45d843315098cdff7d3 +README.md: 5b5e805f2e48d17aeed3cd5f4dce47928b0c9dc5 +README.zh.md: 6dd690a536cd96af889c8c13f2b62282a1cc9328 diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index beadc34ec7..5b5e805f2e 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server. -- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome. +- After protocol shutdown fails, invokes the subprocess provider's termination procedure and awaits the same managed range through `waitForExit()`. The provider owns signal delivery and observation failures; the LSP host owns only protocol-first teardown and the final quiescence wait. - Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process. - Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. @@ -32,7 +32,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v | `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | | `maxDocumentBytes` | `4000000` | Largest source file this host will open. | | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | -| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | +| `killGraceMs` | `2000` | Grace supplied to subprocess termination and output draining. | `servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index 2d6516a251..6dd690a536 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。服务器仍存活时返回的错误不会触发重试;如果选中的池化传输在只读查询之前或期间发生故障,提供方会等待其 dispose(资源释放)完成,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。 -- 协议 shutdown 失败后,经由子进程 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 +- 协议 shutdown 失败后,调用 subprocess provider 的终止过程,并通过 `waitForExit()` 等待同一个 managed range。信号投递与观察失败归 provider 所有;LSP Host 只拥有协议优先的拆卸过程与最终完全停稳等待。 - 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程和协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID namespace 不得监视 harness 进程。 - 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验证,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 @@ -32,7 +32,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) | `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 | | `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 | | `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown`/`exit` 的预算。 | -| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 | +| `killGraceMs` | `2000` | 提供给 subprocess 终止与输出排空的宽限期。 | `servers` 必须至少包含一个配置项,每个 id 都必须非空。定时器预算必须是正整数,且不超过 Node 的 `2_147_483_647` ms 定时器上限。所有可执行文件都会在清理 credential 后于加载时解析;后面的坏配置项会阻止所有提供方注册。进程在第一次匹配查询时惰性启动。 diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index 5cfcf10b0c..55af39341a 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -4,8 +4,8 @@ * server→client requests: it answers `workspace/configuration` from static * config, and rejects `workspace/applyEdit` (this host never applies edits or * runs commands). It caps stderr, surfaces framing/decoder failures as a - * fatal close, and exposes tree-scoped termination through the handle so the - * instance owns teardown; group/tree mechanics live in the subprocess + * fatal close, and exposes managed-range termination through the handle so the + * instance owns teardown; range mechanics live in the subprocess * Service Provider. * @module @deepseek-ai/dsh-lsp-stdio/connection */ @@ -29,9 +29,9 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of - * {@link LspConnection.terminate}'s escalation, and the bound for draining - * pipes a surviving helper still holds after the server exits. + * The subprocess spec's `graceMs`: available to the provider's termination + * procedure and used to bound draining pipes a survivor still holds after + * the server exits. */ readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ @@ -88,7 +88,7 @@ export class LspConnection { this.decoder = new MessageDecoder(spec.maxMessageBytes) // stdin/stdout are piped protocol streams this endpoint frames itself; // stderr is a collected diagnostic tail (no spill — the bounded tail IS - // the contract). The seam owns detachment and tree-scoped signalling. + // the contract). The seam owns managed-range signalling and quiescence. this.handle = spawner({ argv: [spec.command, ...spec.args], cwd: spec.cwd, @@ -209,15 +209,15 @@ export class LspConnection { return this.nextId } - /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ + /** Start the provider's idempotent termination procedure for the server's managed range. */ terminate(): void { this.handle.terminate() } /** - * Wait until the owned process tree has exited. + * Wait until the provider-managed range is empty. * @param signal - optional bound for the wait. - * @returns `true` when the tree exited, or `false` when the signal aborted first. + * @returns `true` when the range is empty, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { return await this.handle.waitForExit(signal) diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index ebc54702da..abd7ab35c5 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -72,9 +72,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ + /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ killGraceMs?: number } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index 2028129de7..88e80b88b0 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -269,8 +269,8 @@ export class LspInstance { } /** - * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting - * process close so nothing outlives disposal. + * Reject queued work, attempt graceful `shutdown`/`exit`, then request the + * provider's termination procedure and await process close and range quiescence. */ async dispose(): Promise { await this.startTeardown() @@ -288,7 +288,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. + // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -303,10 +303,9 @@ export class LspInstance { } /** - * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), - * then await leader and helper exit. The awaits are unbounded on purpose: - * the seam's escalation already committed to SIGKILL, so quiescence — not - * another timer — is the postcondition disposal owes its callers. + * Start the provider's termination procedure, then await command transport + * and managed-range exit. The awaits are unbounded on purpose: quiescence, + * not another consumer timer, is the postcondition disposal owes its callers. */ private async forceTerminate(): Promise { this.connection.terminate() diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 9efc091919..a2dcebcc8f 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -312,7 +312,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it('awaits a surviving process-tree helper on every concurrent dispose', async () => { + it('awaits a surviving managed-range helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 82a7387566..e7e0526ec3 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.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 packages/shell/bash-local/README.md -README.md: 5e62ea24676f3bedf32b1b48f8618d703d5fb462 -README.zh.md: 92f842a777aebec2e2cb6a8c54966e46202531f1 +README.md: 7bdaa7442e7d4166ecae6328d2456ddc22a01cbc +README.zh.md: bf33c9d1108be92ffadcd60363d4d773f7591071 diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 5e62ea2467..7bdaa7442e 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. +Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call in a provider-managed range through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. @@ -17,14 +17,14 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # kill escalation and post-exit pipe-drain grace + graceMs: 3000 # subprocess termination and post-exit pipe-drain grace ``` ## Behavior - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Background processes** — `start()` returns a live `ShellProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Job ids, ownership, polling, and notices belong to the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with. @@ -41,7 +41,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **POSIX-only** — the `bash` binary is hardcoded, so this executor is not composed on Windows. - **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index 92f842a777..bf33c9d110 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `bash -c `,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、终止和 dispose(资源释放)等 managed-range 机制则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -17,14 +17,14 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # kill escalation and post-exit pipe-drain grace + graceMs: 3000 # subprocess termination and post-exit pipe-drain grace ``` ## 行为 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 - **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 -- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **在 managed range 之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。 - **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **后台进程**:`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md),工具层会在其中注册该句柄。 @@ -41,7 +41,7 @@ - **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 -- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,因此本执行器不会在 Windows 上组装。 - **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 6c37c5b794..dbfd90ed22 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -31,7 +31,7 @@ export const ENV_OVERRIDES = { GIT_PAGER: 'cat', } as const -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ +/** Default subprocess termination and output-drain grace (the `graceMs` config; matches OpenCode's 3s). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -49,7 +49,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } @@ -94,8 +94,8 @@ export function assertServiceableBashConfig(config: Config): void { /** * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and - * process-group SIGTERM→SIGKILL escalation are the subprocess service's - * mechanics; this executor supplies their configured budgets per spawn, so a + * provider-owned managed-range termination are the subprocess service's + * mechanics; this executor supplies the configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ @@ -245,7 +245,7 @@ export class LocalBashExecutor extends ShellExecutor { /** * Start an explicit argv with the background lifecycle, environment, output, - * cancellation, and process-tree ownership semantics of this executor. + * cancellation, and managed-range ownership semantics of this executor. * Subclasses use this after replacing the public command's shell argv at an * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index a7712c1df6..cc1fee06f8 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.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 packages/shell/pwsh-local/README.md -README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60 -README.zh.md: b7072ebd9be4e148cedd4edd6c20b12536814856 +README.md: 788495ebc5bb4d498d53eada8fb5401bb3639b43 +README.zh.md: 65297a0606f62c769b8ae219066e1ecce32d1b82 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 2eccc59b91..788495ebc5 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. +Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call in a provider-managed range through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. @@ -19,7 +19,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # kill escalation and post-exit pipe-drain grace + graceMs: 3000 # subprocess termination and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. - **Background processes** — `start()` returns a live `ShellProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index b7072ebd9b..65297a0606 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose(资源释放))属于 subprocess 服务。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。Managed-range 机制(有界 spill 输出、凭据清理、终止、dispose(资源释放))属于 subprocess 服务。 命令字符串作为单个 argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。 @@ -19,7 +19,7 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # kill escalation and post-exit pipe-drain grace + graceMs: 3000 # subprocess termination and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 -- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **Managed range 之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 - **后台进程**——`start()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose(被终止并 join)。一切任务相关职责(job id、所有权、轮询、通知)都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index b7a2d9f915..0760912c63 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -48,7 +48,7 @@ export const ENV_OVERRIDES = { export const ENCODING_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ +/** Default subprocess termination and output-drain grace (the `graceMs` config). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -66,7 +66,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -122,7 +122,7 @@ export function assertServiceablePwshConfig(config: Config): void { /** * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill - * files, and process-tree termination are the subprocess service's mechanics; + * files, and managed-range termination are the subprocess service's mechanics; * this executor supplies their configured budgets per spawn. */ export class PwshLocalExecutor extends ShellExecutor { diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index e26211b268..dab4c4a245 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -2,7 +2,7 @@ * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess * service plus a REAL pwsh executable, exercised through the executor seam * (`resolve` → `run`/`start`). These verify the world — actual PowerShell - * runs, output capture, truncation and spill, deadlines, kill escalation, and + * runs, output capture, truncation and spill, deadlines, termination, and * the background-handle contract. The suite self-skips when no usable `pwsh` * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests * (config validation, executable resolution) run on every platform. PowerShell @@ -400,7 +400,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' expect(lf(read.delta)).toContain('[stderr]') }) - it('kill() terminates the process tree: true once, false after settlement', async () => { + it('kill() terminates the managed range: true once, false after settlement', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) expect(proc.kill()).toBe(true) diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 4aaf1b7552..0a82434228 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.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 packages/subagent/subagent-acp/README.md -README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 -README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88 +README.md: eeeaa9350103761b1507c54d3bb48de97d735906 +README.zh.md: d70e30f7b85fbb052101ef7cc43adc2970630295 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 3bccddbca0..eeeaa93501 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's provider-owned `terminate()` procedure and await managed-range exit. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -31,7 +31,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | -| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess provider's termination procedure and managed-range join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 7ae89ece0c..d70e30f7b8 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄由 provider 拥有的 `terminate()` 过程并等待 managed range 退出。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -31,7 +31,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | -| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由 subprocess provider 执行其终止过程并等待 managed range 停稳。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。 diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 4b526279ba..0b4ad14baa 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -55,11 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent escalates to a signal. Must not exceed + * before the parent invokes provider termination. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5ba7bc1718..a4b80a6d99 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -58,19 +58,18 @@ export interface AcpRunSpec { /** * Grace period (ms) for the child's EOF-driven quiesce in * {@link SubagentRun.dispose} — the window to flush persistence and tear down - * its OWN nested subprocesses before the parent escalates to a signal. The + * its OWN nested subprocesses before the parent invokes provider termination. The * plugin fills this from its `disposeEofGraceMs` config. */ disposeEofGraceMs: number /** - * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX - * waits this long after `SIGTERM` before `SIGKILL`, while Windows - * force-terminates directly. The plugin fills it from `disposeGraceMs`. + * Grace supplied to subprocess termination and output draining in + * {@link SubagentRun.dispose}. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the - * child rides the shared scrub, tree-scoped teardown, and service-owned + * child rides the shared scrub, managed-range teardown, and service-owned * lifetime instead of a package-local child_process path. */ spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -85,13 +84,13 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ +/** EOF grace for child flush and nested-process teardown; wider than the termination grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ +/** Default subprocess termination and output-drain grace (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +/** Bounded managed-range wait: polls the handle until its owned range exits or `ms` elapses. */ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { const controller = new AbortController() const timer = setTimeout(() => { controller.abort() }, ms) @@ -104,10 +103,9 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise {} diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index e9c3d81156..a01b4c560f 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -40,7 +40,7 @@ export function sdkEnvironmentOverlay( /** * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. - * @param graceMs - process-tree termination grace. + * @param graceMs - subprocess termination and output-drain grace. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( @@ -73,7 +73,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** * Project a managed process with piped stdin and stdout. - * @param child - shared handle that remains the process-tree authority. + * @param child - shared handle that remains the managed-range authority. */ constructor(private readonly child: SubprocessHandle) { this.stdin = child.stdin as NonNullable @@ -93,7 +93,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { ) } - /** Whether the SDK has requested managed tree termination. */ + /** Whether the SDK has requested managed-range termination. */ get killed(): boolean { return this.killRequested } @@ -114,8 +114,8 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { } /** - * Route the SDK's termination request to the tree-scoped process owner. - * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. + * Route the SDK's termination request to the managed-range owner. + * @param _signal - SDK-selected signal; the provider owns its termination procedure. * @returns false only after exit or a previous termination request. */ kill(_signal: NodeJS.Signals): boolean { diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 3b0a19073d..4b74d9041a 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its * real CLI process under the shared subprocess owner, map only strict SDK - * success to completion, and dispose to whole-tree quiescence. + * success to completion, and dispose to managed-range quiescence. * * @module @deepseek-ai/dsh-subagent-claude-code/run */ @@ -36,7 +36,7 @@ import { ManagedClaudeCodeProcess, } from './process.ts' -/** Default POSIX grace between subprocess termination tiers. */ +/** Default subprocess termination and output-drain grace. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** Claude Code permission modes that cannot wait for a human response. */ @@ -154,7 +154,7 @@ export interface ClaudeCodeRunSpec { readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Subprocess termination grace passed to the shared process-tree owner. */ + /** Grace passed to the shared subprocess owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -259,10 +259,10 @@ export async function consumeClaudeQuery( } /** - * Close the official query, terminate the managed process tree, and wait for - * the subprocess owner to prove it is gone. + * Close the official query, start managed-range termination, and wait for the + * subprocess owner to prove the range is empty. * @param query - official SDK query, when creation reached that point. - * @param child - live shared-service handle that owns the CLI process tree; + * @param child - live shared-service handle that owns the CLI managed range; * spawn-failed handles settle at the startup boundary instead. */ export async function disposeClaudeCodeChild( diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 0f8ed31eab..6345a1581e 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.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 packages/subagent/subagent-codex/README.md -README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 -README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 +README.md: a0ae6a2f614e20dce0b20220706f63b5a993a8cf +README.zh.md: 4562285158283ba239182642565f83479331e343 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 975f353b9f..a0ae6a2f61 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -6,7 +6,7 @@ This package registers a Profile-named Codex subagent provider whose default nam ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, invokes managed-range termination, waits for the range to empty, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. @@ -14,7 +14,7 @@ For command and file approvals, the unattended provider selects a non-approval d Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts. -`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. +`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. ## Capabilities and context @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), supplied to subprocess termination and output draining; disposal then waits for managed-range exit. | | `permissionMode` value | `thread/start` fields | Native behavior | |---|---|---| diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 2ea256afb3..4562285158 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -6,7 +6,7 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、启动 managed-range 终止并等待该范围为空,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 @@ -14,7 +14,7 @@ 本地取消会在结果竞态中胜出并映射为 `aborted`。对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start`、`turn` 或 `process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded` 仍映射为 `max-tokens`;其他任何远端中断或失败仍映射为 `error`,且该提供方不会产生 `refusal`。参与失败的权限决定会跟在结构化失败行之后。成功与本地取消都不附带这两类事实。 -`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 +`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 ## 能力与上下文 @@ -27,7 +27,7 @@ | `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | +| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待 managed range 退出。 | | `permissionMode` 值 | `thread/start` 字段 | 原生行为 | |---|---|---| diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 79fbdab078..6089a69d75 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -43,7 +43,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds for app-server termination and output draining. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index ec9a6302c4..0c350fffa7 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and process-tree ownership belongs to the subprocess service. + * service and managed-range ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 9db5d7086c..025cbb42d9 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Codex child lifecycle: spawn the real app-server through the * subprocess seam, publish only after initialization and ephemeral thread - * creation, flatten post-publication failures, and dispose to whole-tree + * creation, flatten post-publication failures, and dispose to managed-range * quiescence. * * @module @deepseek-ai/dsh-subagent-codex/run @@ -31,7 +31,7 @@ import { type CodexWireFailureFacts, } from './wire.ts' -/** Default POSIX grace between subprocess termination tiers. */ +/** Default subprocess termination and output-drain grace. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 interface CodexPackageManifest { @@ -141,7 +141,7 @@ export interface CodexRunSpec { readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace passed to the shared process-tree owner. */ + /** Grace passed to the shared subprocess owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -177,10 +177,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { } /** - * Close the private wire, terminate the managed process tree, and wait for the - * subprocess owner to prove it is gone. + * Close the private wire, start managed-range termination, and wait for the + * subprocess owner to prove the range is empty. * @param wire - private app-server protocol connection. - * @param child - shared-service handle that owns the process tree. + * @param child - shared-service handle that owns the managed range. */ export async function disposeCodexChild( wire: CodexAppServerWire, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 38c93dee23..58aa8798a6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -2296,7 +2296,7 @@ describe('disposeCodexChild', () => { .resolves.toBeUndefined() }) - it('handles a spawn-level failure with no process tree', async () => { + it('handles a spawn-level failure with no managed range', async () => { const child = fakeChild({ pid: -1, doneError: new Error('spawn failed'), diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index abb6dd50e7..edaf0286ef 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -5,7 +5,7 @@ * working-directory resolution (config override, else the delegating parent * session's workspace), the never-reject result settlement, and the standard * run-handle publication. Backends compose these with their own wire drivers; - * the process machinery itself (spawn, env scrub, tree-scoped teardown) + * the process machinery itself (spawn, env scrub, managed-range teardown) * belongs to the `dsh-subprocess` seam. * * @module @deepseek-ai/dsh-subagent/out-of-process diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index cb27192356..a240b04ac4 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: 1b516c36d81a51fc2e0023d69f748cb4b46f36a4 -README.zh.md: cb83d0a9d417114f20e8fbd970f57c5d917f736f +README.md: dfb5763d3ac867ddd5b941d5619bf88a2da73cda +README.zh.md: 3f0e7f930133468dcb83ef47e5ec35c67f44ce63 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 1b516c36d8..dfb5763d3a 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -The shared process substrate for one execution world: executable lookup, fully-specified managed child-process trees with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared process substrate for one execution world: executable lookup, fully specified provider-managed ranges with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: native and fallback managed ranges, bounded collection/spill, `node-pty`, foreground/session inspection, signalling, and terminate-and-join disposal | | [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index cb83d0a9d4..3f0e7f9301 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完全明确指定的受管子进程树,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 +这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完整 provider-managed range,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和 provider 仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 | 包 | ctx 键 | 角色 | |---|---|---| | [`subprocess`](subprocess/README.zh.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | -| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:native 与 fallback managed range、有界收集/spill、`node-pty`、前台/会话检查、信号发送,以及先终止再等待退出的 dispose(资源释放) | | [`win32-process`](win32-process/README.zh.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名管道 stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5d30fa50ba..1e77a36b97 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -165,10 +165,9 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) - // Release ownership only once the whole TREE is gone, not at direct-child - // settlement — a TERM-trapping helper that outlives the leader must stay - // owned so teardown can still escalate it. For the common no-survivor - // case waitForExit resolves immediately after settlement. + // Release ownership only once the managed range is empty, not at spawned- + // command settlement. A surviving helper remains owned until provider + // termination and observation reach quiescence. const release = (): Promise => handle.waitForExit().then(() => { this.live.delete(handle) }) void handle.done.then(release, release).catch(() => {}) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index b579fb022a..7dcb054d06 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,6 +1,6 @@ /** - * Process plumbing for the local subprocess service: detached process-tree - * spawn with per-stream stdio dispositions, tail-keep collection with spill + * Process plumbing for the local subprocess service: ordinary process launch + * with per-stream stdio dispositions, tail-keep collection with spill * files, provider-owned range signalling, and common termination scheduling. * POSIX owners stage TERM before KILL; Windows owners terminate immediately. * This layer reacts to an abort signal; callers own deadlines, teardown @@ -420,7 +420,7 @@ function fallbackOwner( } /** - * Bind platform launch facts to the existing stdio, outcome, abort, and escalation lifecycle. + * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param launch - platform child streams, direct outcome, and managed-range owner. * @param internals - test-only spill-directory override. @@ -539,8 +539,8 @@ export function bindManagedProcess( if (directOutcome !== undefined) settle(directOutcome) }) function cleanup(): void { - // graceTimer deliberately NOT cleared: the SIGKILL escalation must be - // able to reach tree survivors after the direct child settles. + // graceTimer deliberately NOT cleared: the forced termination call must + // still reach range survivors after the spawned command settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) } }) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 3de46915da..717c28dc6a 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -39,19 +39,33 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private stopped = false + private runnerClosed = false private readonly observation: Promise constructor(private readonly runner: ReturnType) { - this.observation = new Promise((resolve) => { - runner.once('close', () => { - this.stopped = true - resolve() + this.observation = new Promise((resolve, reject) => { + runner.once('close', (exitCode, signal) => { + this.runnerClosed = true + if (exitCode === 0 && signal === null) { + this.stopped = true + resolve() + return + } + const status = signal !== null + ? `signal ${signal}` + : exitCode === null + ? 'without an exit status' + : `exit code ${String(exitCode)}` + reject(new Error( + `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, + )) }) }) + void this.observation.catch(() => {}) } signal(_signal: NodeJS.Signals): void { - if (this.stopped) return + if (this.stopped || this.runnerClosed) return try { if (this.runner.connected) { this.runner.send({ type: 'terminate' }, (error) => { diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index c3971b5077..2dd5451f03 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -7,10 +7,11 @@ const request = consumeRunnerRequest(requestPath) appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) const configuredExit = Number(request.argv[1]) +// Events carry target results; zero means the runner completed its own observation. if (Number.isSafeInteger(configuredExit)) { setTimeout(() => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exitCode = configuredExit + process.exitCode = 0 }, 10) } else { const hold = setInterval(() => {}, 1_000) @@ -21,7 +22,7 @@ if (Number.isSafeInteger(configuredExit)) { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) clearInterval(hold) if (process.connected) process.disconnect() - process.exitCode = 1 + process.exitCode = 0 } process.on('message', (message: unknown) => { if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index c89b46581a..98427dfc16 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -114,6 +114,8 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { handle.stdout?.once('end', resolve) handle.stdout?.once('error', reject) }) + // A Readable reports `end` only after the consumer drains any buffered bytes. + handle.stdout.resume() const descendant = await waitForPid(pidFile) try { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 0d69a52331..4e1d03c055 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -60,6 +60,33 @@ describe('Windows Job runner adapter', () => { launch.owner.signal('SIGKILL') }) + it.each([ + { exitCode: 127, signal: null, status: 'exit code 127' }, + { exitCode: null, signal: 'SIGTERM' as NodeJS.Signals, status: 'signal SIGTERM' }, + { exitCode: null, signal: null, status: 'without an exit status' }, + ])('rejects range settlement when the runner exits with $status', async ({ exitCode, signal, status }) => { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + Object.assign(child, { pid: 432, connected: false, kill }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + const directFailure = launch.direct.catch((error: unknown) => error) + + child.emit('close', exitCode, signal) + + await expect(launch.owner.waitForExit()).rejects.toThrow( + `Windows Job runner exited with ${status} before proving its managed range empty`, + ) + await expect(directFailure).resolves.toBeInstanceOf(Error) + launch.owner.signal('SIGKILL') + expect(kill).not.toHaveBeenCalled() + }) + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { const child = new EventEmitter() as ChildProcess diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 398e87e81c..decbfdc80f 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 61aec9024427a6530f4e877c2fd35cdf301af169 -README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be +README.md: 20f2edea5d29e152ae01a968b81cbc79e9bb69e5 +README.zh.md: ea4801de670a90b6b158830a7f8b19ea936c6e5c diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 61aec90244..20f2edea5d 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so consumer teardown can await real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 4740bef706..ea4801de67 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方拆卸可以等待真正完全停稳。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 8fc9773df7..c6a452bbec 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -90,9 +90,9 @@ declare module '@deepseek-ai/cordis' { * streams are handed to the caller raw and never buffered here. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) starts the * provider's documented procedure against its managed range. - * {@link SubprocessHandle.waitForExit} observes that same range so a - * consumer-owned teardown ladder can hold each tier on real quiescence; each - * provider documents its identity, signalling, and observability limits. + * {@link SubprocessHandle.waitForExit} observes that same range so consumer + * teardown can await real quiescence; each provider documents its identity, + * signalling, and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index c2b43f919f..0ef3f5f981 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -89,9 +89,9 @@ export interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the terminate escalation on the managed range when - * it fires. The caller owns deadlines and cause classification; this seam - * only reacts to the abort. + * Abort signal — starts the provider's termination procedure on the managed + * range when it fires. The caller owns deadlines and cause classification; + * this seam only reacts to the abort. */ signal?: AbortSignal | undefined /** diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5e19c20113..d5ebe108c6 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -387,7 +387,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['subprocess-local', 'subprocess-e2b'], consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'], - note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.', + note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination.', }, { key: 'shell', From 975afc9e474614bda566950f1162aef1aea48737 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 07:36:08 +0800 Subject: [PATCH 030/110] fix(subprocess): release Windows runner stdio --- packages/subprocess/subprocess-local/src/spawn-runner.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 1636ce0fe5..4b8c6da4b8 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -99,6 +99,9 @@ function replaceEnvironment(env: Record): void { /** Release the runner's copies after the Windows target inherits its standard handles. */ function releaseRunnerStdio(): void { + const stdin = process.stdin + const stdout = process.stdout + const stderr = process.stderr for (const fd of [0, 1, 2]) { try { closeSync(fd) @@ -106,6 +109,12 @@ function releaseRunnerStdio(): void { if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error } } + // A loader may already have materialized Node's libuv stdio wrappers. Settle + // those runner-owned references after closing the CRT descriptors; the target + // keeps the handles CreateProcessW inherited and remains the only pipe writer. + stdin.destroy() + stdout.end() + stderr.end() } async function runWin32(request: RunnerRequest, eventsPath: string): Promise { From 889cbd45f1c4da1114366a4d793d15db2f11f324 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 07:49:15 +0800 Subject: [PATCH 031/110] fix(subprocess): order runner stdio shutdown --- .../subprocess/subprocess-local/src/spawn-runner.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 4b8c6da4b8..45bfac7ffa 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -102,6 +102,12 @@ function releaseRunnerStdio(): void { const stdin = process.stdin const stdout = process.stdout const stderr = process.stderr + // A loader may already have materialized Node's libuv stdio wrappers. Their + // shutdown must be requested before the CRT descriptors close; the target + // keeps the handles CreateProcessW inherited and remains the only pipe writer. + stdin.destroy() + stdout.end() + stderr.end() for (const fd of [0, 1, 2]) { try { closeSync(fd) @@ -109,12 +115,6 @@ function releaseRunnerStdio(): void { if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error } } - // A loader may already have materialized Node's libuv stdio wrappers. Settle - // those runner-owned references after closing the CRT descriptors; the target - // keeps the handles CreateProcessW inherited and remains the only pipe writer. - stdin.destroy() - stdout.end() - stderr.end() } async function runWin32(request: RunnerRequest, eventsPath: string): Promise { From 43b093a502eddf54fb62b3a22c3df1ef3381fa9a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 08:08:10 +0800 Subject: [PATCH 032/110] fix(subprocess): close runner pipe handles --- .../subprocess-local/src/spawn-runner.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 45bfac7ffa..0b1d3eadfa 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -97,17 +97,20 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } +interface MaterializedStdioStream { + readonly _handle?: { close(): void } | null +} + +function closeMaterializedStdio(stream: NodeJS.WriteStream): void { + (stream as unknown as MaterializedStdioStream)._handle?.close() +} + /** Release the runner's copies after the Windows target inherits its standard handles. */ function releaseRunnerStdio(): void { const stdin = process.stdin const stdout = process.stdout const stderr = process.stderr - // A loader may already have materialized Node's libuv stdio wrappers. Their - // shutdown must be requested before the CRT descriptors close; the target - // keeps the handles CreateProcessW inherited and remains the only pipe writer. stdin.destroy() - stdout.end() - stderr.end() for (const fd of [0, 1, 2]) { try { closeSync(fd) @@ -115,6 +118,11 @@ function releaseRunnerStdio(): void { if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error } } + // Node deliberately keeps stdout/stderr alive when destroy() is called. A + // loader may already have materialized their libuv handles, so close those + // runner-owned references explicitly; the target keeps its inherited copies. + closeMaterializedStdio(stdout) + closeMaterializedStdio(stderr) } async function runWin32(request: RunnerRequest, eventsPath: string): Promise { From f913c813d2e975e84cce519060ddddc22887b5cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 08:21:33 +0800 Subject: [PATCH 033/110] fix(subprocess): harden managed-range cleanup --- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 2 +- ...-08-20-subprocess-native-containment.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 9 ++--- .../subagent-acp/tests/subagent-acp.spec.ts | 27 ++++++++++++- .../subagent/subagent-claude-code/src/run.ts | 22 +++++----- .../tests/subagent-claude-code.spec.ts | 4 +- packages/subagent/subagent-codex/src/run.ts | 40 +++++++++---------- .../tests/subagent-codex.spec.ts | 9 +++-- .../subprocess/subprocess-local/src/index.ts | 9 ++++- .../subprocess-local/src/linux-scope.ts | 9 +---- .../subprocess-local/src/runner-launch.ts | 7 +--- .../subprocess-local/src/runner-protocol.ts | 16 +++++++- .../subprocess-local/src/spawn-runner.ts | 1 + .../subprocess/subprocess-local/src/spawn.ts | 14 ++++--- .../tests/linux-scope.spec.ts | 37 ++++++++++------- .../subprocess-local/tests/local.spec.ts | 35 ++++++++++++++++ .../tests/managed-spawn.spec.ts | 2 + .../tests/native-windows.spec.ts | 9 ++++- .../tests/spawn-runner.spec.ts | 33 ++++++++------- 20 files changed, 192 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index a3cf978af0..9c308e96a0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index d5a405cdda..c1f9901c10 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index abd450fe84..1a3bddf797 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.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 重放。 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index a4b80a6d99..688dc3c8a4 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -110,12 +110,9 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - // 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 diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 26ec4cd0b3..a05ea83630 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/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', () => { diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 4b74d9041a..dfcc9cdd31 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/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}`, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 3298e07037..ae6fbd6672 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/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({ diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 025cbb42d9..55382e7e55 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -188,32 +188,32 @@ export async function disposeCodexChild( ): Promise { 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(() => {}) } /** diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 58aa8798a6..bdd5ee4aeb 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/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 () => { diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 1e77a36b97..14bd6c3bda 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -94,8 +94,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const pending: Promise[] = [] 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()) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 24f7bb947b..411fc60b94 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/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 { @@ -106,7 +106,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | 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 } } diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index f09da5adca..aec1a3d1f0 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -114,7 +114,6 @@ async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], closed: Promise, - missingResult?: () => SubprocessOutcome | undefined, ): Promise { 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, - missingResult?: () => SubprocessOutcome | undefined, ): { pid: number direct: Promise @@ -165,7 +160,7 @@ export function runnerDirectResult( } return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, closed, missingResult), + direct: waitForDirectResult(files, handshake.events, closed), } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 0eab839b9f..73762bf2dc 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/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. } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 0b1d3eadfa..eab4c35a92 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -134,6 +134,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() + } let graceTimer: ReturnType | 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))) }) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ffbc047518..fb003e6d9e 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/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 = [] 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 | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[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) }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e7e24ca07b..37bcae6866 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/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 + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: new Promise(() => {}), + terminate, + terminateForHostExit, + waitForExit: async () => { throw rangeFailure }, + }) + + await expect(Promise.race([ + fiber.dispose().then(() => 'disposed'), + new Promise(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() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 4ea1581fa3..ed088f4808 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/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') } diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 98427dfc16..a9d6fdece0 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/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' }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index c54d831c43..595e53fe84 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/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 () => { From e70405847c39331e72a9ebde90a55a248d10b9cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 08:37:50 +0800 Subject: [PATCH 034/110] fix(subprocess): complete runner resource release --- .../tests/subagent-claude-code.spec.ts | 25 +++++++++++-------- .../subprocess-local/src/spawn-runner.ts | 10 +++----- .../tests/spawn-runner.spec.ts | 14 ++++++++++- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index ae6fbd6672..44455f3d65 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1505,10 +1505,12 @@ describe('run publication, cancellation, and settlement', () => { expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() const failedSpawnCloseError = new Error('query close failed') + const failedSpawnWaitError = new Error('managed range wait failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ pid: -1, doneError: spawnError, + waitForExitError: failedSpawnWaitError, }) queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) @@ -1518,17 +1520,18 @@ describe('run publication, cancellation, and settlement', () => { ...unused.spec, spawn: () => failedSpawnWithCloseFailure.handle, }) - await expect(failedWithCloseFailure) - .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) - await expect(failedWithCloseFailure) - .rejects.not.toThrow('spawn /sdk/claude EACCES') - await expect(failedWithCloseFailure).rejects.toMatchObject({ - message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, - errors: [ - expect.objectContaining({ cause: spawnError }), - expect.objectContaining({ cause: failedSpawnCloseError }), - ], - }) + const failedWithCloseError = await failedWithCloseFailure.catch((error: unknown) => error) + expect(failedWithCloseError).toBeInstanceOf(AggregateError) + expect(String(failedWithCloseError)).toContain(expectedFailureDiagnostic('query-start', 'unknown')) + expect(String(failedWithCloseError)).not.toContain('spawn /sdk/claude EACCES') + const failures = (failedWithCloseError as AggregateError).errors as unknown[] + expect(failures[0]).toMatchObject({ cause: spawnError }) + const cleanupCause = errorCause(failures[1]) + expect(cleanupCause).toBeInstanceOf(AggregateError) + expect((cleanupCause as AggregateError).errors).toEqual([ + failedSpawnCloseError, + failedSpawnWaitError, + ]) const cleanupError = new Error('live child cleanup failed') const constructionError = new Error( diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index eab4c35a92..aac9215a82 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -101,15 +101,13 @@ interface MaterializedStdioStream { readonly _handle?: { close(): void } | null } -function closeMaterializedStdio(stream: NodeJS.WriteStream): void { - (stream as unknown as MaterializedStdioStream)._handle?.close() -} - /** Release the runner's copies after the Windows target inherits its standard handles. */ function releaseRunnerStdio(): void { const stdin = process.stdin const stdout = process.stdout const stderr = process.stderr + const stdoutHandle = (stdout as unknown as MaterializedStdioStream)._handle + const stderrHandle = (stderr as unknown as MaterializedStdioStream)._handle stdin.destroy() for (const fd of [0, 1, 2]) { try { @@ -121,8 +119,8 @@ function releaseRunnerStdio(): void { // Node deliberately keeps stdout/stderr alive when destroy() is called. A // loader may already have materialized their libuv handles, so close those // runner-owned references explicitly; the target keeps its inherited copies. - closeMaterializedStdio(stdout) - closeMaterializedStdio(stderr) + stdoutHandle?.close() + stderrHandle?.close() } async function runWin32(request: RunnerRequest, eventsPath: string): Promise { diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 595e53fe84..370e92ea76 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,6 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' -import { existsSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -127,6 +127,18 @@ describe('spawn runner transport', () => { } }) + it('contains an unexpected owned-path cleanup failure', () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + rmSync(files.requestPath, { force: true }) + mkdirSync(files.requestPath) + try { + expect(() => { cleanupRunnerFiles(files) }).not.toThrow() + expect(existsSync(files.directory)).toBe(true) + } finally { + rmSync(files.directory, { recursive: true, force: true }) + } + }) + it.each([ ['non-object request', null, 'no executable'], ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'], From ea7de60840107e903f4d2be2282efd6d50bea75c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 09:51:21 +0800 Subject: [PATCH 035/110] fix(subprocess): keep Windows Job ownership in parent --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 2 +- ...8-19-shared-win32-process-primitives.zh.md | 2 +- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 2 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 2 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 6 +- ...-08-20-subprocess-native-containment.zh.md | 6 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 6 +- .../subprocess/subprocess-local/README.zh.md | 6 +- .../subprocess-local/src/runner-launch.ts | 7 +- .../subprocess-local/src/spawn-runner.ts | 86 ++----- .../subprocess-local/src/windows-job.ts | 152 +++++++---- .../tests/fixtures/fake-job-runner.ts | 17 +- .../tests/spawn-runner.spec.ts | 4 +- .../tests/windows-job.spec.ts | 241 ++++++++++-------- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 2 + packages/subprocess/win32-process/src/ffi.ts | 10 +- .../subprocess/win32-process/src/index.ts | 7 +- .../subprocess/win32-process/src/process.ts | 82 ++++-- .../tests/ordinary-process.spec.ts | 45 +++- 26 files changed, 412 insertions(+), 301 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index b3aae39a5d..fe7dec2097 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0 -2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36 +2026-08-19-shared-win32-process-primitives.md: 67ee4527cdbb63724e371707bfa2ef19f4a86838 +2026-08-19-shared-win32-process-primitives.zh.md: 4c7a683c38c03dbc64a3fd77f33d71daf7b7a209 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index a3ab8ebcfa..67ee4527cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,7 +14,7 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately and closes the Job only after it is empty. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately, while the subprocess parent owns Job accounting, termination, and closure. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index e64f7537cb..4c7a683c38 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,7 +14,7 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,并只在 Job 为空后关闭它。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,而 subprocess parent 拥有 Job accounting、termination 与 closure。 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 88f71563cf..4b9440bce5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 4f0603f6e2cb3c0cc4c0d4c0cde1b0da0ae82763 -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: acb20c8989d21b1fd5e97fa44247cd2875fb4570 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 8e0e9b338f1c0f5f2aa0d6a7acde5807025f82ad +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 723d93c9bfb6898563b10c05ac2705a3dee50646 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 4f0603f6e2..8e0e9b338f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -16,7 +16,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: -- An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. +- An ordinary handle synchronously signals its bound native scope or parent-held Job when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index acb20c8989..723d93c9bf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -16,7 +16,7 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: -- 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 +- 普通 handle在可用时同步向绑定的 native scope 或 parent-held Job 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 9c308e96a0..125c8ff688 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: c1f9901c10e1ac09304bba10805ec019e8ae3247 -2026-08-20-subprocess-native-containment.zh.md: 1a3bddf797b1a9c4c189501a733a1242cb153b06 +2026-08-20-subprocess-native-containment.md: 4ff86b626f8d0fbb7c09ce82f5115774199e397a +2026-08-20-subprocess-native-containment.zh.md: 4f8390974f2d3a4e34704dce574d45079c9a27be diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index c1f9901c10..4ff86b626f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -10,11 +10,11 @@ The local subprocess provider treated a POSIX process group or a Windows direct- ## Decision -`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. +`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows creates and retains a named kill-on-close Job; a local runner backed by `@deepseek-ai/dsh-win32-process` opens that Job, creates the target suspended, assigns it, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.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. +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 parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume, closes its copy, then exits after publishing the direct result; the parent owner independently terminates the Job and polls `ActiveProcesses`. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. 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. @@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each runner remains only until the direct target result while the OS owner persists for descendants. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 1a3bddf797..4f8390974f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -10,11 +10,11 @@ Status: implemented ## Decision -`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 +`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.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。 +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 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job,随后关闭自身副本,并在发布 direct result 后退出;parent owner 独立终止 Job 并轮询 `ActiveProcesses`。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 18ed126f0e..96b14e63c1 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: af971f2f9706117d9a7be8fdeb29eea4d33f72e9 -README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a +README.md: a2766a3a90f14c69727d2708d92927e03334ed1f +README.zh.md: d26b01ebebd0fe3ccd37b4771f1d224b96973bfd diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index af971f2f97..a2766a3a90 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, resumes it, then closes its own Job handle and exits after publishing the direct result. Raw pipe EOF therefore follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). @@ -14,7 +14,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes. -- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the parent-owned Windows Job receives immediate termination and its handle also closes with the host; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each runner remains only until the direct target result; the OS-owned scope or parent-held Job persists for later descendants. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e829dce28d..d26b01ebeb 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复,随后关闭自己的 Job handle,并在发布 direct result 后退出。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 @@ -14,7 +14,7 @@ - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;parent-owned Windows Job 会被立即终止,其 handle 也会随 host 关闭;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 ## 模型体验 @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index aec1a3d1f0..c29621ea07 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,17 +41,14 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. - * @param ipc - append a Node IPC channel for the Windows runner. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { - const stdio: StdioOptions = [ +export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { + return [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] - if (ipc) stdio.push('ipc') - return stdio } /** diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index aac9215a82..b4816d52f2 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,14 +1,12 @@ /** Native managed-range runner for ordinary local subprocesses. */ import { spawn } from 'node:child_process' -import { closeSync } from 'node:fs' import { closeHandleChecked, - isJobEmpty, loadWin32ProcessBindings, + openJobForAssignment, pollProcessExit, - spawnOrdinaryJobProcess, - terminateJob, + spawnOrdinaryProcessInJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' import type { NativePtr } from '@deepseek-ai/dsh-win32-process' @@ -22,10 +20,12 @@ import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = | { mode: 'probe-node' } | { mode: 'probe-win32' } - | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } + | { mode: 'node'; requestPath: string; eventsPath: string } + | { mode: 'win32'; requestPath: string; eventsPath: string; jobName: string } function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined + let jobName: string | undefined let requestPath: string | undefined let eventsPath: string | undefined for (let index = 0; index < argv.length; index += 2) { @@ -33,6 +33,7 @@ function parseArgs(argv: string[]): RunnerArgs { const value = argv[index + 1] if (value === undefined) throw new Error(`subprocess runner missing value after ${String(key)}`) if (key === '--mode') mode = value + else if (key === '--job') jobName = value else if (key === '--request') requestPath = value else if (key === '--events') eventsPath = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) @@ -40,6 +41,10 @@ function parseArgs(argv: string[]): RunnerArgs { if (mode === 'probe-node' || mode === 'probe-win32') return { mode } if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') + if (mode === 'win32') { + if (jobName === undefined || jobName.length === 0) throw new Error('subprocess runner requires a Windows Job name') + return { mode, requestPath, eventsPath, jobName } + } return { mode, requestPath, eventsPath } } @@ -97,62 +102,21 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -interface MaterializedStdioStream { - readonly _handle?: { close(): void } | null -} - -/** Release the runner's copies after the Windows target inherits its standard handles. */ -function releaseRunnerStdio(): void { - const stdin = process.stdin - const stdout = process.stdout - const stderr = process.stderr - const stdoutHandle = (stdout as unknown as MaterializedStdioStream)._handle - const stderrHandle = (stderr as unknown as MaterializedStdioStream)._handle - stdin.destroy() - for (const fd of [0, 1, 2]) { - try { - closeSync(fd) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error - } - } - // Node deliberately keeps stdout/stderr alive when destroy() is called. A - // loader may already have materialized their libuv handles, so close those - // runner-owned references explicitly; the target keeps its inherited copies. - stdoutHandle?.close() - stderrHandle?.close() -} - -async function runWin32(request: RunnerRequest, eventsPath: string): Promise { +async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false 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) { - appendRunnerEvent(eventsPath, { type: 'spawn-error', error: win32SpawnError(error, request) }) - return - } + process.chdir(request.cwd) + jobHandle = openJobForAssignment(api, jobName) + const [command, ...args] = request.argv + const spawned = spawnOrdinaryProcessInJob(api, { command: command as string, args, cwd: process.cwd() }, jobHandle) processHandle = spawned.process - jobHandle = spawned.job targetStarted = true - let terminationRequested = false - const terminate = (): void => { - if (terminationRequested || jobHandle === undefined) return - terminationRequested = true - terminateJob(api, jobHandle, 1) - } - process.on('message', (message: unknown) => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() - }) - process.on('disconnect', terminate) - releaseRunnerStdio() + closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') + jobHandle = undefined appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) await new Promise((resolve, reject) => { @@ -164,14 +128,10 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { } const request = consumeRunnerRequest(args.requestPath) if (args.mode === 'node') runNode(request, args.eventsPath) - else { - try { - await runWin32(request, args.eventsPath) - } finally { - if (process.connected) process.disconnect() - } - } + else await runWin32(request, args.eventsPath, args.jobName) } main().catch((error: unknown) => { diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 717c28dc6a..1acccb00d2 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,7 +1,17 @@ /** Windows Job runner launch and managed-range ownership. */ import { spawn, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { + closeHandleChecked, + createKillOnCloseJob, + isJobEmpty, + loadWin32ProcessBindings, + terminateJob, +} from '@deepseek-ai/dsh-win32-process' +import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { observeChildClose, waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' @@ -12,12 +22,35 @@ import { runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' +import { cleanupRunnerFiles } from './runner-protocol.ts' + +const JOB_POLL_INTERVAL_MS = 10 + +/** Parent-side operations for one Windows Job handle. */ +export interface WindowsJobOperations { + create(name: string): NativePtr + empty(job: NativePtr): boolean + terminate(job: NativePtr): void + close(job: NativePtr): void +} + +function nativeJobOperations(): WindowsJobOperations { + const api = loadWin32ProcessBindings() + return { + create: name => createKillOnCloseJob(api, name), + empty: job => isJobEmpty(api, job), + terminate: (job) => { terminateJob(api, job, 1) }, + close: (job) => { closeHandleChecked(api, job, 'ordinary process Job') }, + } +} /** Test seams for the runner process. */ export interface WindowsJobInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync runnerInvocation?: string[] + jobs?: WindowsJobOperations + jobName?: () => string } /** @@ -39,53 +72,57 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private stopped = false - private runnerClosed = false - private readonly observation: Promise + private closed = false + private terminationRequested = false + private terminationFailure: Error | undefined + private observation: Promise | undefined - constructor(private readonly runner: ReturnType) { - this.observation = new Promise((resolve, reject) => { - runner.once('close', (exitCode, signal) => { - this.runnerClosed = true - if (exitCode === 0 && signal === null) { - this.stopped = true - resolve() - return - } - const status = signal !== null - ? `signal ${signal}` - : exitCode === null - ? 'without an exit status' - : `exit code ${String(exitCode)}` - reject(new Error( - `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, - )) - }) - }) - void this.observation.catch(() => {}) - } + constructor( + private readonly job: NativePtr, + private readonly operations: WindowsJobOperations, + private readonly runnerClosed: Promise, + ) {} signal(_signal: NodeJS.Signals): void { - if (this.stopped || this.runnerClosed) return + if (this.stopped || this.terminationRequested) return + this.terminationRequested = true try { - if (this.runner.connected) { - this.runner.send({ type: 'terminate' }, (error) => { - if (error !== null) this.runner.kill() - }) - } else { - this.runner.kill() - } - } catch { - this.runner.kill() + this.operations.terminate(this.job) + } catch (error) { + this.terminationFailure = error instanceof Error ? error : new Error(String(error)) } } waitForExit(signal?: AbortSignal): Promise { - return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal) + if (this.observation !== undefined) return waitWithAbort(this.observation, signal) + if (this.stopped) return Promise.resolve(true) + this.observation = (async () => { + try { + while (!this.operations.empty(this.job)) { + if (this.terminationFailure !== undefined) throw this.terminationFailure + await sleepMs(JOB_POLL_INTERVAL_MS) + } + this.stopped = true + this.close() + await this.runnerClosed + } catch (error) { + this.stopped = true + try { this.close() } catch { /* Preserve the observation failure. */ } + throw error + } + })() + return waitWithAbort(this.observation, signal) + } + + private close(): void { + if (this.closed) return + this.operations.close(this.job) + this.closed = true } } /** - * Launch one direct command through the Job-owning runner. + * Launch one direct command through a runner into a parent-owned Job. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. * @param internals - injected process runner used by tests. * @returns wrapper streams, target outcome, and the bound Job owner. @@ -98,21 +135,40 @@ export function launchWindowsJob( const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') + /* v8 ignore next -- the native Windows suite exercises the real Job operations. */ + const jobs = internals.jobs ?? nativeJobOperations() + const jobName = (internals.jobName ?? (() => `Local\\dsh-subprocess-${randomUUID()}`))() const files = runnerFiles(spec) - const child = run(command, [ - ...prefix, - '--mode', - 'win32', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ], { - env: childEnv(), - stdio: runnerStdio(spec, true), - }) + let job: NativePtr + try { + job = jobs.create(jobName) + } catch (error) { + cleanupRunnerFiles(files) + throw error + } + let child: ReturnType + try { + child = run(command, [ + ...prefix, + '--mode', + 'win32', + '--job', + jobName, + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec), + }) + } catch (error) { + try { jobs.close(job) } catch { /* Preserve the launch failure. */ } + cleanupRunnerFiles(files) + throw error + } const closed = observeChildClose(child) - const owner = new WindowsJobOwner(child) + const owner = new WindowsJobOwner(job, jobs, closed) const result = runnerDirectResult(child, files, closed) cleanupAfterRunner(files, result.direct, closed) return { child, pid: result.pid, direct: result.direct, closed, owner } diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index 2dd5451f03..2a159b9bff 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -7,25 +7,12 @@ const request = consumeRunnerRequest(requestPath) appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) const configuredExit = Number(request.argv[1]) -// Events carry target results; zero means the runner completed its own observation. +// Events carry target results; zero means the runner completed its own work. if (Number.isSafeInteger(configuredExit)) { setTimeout(() => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) process.exitCode = 0 }, 10) } else { - const hold = setInterval(() => {}, 1_000) - let terminated = false - const terminate = (): void => { - if (terminated) return - terminated = true - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) - clearInterval(hold) - if (process.connected) process.disconnect() - process.exitCode = 0 - } - process.on('message', (message: unknown) => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() - }) - process.on('disconnect', terminate) + setInterval(() => {}, 1_000) } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 370e92ea76..419baaed07 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -85,11 +85,11 @@ describe('spawn runner transport', () => { expect(result.status).toBe(0) }) - it('maps every target stdio disposition and optional IPC channel', () => { + it('maps every target stdio disposition', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, - }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc']) + }))).toEqual(['pipe', 'inherit', 'inherit']) }) it('materializes and consumes the exact runner request once', () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 4e1d03c055..d2f199aa82 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -4,8 +4,10 @@ import { EventEmitter } from 'node:events' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent } from '../src/runner-protocol.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' +import type { WindowsJobOperations } from '../src/windows-job.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) const invocation = [process.execPath, '--import', 'tsx/esm', fixture] @@ -19,6 +21,26 @@ function spec(argv: string[]): SubprocessSpawnSpec { } } +function jobOperations(overrides: Partial = {}): { + operations: WindowsJobOperations + create: ReturnType + empty: ReturnType + terminate: ReturnType + close: ReturnType +} { + const create = vi.fn(() => 50n as NativePtr) + const empty = vi.fn(() => true) + const terminate = vi.fn() + const close = vi.fn() + return { + operations: { create, empty, terminate, close, ...overrides }, + create, + empty, + terminate, + close, + } +} + describe('Windows Job runner adapter', () => { it('probes the runner before a user command is selected', () => { const runSync = vi.fn(() => ({ status: 0, error: undefined })) as unknown as typeof spawnSync @@ -40,153 +62,172 @@ describe('Windows Job runner adapter', () => { }) it('reports direct outcome separately from runner settlement', async () => { + const jobs = jobOperations() const launch = launchWindowsJob(spec(['fake-target', '7']), { spawn, runnerInvocation: invocation, + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(jobs.create).toHaveBeenCalledExactlyOnceWith('Local\\dsh-test-job') + expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) }) - it('signals the Job runner and waits for its managed range to stop', async () => { + it('signals and waits through the parent-owned Job', async () => { + const child = new EventEmitter() as ChildProcess + Object.assign(child, { pid: 321 }) + let eventsPath = '' + let empty = false + const terminate = vi.fn(() => { + empty = true + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) + child.emit('close', 0, null) + }) + const jobs = jobOperations({ + empty: vi.fn(() => empty), + terminate, + }) + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) + return child + }) as unknown as typeof spawn const launch = launchWindowsJob(spec(['fake-target']), { - spawn, - runnerInvocation: invocation, + spawn: run, + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') + expect(terminate).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) }) - it.each([ - { exitCode: 127, signal: null, status: 'exit code 127' }, - { exitCode: null, signal: 'SIGTERM' as NodeJS.Signals, status: 'signal SIGTERM' }, - { exitCode: null, signal: null, status: 'without an exit status' }, - ])('rejects range settlement when the runner exits with $status', async ({ exitCode, signal, status }) => { + it('does not treat runner exit as proof that the Job is empty', async () => { const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - Object.assign(child, { pid: 432, connected: false, kill }) + Object.assign(child, { pid: 432 }) let eventsPath = '' + let empty = false + const jobs = jobOperations({ empty: vi.fn(() => empty) }) const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) return child }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', + }) const directFailure = launch.direct.catch((error: unknown) => error) - child.emit('close', exitCode, signal) + child.emit('close', 127, null) - await expect(launch.owner.waitForExit()).rejects.toThrow( - `Windows Job runner exited with ${status} before proving its managed range empty`, - ) + await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) await expect(directFailure).resolves.toBeInstanceOf(Error) + empty = true + await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') - expect(kill).not.toHaveBeenCalled() + expect(jobs.terminate).not.toHaveBeenCalled() }) - it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { - for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { - if (mode === 'throw') throw new Error('send threw') - callback(mode === 'callback-error' ? new Error('send failed') : null) - return true - }) - Object.assign(child, { - pid: 321, - connected: mode !== 'disconnected', - kill, - send, - }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - - launch.owner.signal('SIGTERM') - if (mode === 'callback-error' || mode === 'throw' || mode === 'disconnected') { - expect(kill).toHaveBeenCalledOnce() - } - if (mode === 'disconnected') expect(send).not.toHaveBeenCalled() - - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - const sends = send.mock.calls.length - const kills = kill.mock.calls.length - launch.owner.signal('SIGKILL') - expect(send).toHaveBeenCalledTimes(sends) - expect(kill).toHaveBeenCalledTimes(kills) - } - + it('reports Job termination failures through waitForExit', async () => { const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { - callback(null) - return true - }) - Object.assign(child, { pid: 654, connected: true, kill, send }) + Object.assign(child, { pid: 654 }) let eventsPath = '' const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 654 }) return child }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + const failure = new Error('TerminateJobObject failed') + const jobs = jobOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) }) + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', + }) + void launch.direct.catch(() => {}) launch.owner.signal('SIGTERM') - expect(send).toHaveBeenCalledOnce() - expect(kill).not.toHaveBeenCalled() - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await launch.direct - await launch.owner.waitForExit() + await expect(launch.owner.waitForExit()).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).rejects.toBe(failure) + expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) }) - it('uses production runner defaults and rejects an empty invocation', async () => { - expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] })) - .toThrow('Windows runner invocation is empty') - + it('keeps a Job observation failure visible on repeated waits', async () => { const child = new EventEmitter() as ChildProcess - Object.assign(child, { - pid: 987, - connected: true, - kill: vi.fn(() => true), - send: vi.fn(), - }) + Object.assign(child, { pid: 655 }) let eventsPath = '' const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 655 }) + return child + }) as unknown as typeof spawn + const failure = new Error('QueryInformationJobObject failed') + const jobs = jobOperations({ empty: vi.fn(() => { throw failure }) }) + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', + }) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).rejects.toBe(failure) + expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + }) + + it('closes the parent Job when spawning the runner throws synchronously', () => { + const failure = new Error('runner spawn failed') + const jobs = jobOperations() + expect(() => launchWindowsJob(spec(['fake-target']), { + spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn, + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + jobName: () => 'Local\\dsh-test-job', + })).toThrow(failure) + expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + }) + + it('passes a generated Job name to the runner and rejects an empty invocation', async () => { + const emptyJobs = jobOperations() + expect(() => launchWindowsJob(spec(['fake-target']), { + runnerInvocation: [], + jobs: emptyJobs.operations, + })) + .toThrow('Windows runner invocation is empty') + expect(emptyJobs.create).not.toHaveBeenCalled() + + const child = new EventEmitter() as ChildProcess + Object.assign(child, { pid: 987 }) + let eventsPath = '' + let jobName = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + jobName = args[args.indexOf('--job') + 1] as string eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 987 }) return child - }) - const runSync = vi.fn(() => ({ status: 0, error: undefined })) - vi.resetModules() - vi.doMock('node:child_process', async importOriginal => ({ - ...await importOriginal(), + }) as unknown as typeof spawn + const jobs = jobOperations() + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, - spawnSync: runSync, - })) - try { - const defaults = await import('../src/windows-job.ts') - expect(defaults.probeWindowsJob()).toBe(true) - const launch = defaults.launchWindowsJob(spec(['fake-target'])) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(run).toHaveBeenCalledOnce() - expect(runSync).toHaveBeenCalledOnce() - } finally { - vi.doUnmock('node:child_process') - vi.resetModules() - } + runnerInvocation: ['fake-runner'], + jobs: jobs.operations, + }) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(jobName).toMatch(/^Local\\dsh-subprocess-/u) }) }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index a8b66f662e..86b9d7458b 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8 -README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c +README.md: 83edff9e4c7cc7dab0c05d539578893ddc409a4a +README.zh.md: 18c36f6bf425f86218bd8398ea770bb17a765eb7 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 5d14ead5a8..83edff9e4c 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait lets the runner publish direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling and parent-owned Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index faa2dc829d..18c36f6bf4 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一直存活到 `ActiveProcesses` 归零。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 让 runner 单独发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling 与 parent-owned Job 的 accounting、termination、closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index d3b2eafcb4..a2c2c2b8bb 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -28,6 +28,8 @@ export const ERROR_BROKEN_PIPE = 109 export const ERROR_NO_DATA = 232 /** Job limit that terminates every member when the final Job handle closes. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +/** Access right required to assign a process to an existing Job. */ +export const JOB_OBJECT_ASSIGN_PROCESS = 0x0001 /** QueryInformationJobObject class for basic accounting and active-process count. */ export const JobObjectBasicAccountingInformation = 1 /** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b1adeff700..8c968a91e3 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -53,7 +53,7 @@ export interface ProcessInfoOutput { dwThreadId: number } -/** Generic Win32 calls consumed by restricted-token sandbox process operations. */ +/** Generic Win32 calls consumed by sandbox and ordinary process operations. */ export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number getLastError(): number @@ -104,7 +104,8 @@ export interface Win32ProcessBindings { ): number waitForSingleObject(handle: NativePtr, milliseconds: number): number getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - createJobObjectW(attributes: null, name: null): NativePtr + createJobObjectW(attributes: null, name: string | null): NativePtr + openJobObjectW(desiredAccess: number, inheritHandle: number, name: string): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number queryInformationJobObject( job: NativePtr, @@ -121,7 +122,7 @@ export interface Win32ProcessBindings { } /** Koffi STARTUPINFOW layout. */ -export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { +export const STARTUPINFOW = koffi.struct({ cb: 'uint32', lpReserved: 'str16', lpDesktop: 'str16', @@ -143,7 +144,7 @@ export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { }) /** Koffi PROCESS_INFORMATION layout. */ -export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', { +export const PROCESS_INFORMATION = koffi.struct({ hProcess: PVOID, hThread: PVOID, dwProcessId: 'uint32', @@ -272,6 +273,7 @@ function bindings(): Win32ProcessBindings { waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + openJobObjectW: bind(kernel32, 'OpenJobObjectW', PVOID, ['uint32', 'int', 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), queryInformationJobObject: bind(kernel32, 'QueryInformationJobObject', 'int', [ PVOID, 'int', PVOID, 'uint32', PVOID, diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index b27a72690c..49acec7d67 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -1,4 +1,4 @@ -/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ +/** Low-level Win32 process, stdio, and Job Object primitives shared by sandbox and ordinary subprocess paths. */ export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' export * from './errors.ts' @@ -19,17 +19,20 @@ export type { } from './ffi.ts' export { closeHandleChecked, + createKillOnCloseJob, drainPipe, isJobEmpty, + openJobForAssignment, pollProcessExit, spawnInheritedJobProcess, - spawnOrdinaryJobProcess, + spawnOrdinaryProcessInJob, spawnPipedProcess, terminateJob, waitForProcessExit, } from './process.ts' export type { OrdinaryProcessSpawnOptions, + SpawnedAssignedProcess, SpawnedJobProcess, SpawnedPipedProcess, } from './process.ts' diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 76260b5f4f..bda6bec42e 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -91,6 +91,14 @@ export interface SpawnedJobProcess { job: NativePtr } +/** Suspended child already assigned to a caller-owned Job. */ +export interface SpawnedAssignedProcess { + /** Direct child process id. */ + pid: number + /** Process handle closed by waitForProcessExit. */ + process: NativePtr +} + interface PipePair { read: NativePtr write: NativePtr @@ -299,8 +307,14 @@ export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr } } -function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { - const job = api.createJobObjectW(null, null) +/** + * Create a caller-owned Job whose final handle closure terminates all members. + * @param api - active binding table. + * @param name - optional name used when another process must open the same Job. + * @returns caller-owned Job handle. + */ +export function createKillOnCloseJob(api: Win32ProcessBindings, name: string | null = null): NativePtr { + const job = api.createJobObjectW(null, name) if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) information.writeUInt32LE( @@ -320,19 +334,30 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { return job } +/** + * Open one named Job for assigning a process from another process. + * @param api - active binding table. + * @param name - name supplied by the Job's owner. + * @returns caller-owned Job handle with assignment access. + */ +export function openJobForAssignment(api: Win32ProcessBindings, name: string): NativePtr { + const job = api.openJobObjectW(abi.JOB_OBJECT_ASSIGN_PROCESS, 0, name) + if (isNullPtr(job)) throwLastError(api, 'OpenJobObjectW', name) + return job +} + /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, + job: NativePtr, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, -): SpawnedJobProcess { - const job = createKillOnCloseJob(api) +): SpawnedAssignedProcess { const getStdHandle = (selector: number, label: string): NativePtr => { const handle = api.getStdHandle(selector) if (!isNullPtr(handle)) return handle const win32Code = api.getLastError() - api.closeHandle(job) throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) } const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') @@ -367,7 +392,6 @@ function spawnJobProcess( if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) - api.closeHandle(job) throw error } finally { freeNative(startupInfo) @@ -378,7 +402,6 @@ function spawnJobProcess( } if (created === 0) { freeNative(processInfo) - api.closeHandle(job) throwWin32( api, createName, @@ -394,7 +417,6 @@ function spawnJobProcess( } if (info.hProcess === null || info.hThread === null) { if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) - api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) throw new Error(`${createName} succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) @@ -404,18 +426,17 @@ function spawnJobProcess( api.terminateProcess(info.hProcess, 1) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) - api.closeHandle(job) throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) } if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { const win32Code = api.getLastError() + api.terminateProcess(info.hProcess, 1) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) - api.closeHandle(job) throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) } closeBestEffort(api, info.hThread) - return { pid: info.dwProcessId, process: info.hProcess, job } + return { pid: info.dwProcessId, process: info.hProcess } } /** @@ -432,30 +453,41 @@ export function spawnInheritedJobProcess( api: Win32ProcessBindings, options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { + const job = createKillOnCloseJob(api) const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => - createRestrictedProcess( - api, - options, - commandLine, - abi.CREATE_SUSPENDED, - startupInfo, - processInfo, - )) + try { + return { + ...spawnJobProcess(api, options, job, 'CreateProcessAsUserW', (startupInfo, processInfo) => + createRestrictedProcess( + api, + options, + commandLine, + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + )), + job, + } + } catch (error) { + api.closeHandle(job) + throw error + } } /** - * Spawn an ordinary process suspended, assign its Job, then resume it. + * Spawn an ordinary process suspended, assign a caller-owned Job, then resume it. * @param api - active binding table. * @param options - command, cwd, and argv. - * @returns caller-owned process and Job handles after successful resume. + * @param job - caller-owned Job handle that remains open after this call. + * @returns caller-owned process handle after successful resume. */ -export function spawnOrdinaryJobProcess( +export function spawnOrdinaryProcessInJob( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, -): SpawnedJobProcess { + job: NativePtr, +): SpawnedAssignedProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, job, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 3c8f182e27..137bd3d7eb 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,14 +2,17 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, + createKillOnCloseJob, isJobEmpty, + openJobForAssignment, pollProcessExit, - spawnOrdinaryJobProcess, + spawnOrdinaryProcessInJob, terminateJob, Win32Error, } from '../src/index.ts' import { CREATE_SUSPENDED, + JOB_OBJECT_ASSIGN_PROCESS, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, @@ -21,6 +24,7 @@ import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), + openJobObjectW: vi.fn(() => 55n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -56,6 +60,7 @@ function api(overrides: Partial = {}): Win32ProcessBinding describe('ordinary Job process operations', () => { it('creates suspended, assigns the Job, and resumes before returning', () => { const events: string[] = [] + const createJobObjectW = vi.fn(() => 50n as NativePtr) const createProcessW = vi.fn(( _app: unknown, _line: unknown, @@ -73,16 +78,19 @@ describe('ordinary Job process operations', () => { return 1 }) const bindings = api({ + createJobObjectW, createProcessW, assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }), resumeThread: vi.fn(() => { events.push('resume'); return 0 }), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), }) - expect(spawnOrdinaryJobProcess(bindings, { + const job = createKillOnCloseJob(bindings, 'Local\\test-job') + expect(spawnOrdinaryProcessInJob(bindings, { command: 'probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', - })).toEqual({ pid: 1234, process: 60n, job: 50n }) + }, job)).toEqual({ pid: 1234, process: 60n }) + expect(createJobObjectW).toHaveBeenCalledWith(null, 'Local\\test-job') expect(createProcessW).toHaveBeenCalledWith( null, 'probe.exe "literal $VALUE" "a b"', @@ -104,13 +112,32 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) + spawnOrdinaryProcessInJob(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }, 50n as NativePtr) } catch (error) { caught = error } expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) + it('terminates an assigned suspended process when resume fails', () => { + const terminateProcess = vi.fn(() => 1) + const closeHandle = vi.fn(() => 1) + const bindings = api({ + resumeThread: vi.fn(() => 0xFFFFFFFF), + terminateProcess, + closeHandle, + }) + expect(() => spawnOrdinaryProcessInJob(bindings, { + command: 'probe.exe', + args: [], + cwd: 'C:\\work', + }, 50n as NativePtr)).toThrow(Win32Error) + expect(terminateProcess).toHaveBeenCalledWith(60n, 1) + expect(closeHandle).toHaveBeenCalledWith(61n) + expect(closeHandle).toHaveBeenCalledWith(60n) + expect(closeHandle).not.toHaveBeenCalledWith(50n) + }) + it('polls direct exit and Job emptiness without blocking', () => { const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -159,4 +186,14 @@ describe('ordinary Job process operations', () => { const closeFailure = api({ closeHandle: vi.fn(() => 0) }) expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error) }) + + it('opens a named Job for process assignment', () => { + const openJobObjectW = vi.fn(() => 55n as NativePtr) + const bindings = api({ openJobObjectW }) + expect(openJobForAssignment(bindings, 'Local\\test-job')).toBe(55n) + expect(openJobObjectW).toHaveBeenCalledWith(JOB_OBJECT_ASSIGN_PROCESS, 0, 'Local\\test-job') + + const missing = api({ openJobObjectW: vi.fn(() => 0n as NativePtr) }) + expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error) + }) }) From b03e52fab08ec2c84c2d0dc2b46fe69730a0c0d4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 10:20:24 +0800 Subject: [PATCH 036/110] refactor(subprocess): simplify Windows runner settlement --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 ++-- ...8-19-shared-win32-process-primitives.zh.md | 8 ++-- .../subprocess-local/src/spawn-runner.ts | 38 ++++++------------- .../subprocess-local/src/windows-job.ts | 3 +- .../tests/windows-job.spec.ts | 10 ++--- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 2 - .../subprocess/win32-process/src/index.ts | 1 - .../subprocess/win32-process/src/process.ts | 19 ---------- .../tests/ordinary-process.spec.ts | 23 ++--------- .../win32-process/verify/abi-probe.cpp | 2 - 14 files changed, 36 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index fe7dec2097..2807df6833 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 67ee4527cdbb63724e371707bfa2ef19f4a86838 -2026-08-19-shared-win32-process-primitives.zh.md: 4c7a683c38c03dbc64a3fd77f33d71daf7b7a209 +2026-08-19-shared-win32-process-primitives.md: eef98440b4e1c6762f5c7f5fafa63ea77d795d39 +2026-08-19-shared-win32-process-primitives.zh.md: 795248083297eb14d45e7fa625c38954d072140c diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 67ee4527cd..eef98440b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, polling, termination, and handle operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, termination, and handle operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately, while the subprocess parent owns Job accounting, termination, and closure. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner waits for the direct process separately, while the subprocess parent owns Job accounting, termination, and closure. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking direct-exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. -**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, polling, and Job controls were added only with their runner consumer. +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait, and Job controls were added only with their runner consumer. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 4c7a683c38..7952480832 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、polling、termination 与 handle 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、termination 与 handle 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,而 subprocess parent 拥有 Job accounting、termination 与 closure。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独等待 direct process,而 subprocess parent 拥有 Job accounting、termination 与 closure。 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking direct-exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 -**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、polling 与 Job control 只随实际 runner consumer 一起加入。 +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait 与 Job control 只随实际 runner consumer 一起加入。 ## Consequences diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index b4816d52f2..15e304e7ac 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -5,8 +5,8 @@ import { closeHandleChecked, loadWin32ProcessBindings, openJobForAssignment, - pollProcessExit, spawnOrdinaryProcessInJob, + waitForProcessExit, Win32Error, } from '@deepseek-ai/dsh-win32-process' import type { NativePtr } from '@deepseek-ai/dsh-win32-process' @@ -102,7 +102,7 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise { +function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): void { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined @@ -118,26 +118,10 @@ async function runWin32(request: RunnerRequest, eventsPath: string, jobName: str closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') jobHandle = undefined appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) - - await new Promise((resolve, reject) => { - const timer = setInterval(() => { - try { - if (processHandle !== undefined) { - const exitCode = pollProcessExit(api, processHandle) - if (exitCode !== undefined) { - appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) - closeHandleChecked(api, processHandle, 'ordinary direct process') - processHandle = undefined - clearInterval(timer) - resolve() - } - } - } catch (error) { - clearInterval(timer) - reject(error instanceof Error ? error : new Error(String(error))) - } - }, 10) - }) + const directProcess = processHandle + processHandle = undefined + const exitCode = waitForProcessExit(api, directProcess) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) } catch (error) { appendRunnerEvent(eventsPath, { type: targetStarted ? 'runner-error' : 'spawn-error', @@ -154,7 +138,7 @@ async function runWin32(request: RunnerRequest, eventsPath: string, jobName: str } } -async function main(): Promise { +function main(): void { const args = parseArgs(process.argv.slice(2)) if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { @@ -163,10 +147,12 @@ async function main(): Promise { } const request = consumeRunnerRequest(args.requestPath) if (args.mode === 'node') runNode(request, args.eventsPath) - else await runWin32(request, args.eventsPath, args.jobName) + else runWin32(request, args.eventsPath, args.jobName) } -main().catch((error: unknown) => { +try { + main() +} catch (error: unknown) { try { const args = parseArgs(process.argv.slice(2)) if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { @@ -176,4 +162,4 @@ main().catch((error: unknown) => { // No trustworthy transport remains; the parent reports the missing result. } process.exitCode = 127 -}) +} diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 1acccb00d2..f070eeefc9 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -50,7 +50,6 @@ export interface WindowsJobInternals { spawnSync?: typeof spawnSync runnerInvocation?: string[] jobs?: WindowsJobOperations - jobName?: () => string } /** @@ -137,7 +136,7 @@ export function launchWindowsJob( if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') /* v8 ignore next -- the native Windows suite exercises the real Job operations. */ const jobs = internals.jobs ?? nativeJobOperations() - const jobName = (internals.jobName ?? (() => `Local\\dsh-subprocess-${randomUUID()}`))() + const jobName = `Local\\dsh-subprocess-${randomUUID()}` const files = runnerFiles(spec) let job: NativePtr try { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index d2f199aa82..b734f43ffa 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -67,12 +67,12 @@ describe('Windows Job runner adapter', () => { spawn, runnerInvocation: invocation, jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(jobs.create).toHaveBeenCalledExactlyOnceWith('Local\\dsh-test-job') + expect(jobs.create).toHaveBeenCalledOnce() + expect(jobs.create.mock.calls[0]?.[0]).toMatch(/^Local\\dsh-subprocess-/u) expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) }) @@ -99,7 +99,6 @@ describe('Windows Job runner adapter', () => { spawn: run, runnerInvocation: ['fake-runner'], jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) @@ -124,7 +123,6 @@ describe('Windows Job runner adapter', () => { spawn: run, runnerInvocation: ['fake-runner'], jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', }) const directFailure = launch.direct.catch((error: unknown) => error) @@ -153,7 +151,6 @@ describe('Windows Job runner adapter', () => { spawn: run, runnerInvocation: ['fake-runner'], jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', }) void launch.direct.catch(() => {}) launch.owner.signal('SIGTERM') @@ -177,7 +174,6 @@ describe('Windows Job runner adapter', () => { spawn: run, runnerInvocation: ['fake-runner'], jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', }) appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) child.emit('close', 0, null) @@ -194,7 +190,6 @@ describe('Windows Job runner adapter', () => { spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn, runnerInvocation: ['fake-runner'], jobs: jobs.operations, - jobName: () => 'Local\\dsh-test-job', })).toThrow(failure) expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) }) @@ -229,5 +224,6 @@ describe('Windows Job runner adapter', () => { await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) expect(jobName).toMatch(/^Local\\dsh-subprocess-/u) + expect(jobs.create).toHaveBeenCalledExactlyOnceWith(jobName) }) }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 86b9d7458b..5bd3df6a70 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 83edff9e4c7cc7dab0c05d539578893ddc409a4a -README.zh.md: 18c36f6bf425f86218bd8398ea770bb17a765eb7 +README.md: 71130d4289716e55d645743c5c9e9f6fa4cb40ca +README.zh.md: 3de82596dffdfa264ebd31ad87517b2e8e330052 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 83edff9e4c..71130d4289 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait lets the runner publish direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. -- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling and parent-owned Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A blocking process wait inside the isolated runner publishes direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. +- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox or ordinary-runner process handle; parent-owned Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 18c36f6bf4..3de82596df 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 让 runner 单独发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling 与 parent-owned Job 的 accounting、termination、closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。隔离 runner 内的 blocking process wait 会发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox 或 ordinary runner 的 process handle;parent-owned Job 的 accounting、termination 与 closure 仍是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index a2c2c2b8bb..ca77cd2e3a 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,8 +6,6 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** WaitForSingleObject returned because a zero-time probe is not signalled. */ -export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 49acec7d67..678cd7f515 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -23,7 +23,6 @@ export { drainPipe, isJobEmpty, openJobForAssignment, - pollProcessExit, spawnInheritedJobProcess, spawnOrdinaryProcessInJob, spawnPipedProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index bda6bec42e..64353ab0f1 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -502,25 +502,6 @@ export function spawnOrdinaryProcessInJob( )) } -/** - * Poll one process handle without blocking the runner event loop. - * @param api - active binding table. - * @param process - caller-owned process handle. - * @returns the direct exit code when signalled, or undefined while running. - */ -export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr): number | undefined { - const waitResult = api.waitForSingleObject(process, 0) - if (waitResult === abi.WAIT_TIMEOUT) return undefined - if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') - const exitCodeSlot = allocUint32() - try { - if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') - return decodeUint32(exitCodeSlot) - } finally { - koffi.free(exitCodeSlot) - } -} - /** * Return whether a Job has no active processes. * @param api - active binding table. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 137bd3d7eb..0d0fd82d1e 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -5,7 +5,6 @@ import { createKillOnCloseJob, isJobEmpty, openJobForAssignment, - pollProcessExit, spawnOrdinaryProcessInJob, terminateJob, Win32Error, @@ -16,7 +15,6 @@ import { JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, - WAIT_TIMEOUT, } from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -138,16 +136,12 @@ describe('ordinary Job process operations', () => { expect(closeHandle).not.toHaveBeenCalledWith(50n) }) - it('polls direct exit and Job emptiness without blocking', () => { + it('reads Job emptiness without blocking', () => { const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) return 1 }) - const running = api({ - waitForSingleObject: vi.fn(() => WAIT_TIMEOUT), - queryInformationJobObject, - }) - expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined() + const running = api({ queryInformationJobObject }) expect(isJobEmpty(running, 50n as NativePtr)).toBe(false) expect(queryInformationJobObject).toHaveBeenCalledWith( 50n, @@ -156,19 +150,10 @@ describe('ordinary Job process operations', () => { JOBOBJECT_BASIC_ACCOUNTING_SIZE, null, ) - - const exited = api() - expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) - expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) + expect(isJobEmpty(api(), 50n as NativePtr)).toBe(true) }) - it('reports wait and exit-code query failures', () => { - const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) - expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error) - - const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) }) - expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error) - + it('reports a Job accounting query failure', () => { const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) }) expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 3cbb883ccf..89476bd04c 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,7 +21,6 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); - P(WAIT_TIMEOUT); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); P(STD_ERROR_HANDLE); @@ -43,7 +42,6 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); - static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class"); From 5b215932f821592daee048e20788b01dc3dd8f5c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 11:18:43 +0800 Subject: [PATCH 037/110] fix(subprocess): hand off Windows process observation --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 6 +- ...8-19-shared-win32-process-primitives.zh.md | 6 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 4 +- ...-08-20-subprocess-native-containment.zh.md | 4 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 - packages/shell/bash-local/README.i18n.yaml | 4 +- packages/shell/bash-local/README.md | 2 +- packages/shell/bash-local/README.zh.md | 2 +- packages/shell/bash-local/src/index.ts | 37 +-- .../shell/bash-local/tests/executor.spec.ts | 72 ++++++ packages/shell/bash-sandbox/src/index.ts | 5 +- .../shell/bash-sandbox/tests/sandbox.spec.ts | 4 +- packages/shell/pwsh-local/README.i18n.yaml | 4 +- packages/shell/pwsh-local/README.md | 2 +- packages/shell/pwsh-local/README.zh.md | 2 +- packages/shell/pwsh-local/src/index.ts | 35 ++- .../shell/pwsh-local/tests/executor.spec.ts | 54 ++++ packages/shell/pwsh-sandbox/src/index.ts | 5 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- .../subprocess-local/src/runner-launch.ts | 7 +- .../subprocess-local/src/spawn-runner.ts | 25 +- .../subprocess-local/src/windows-job.ts | 146 +++++++++-- .../tests/fixtures/fake-job-runner.ts | 8 +- .../tests/native-windows.spec.ts | 27 ++ .../tests/spawn-runner.spec.ts | 1 + .../tests/windows-job.spec.ts | 232 +++++++++++++----- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 6 + packages/subprocess/win32-process/src/ffi.ts | 2 + .../subprocess/win32-process/src/index.ts | 2 + .../subprocess/win32-process/src/process.ts | 31 +++ .../tests/ordinary-process.spec.ts | 36 ++- .../win32-process/verify/abi-probe.cpp | 6 + 39 files changed, 647 insertions(+), 164 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 2807df6833..5393e1c3ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: eef98440b4e1c6762f5c7f5fafa63ea77d795d39 -2026-08-19-shared-win32-process-primitives.zh.md: 795248083297eb14d45e7fa625c38954d072140c +2026-08-19-shared-win32-process-primitives.md: 086bc83c2c146cc836d32c1b4b73991fa00f6fc0 +2026-08-19-shared-win32-process-primitives.zh.md: 62a519e4e327ad238eeb4fd861ea14646ef28b85 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index eef98440b4..086bc83c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,13 +14,13 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner waits for the direct process separately, while the subprocess parent owns Job accounting, termination, and closure. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner retains its process handle only until the parent opens a separate wait handle, while the subprocess parent owns direct-result polling and Job accounting, termination, and closure. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking direct-exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time direct-exit reads, parent-side process opening, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. -**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait, and Job controls were added only with their runner consumer. +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait/poll, and Job controls were added only with their runner and parent consumers. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 7952480832..62a519e4e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,13 +14,13 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独等待 direct process,而 subprocess parent 拥有 Job accounting、termination 与 closure。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 只把自己的 process handle 保留到 parent 打开独立 wait handle,而 subprocess parent 拥有 direct-result polling 以及 Job accounting、termination 与 closure。 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking direct-exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time direct-exit 读取、parent-side process opening、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 -**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait 与 Job control 只随实际 runner consumer 一起加入。 +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait/poll 与 Job control 只随实际 runner 和 parent consumer 一起加入。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 125c8ff688..9c36ca91d3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 4ff86b626f8d0fbb7c09ce82f5115774199e397a -2026-08-20-subprocess-native-containment.zh.md: 4f8390974f2d3a4e34704dce574d45079c9a27be +2026-08-20-subprocess-native-containment.md: 89b6648b6edf2b2e5e84a2900417ecd5229289e3 +2026-08-20-subprocess-native-containment.zh.md: af1b3cb960e03813dcaba67c027a785f1aab2943 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 4ff86b626f..89b6648b6e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct- The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume, closes its copy, then exits after publishing the direct result; the parent owner independently terminates the Job and polls `ActiveProcesses`. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. +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 parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume; after the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes the target exit, terminates the Job, and polls `ActiveProcesses` without retaining a runner-owned copy of the target's stdio. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. 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. @@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each runner remains only until the direct target result while the OS owner persists for descendants. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, while the Linux runner remains until the direct target result and the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 4f8390974f..af1b3cb960 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -14,7 +14,7 @@ Status: implemented common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job,随后关闭自身副本,并在发布 direct result 后退出;parent owner 独立终止 Job 并轮询 `ActiveProcesses`。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 +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 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job;parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 观察 target exit、终止 Job 并轮询 `ActiveProcesses`,不再保留 runner 拥有的 target stdio 副本。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 983f5953f8..0e50bf4392 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -28,7 +28,6 @@ const PPVOID: Ptr = koffi.pointer(PVOID) /** ACL/token calls composed with the generic Win32 process binding table. */ export interface Win32Bindings extends Win32ProcessBindings { - openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number localAlloc(flags: number, bytes: number): NativePtr localFree(memory: NativePtr): NativePtr @@ -222,7 +221,6 @@ let cached: Win32Bindings | undefined function bindings(): Win32Bindings { if (cached !== undefined) return cached cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({ - openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index e7e0526ec3..70141eb599 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.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 packages/shell/bash-local/README.md -README.md: 7bdaa7442e7d4166ecae6328d2456ddc22a01cbc -README.zh.md: bf33c9d1108be92ffadcd60363d4d773f7591071 +README.md: 051b8aba0c66d2970f42ada9d3437265c29cd839 +README.zh.md: ff5a0178f8dedaf2f4bea5eb603e51bd2ffa74bc diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 7bdaa7442e..051b8aba0c 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -42,6 +42,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. - **POSIX-only** — the `bash` binary is hardcoded, so this executor is not composed on Windows. -- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index bf33c9d110..ff5a0178f8 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -42,6 +42,6 @@ - **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 - **仅支持 POSIX**:`bash` 二进制已硬编码,因此本执行器不会在 Windows 上组装。 -- **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 +- **后台失败提示只交付一次**:`done` 拒绝且没有真实 stderr 时,执行器会把一条诊断注入恰好一个 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index dbfd90ed22..171ab0e4aa 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -66,6 +66,15 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } +/** Whether a rejection carries direct evidence that argv[0] never started. */ +function isSpawnFailure(error: unknown, program: string): boolean { + if (typeof error !== 'object' || error === null) return false + const { path, syscall } = error as { path?: unknown; syscall?: unknown } + if (typeof syscall !== 'string') return false + if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false + return path === undefined || path === program +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -257,12 +266,12 @@ export class LocalBashExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) - // A spawn failure produces no process output, so the subprocess service has nothing - // to buffer; the note is delivered exactly once through the read path. - let spawnFailureNote: string | undefined - const consumeSpawnFailure = (): string => { - const note = spawnFailureNote ?? '' - spawnFailureNote = undefined + // A rejected subprocess result has no settled outcome. Its diagnostic is + // delivered exactly once through the read path. + let failureNote: string | undefined + const consumeFailure = (): string => { + const note = failureNote ?? '' + failureNote = undefined return note } @@ -281,10 +290,10 @@ export class LocalBashExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - // Background spawn failures settle as killed and surface through the read path. + const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) proc.status = 'killed' - spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote, true, error) + failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` + this.onProcessDone(proc, failureNote, spawnFailed, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -292,9 +301,9 @@ export class LocalBashExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A failed spawn never produced process output, so the note and real - // stderr text are mutually exclusive. - const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // A rejected subprocess may have no process output; its synthetic note + // is used only when no real stderr is available. + const errText = err.text.length > 0 ? err.text : consumeFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -319,13 +328,13 @@ export class LocalBashExecutor extends ShellExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. - * Called after exit facts or spawn-failure output are stamped and before + * Called after exit facts or rejection output are stamped and before * {@link ShellProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the subprocess promise rejected before a process started. - * @param _spawnError - the original spawn rejection reason, which may itself be undefined. + * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c081ad7078..11299b0ed9 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -4,9 +4,11 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import SubprocessRuntime from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -20,6 +22,43 @@ async function setup(config: ConstructorParameters[1] return { ctx, bash } } +class RejectingSubprocessRuntime extends SubprocessRuntime { + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + + constructor(ctx: Context, private readonly failure: unknown, private readonly processId = 123) { + super(ctx) + } + + override async resolveExecutable(command: string): Promise { return command } + override spawnTerminal(): Promise { throw new Error('bash spawns pipes, never terminals') } + override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { + return { + pid: this.processId, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve().then(() => { throw this.failure }), + terminate: () => {}, + waitForExit: async () => true, + } + } +} + +class ObservingBashExecutor extends LocalBashExecutor { + spawnFailed: boolean | undefined + + protected override onProcessDone( + _proc: ShellProcess, + _stderr: string, + spawnFailed: boolean, + ): void { + this.spawnFailed = spawnFailed + } +} + /** * Poll a handle's consuming readOutput until the ACCUMULATED delta contains * `expected`; returns the accumulation (reads never re-deliver, so the caller @@ -296,6 +335,39 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.status).toBe('killed') expect(proc.readOutput().delta).toContain('spawn failed:') }) + + it('does not label a post-start provider rejection as a spawn failure', async () => { + const ctx = new Context() + const failure = Object.assign(new Error('managed owner became unreadable'), { + code: 'ENOENT', + syscall: 'spawn bash', + path: 'bash', + }) + new RejectingSubprocessRuntime(ctx, failure) + await ctx.plugin(ObservingBashExecutor) + const bash = ctx.shell as ObservingBashExecutor + const proc = bash.start(bash.resolve({ command: 'true' })) + await proc.done + expect(proc.readOutput().delta).toContain('subprocess failed:') + expect(proc.readOutput().delta).toBe('') + expect(bash.spawnFailed).toBe(false) + }) + + it.each([ + ['non-object rejection', undefined, 'subprocess failed:', false], + ['non-string syscall', { syscall: 1 }, 'subprocess failed:', false], + ['non-spawn syscall', { syscall: 'kill', path: 'bash' }, 'subprocess failed:', false], + ['matching syscall without path', { syscall: 'spawn bash' }, 'spawn failed:', true], + ])('classifies a pre-start %s from structured evidence', async (_label, failure, note, spawnFailed) => { + const ctx = new Context() + new RejectingSubprocessRuntime(ctx, failure, -1) + await ctx.plugin(ObservingBashExecutor) + const bash = ctx.shell as ObservingBashExecutor + const proc = bash.start(bash.resolve({ command: 'true' })) + await proc.done + expect(proc.readOutput().delta).toContain(note) + expect(bash.spawnFailed).toBe(spawnFailed) + }) }) describe('process lifecycle ownership (the subprocess service, not the executor)', () => { diff --git a/packages/shell/bash-sandbox/src/index.ts b/packages/shell/bash-sandbox/src/index.ts index be9c6647ec..2f6b92f772 100644 --- a/packages/shell/bash-sandbox/src/index.ts +++ b/packages/shell/bash-sandbox/src/index.ts @@ -151,8 +151,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A rejected spawn never started the confined launch. Otherwise runner - // failure outranks denial because its diagnostics may contain denial terms. + // A definite spawn rejection never started the confined launch. A + // settled runner failure outranks denial because its diagnostics may + // contain denial terms; unclassified provider rejection proves neither. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 750fb870c3..b17277bc77 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -558,7 +558,7 @@ describe('background sandbox facts', () => { } }) - it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { + it('does not invent spawn or runner evidence for an unstructured subprocess rejection', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -579,7 +579,7 @@ describe('background sandbox facts', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done - expect(task.readOutput().delta).toContain('spawn failed: undefined') + expect(task.readOutput().delta).toContain('subprocess failed: undefined') expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index cc1fee06f8..beff18aafd 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.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 packages/shell/pwsh-local/README.md -README.md: 788495ebc5bb4d498d53eada8fb5401bb3639b43 -README.zh.md: 65297a0606f62c769b8ae219066e1ecce32d1b82 +README.md: 44ea2f3d9face0499f0b7088a257a391a7370225 +README.zh.md: 81e46ad52f0c00817f31f0ba51379f2f1cbbc840 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 788495ebc5..44ea2f3d9f 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -49,7 +49,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. -- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. - **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index 65297a0606..81e46ad52f 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -49,7 +49,7 @@ - **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要隔离的部署应组合启用沙箱的 bash 执行器或策略。 - **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。 - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 -- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 +- **后台失败提示只投递一次**——`done` 拒绝且没有真实 stderr 时,执行器只把一条诊断注入一次 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`。 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 0760912c63..8867245e33 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -94,6 +94,15 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } +/** Whether a rejection carries direct evidence that argv[0] never started. */ +function isSpawnFailure(error: unknown, program: string): boolean { + if (typeof error !== 'object' || error === null) return false + const { path, syscall } = error as { path?: unknown; syscall?: unknown } + if (typeof syscall !== 'string') return false + if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false + return path === undefined || path === program +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`pwsh-local: ${name} must be a positive finite number`) @@ -286,12 +295,12 @@ export class PwshLocalExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) - // A spawn failure produces no process output, so the subprocess service has nothing - // to buffer; the note is delivered exactly once through the read path. - let spawnFailureNote: string | undefined - const consumeSpawnFailure = (): string => { - const note = spawnFailureNote ?? '' - spawnFailureNote = undefined + // A rejected subprocess result has no settled outcome. Its diagnostic is + // delivered exactly once through the read path. + let failureNote: string | undefined + const consumeFailure = (): string => { + const note = failureNote ?? '' + failureNote = undefined return note } @@ -310,10 +319,10 @@ export class PwshLocalExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - // Background spawn failures settle as killed and surface through the read path. + const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) proc.status = 'killed' - spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote, true, error) + failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` + this.onProcessDone(proc, failureNote, spawnFailed, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -321,9 +330,9 @@ export class PwshLocalExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A failed spawn never produced process output, so the note and real - // stderr text are mutually exclusive. - const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + // A rejected subprocess may have no process output; its synthetic note + // is used only when no real stderr is available. + const errText = err.text.length > 0 ? err.text : consumeFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -354,7 +363,7 @@ export class PwshLocalExecutor extends ShellExecutor { * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the spawn rejected before any process existed. - * @param _spawnError - the spawn rejection, when `_spawnFailed`. + * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index dab4c4a245..2938ac63f9 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -175,6 +175,43 @@ describe('spawn construction (pure, every platform)', () => { } } + class RejectingSubprocessRuntime extends SubprocessRuntime { + private readonly reader: SubprocessOutputReader = { + readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), + } + + constructor(ctx: Context, private readonly failure: unknown) { + super(ctx) + } + + override async resolveExecutable(command: string): Promise { return command } + override spawnTerminal(): Promise { throw new Error('pwsh spawns pipes, never terminals') } + override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { + return { + pid: 123, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: this.reader, stderr: this.reader }, + done: Promise.resolve().then(() => { throw this.failure }), + terminate: () => {}, + waitForExit: async () => true, + } + } + } + + class ObservingPwshExecutor extends PwshLocalExecutor { + spawnFailed: boolean | undefined + + protected override onProcessDone( + _proc: ShellProcess, + _stderr: string, + spawnFailed: boolean, + ): void { + this.spawnFailed = spawnFailed + } + } + it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { const ctx = new Context() const subprocess = new CapturingSubprocessRuntime(ctx) @@ -187,6 +224,23 @@ describe('spawn construction (pure, every platform)', () => { expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') }) + + it('does not label a post-start provider rejection as a spawn failure', async () => { + const ctx = new Context() + const failure = Object.assign(new Error('managed owner became unreadable'), { + code: 'ENOENT', + syscall: 'spawn pwsh', + path: 'pwsh', + }) + new RejectingSubprocessRuntime(ctx, failure) + await ctx.plugin(ObservingPwshExecutor, { pwshPath: 'pwsh' }) + const pwsh = ctx.shell as ObservingPwshExecutor + const proc = pwsh.start(pwsh.resolve({ command: 'Write-Output ok' })) + await proc.done + expect(proc.readOutput().delta).toContain('subprocess failed:') + expect(proc.readOutput().delta).toBe('') + expect(pwsh.spawnFailed).toBe(false) + }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { diff --git a/packages/shell/pwsh-sandbox/src/index.ts b/packages/shell/pwsh-sandbox/src/index.ts index 66bc73bcf0..ec3e8fb98d 100644 --- a/packages/shell/pwsh-sandbox/src/index.ts +++ b/packages/shell/pwsh-sandbox/src/index.ts @@ -157,8 +157,9 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A rejected spawn never started the confined launch. Otherwise runner - // failure outranks denial because its diagnostics may contain denial terms. + // A definite spawn rejection never started the confined launch. A + // settled runner failure outranks denial because its diagnostics may + // contain denial terms; unclassified provider rejection proves neither. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 96b14e63c1..c384918da2 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: a2766a3a90f14c69727d2708d92927e03334ed1f -README.zh.md: d26b01ebebd0fe3ccd37b4771f1d224b96973bfd +README.md: d62b946f274a4d3337ad7d5ced01a8d8b51924b2 +README.zh.md: 78405529cf054456496c68739bc2037ef2712111 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index a2766a3a90..d62b946f27 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, resumes it, then closes its own Job handle and exits after publishing the direct result. Raw pipe EOF therefore follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each runner remains only until the direct target result; the OS-owned scope or parent-held Job persists for later descendants. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. The Windows runner is released as soon as the parent acquires direct-process observation; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux runner events and Windows direct-process state are polled asynchronously every 100 ms and 10 ms respectively, while Linux scope state is polled every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index d26b01ebeb..78405529cf 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复,随后关闭自己的 Job handle,并在发布 direct result 后退出。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每个 runner 只保留到 direct target result,后续 descendant 则继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。Windows runner 会在 parent 取得 direct-process observation 后立即释放;Linux runner 保留到 direct target result,而后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux runner event 与 Windows direct-process state 分别每 100 ms 和 10 ms 异步轮询,Linux scope state 每 200 ms 轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index c29621ea07..7aa3b96955 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,14 +41,17 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. + * @param ipc - append a private control channel for the Windows launcher. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { - return [ +export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { + const stdio: StdioOptions = [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] + if (ipc) stdio.push('ipc') + return stdio } /** diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 15e304e7ac..a65e875e1f 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -6,7 +6,6 @@ import { loadWin32ProcessBindings, openJobForAssignment, spawnOrdinaryProcessInJob, - waitForProcessExit, Win32Error, } from '@deepseek-ai/dsh-win32-process' import type { NativePtr } from '@deepseek-ai/dsh-win32-process' @@ -102,13 +101,15 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): void { +async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false try { + if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel') + const released = new Promise((resolve) => { process.once('disconnect', resolve) }) process.chdir(request.cwd) jobHandle = openJobForAssignment(api, jobName) const [command, ...args] = request.argv @@ -118,10 +119,10 @@ function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') jobHandle = undefined appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) + await released const directProcess = processHandle processHandle = undefined - const exitCode = waitForProcessExit(api, directProcess) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) + closeHandleChecked(api, directProcess, 'ordinary direct process handoff') } catch (error) { appendRunnerEvent(eventsPath, { type: targetStarted ? 'runner-error' : 'spawn-error', @@ -138,7 +139,7 @@ function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): } } -function main(): void { +async function main(): Promise { const args = parseArgs(process.argv.slice(2)) if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { @@ -147,12 +148,16 @@ function main(): void { } const request = consumeRunnerRequest(args.requestPath) if (args.mode === 'node') runNode(request, args.eventsPath) - else runWin32(request, args.eventsPath, args.jobName) + else { + try { + await runWin32(request, args.eventsPath, args.jobName) + } finally { + if (process.connected) process.disconnect() + } + } } -try { - main() -} catch (error: unknown) { +main().catch((error: unknown) => { try { const args = parseArgs(process.argv.slice(2)) if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { @@ -162,4 +167,4 @@ try { // No trustworthy transport remains; the parent reports the missing result. } process.exitCode = 127 -} +}) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index f070eeefc9..8a87f83223 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -2,18 +2,21 @@ import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' +import type { Readable } from 'node:stream' import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { closeHandleChecked, createKillOnCloseJob, isJobEmpty, loadWin32ProcessBindings, + openProcessForWait, + pollProcessExit, terminateJob, } from '@deepseek-ai/dsh-win32-process' import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { cleanupAfterRunner, @@ -25,31 +28,107 @@ import { import { cleanupRunnerFiles } from './runner-protocol.ts' const JOB_POLL_INTERVAL_MS = 10 +const PROCESS_POLL_INTERVAL_MS = 10 -/** Parent-side operations for one Windows Job handle. */ -export interface WindowsJobOperations { +/** Parent-side operations for one Windows managed launch. */ +export interface WindowsProcessOperations { create(name: string): NativePtr + openProcess(pid: number): NativePtr + pollProcess(process: NativePtr): number | undefined empty(job: NativePtr): boolean terminate(job: NativePtr): void - close(job: NativePtr): void + closeJob(job: NativePtr): void + closeProcess(process: NativePtr): void } -function nativeJobOperations(): WindowsJobOperations { +function nativeProcessOperations(): WindowsProcessOperations { const api = loadWin32ProcessBindings() return { create: name => createKillOnCloseJob(api, name), + openProcess: pid => openProcessForWait(api, pid), + pollProcess: process => pollProcessExit(api, process), empty: job => isJobEmpty(api, job), terminate: (job) => { terminateJob(api, job, 1) }, - close: (job) => { closeHandleChecked(api, job, 'ordinary process Job') }, + closeJob: (job) => { closeHandleChecked(api, job, 'ordinary process Job') }, + closeProcess: (process) => { closeHandleChecked(api, process, 'ordinary direct process') }, } } +function releaseRunner(child: ReturnType): Error | undefined { + if (!child.connected) return undefined + try { + child.disconnect() + return undefined + } catch (error) { + try { child.kill() } catch { /* The direct process and Job remain parent-owned. */ } + return error instanceof Error ? error : new Error(String(error)) + } +} + +function observeRunnerExit(child: ReturnType): Promise { + return new Promise((resolve) => { + child.once('error', () => { resolve() }) + child.once('exit', () => { resolve() }) + }) +} + +function observeCollectedStream( + mode: SubprocessSpawnSpec['stdio']['stdout'], + stream: Readable | null | undefined, +): Promise { + if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined + || stream.readableEnded || stream.destroyed) { + return Promise.resolve() + } + return new Promise((resolve) => { + const settle = (): void => { + stream.off('end', settle) + stream.off('close', settle) + stream.off('error', settle) + resolve() + } + stream.once('end', settle) + stream.once('close', settle) + stream.once('error', settle) + }) +} + /** Test seams for the runner process. */ export interface WindowsJobInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync runnerInvocation?: string[] - jobs?: WindowsJobOperations + operations?: WindowsProcessOperations +} + +function observeDirectProcess( + pid: number, + operations: WindowsProcessOperations, +): Promise { + const processHandle = operations.openProcess(pid) + let closed = false + const close = (): void => { + if (closed) return + operations.closeProcess(processHandle) + closed = true + } + return new Promise((resolve, reject) => { + const poll = (): void => { + try { + const exitCode = operations.pollProcess(processHandle) + if (exitCode === undefined) { + setTimeout(poll, PROCESS_POLL_INTERVAL_MS) + return + } + close() + resolve({ exitCode, signal: null }) + } catch (error) { + try { close() } catch { /* Preserve the observation failure. */ } + reject(error instanceof Error ? error : new Error(String(error))) + } + } + poll() + }) } /** @@ -78,8 +157,8 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly job: NativePtr, - private readonly operations: WindowsJobOperations, - private readonly runnerClosed: Promise, + private readonly operations: WindowsProcessOperations, + private readonly runnerExited: Promise, ) {} signal(_signal: NodeJS.Signals): void { @@ -103,7 +182,7 @@ class WindowsJobOwner implements BoundProcessOwner { } this.stopped = true this.close() - await this.runnerClosed + await this.runnerExited } catch (error) { this.stopped = true try { this.close() } catch { /* Preserve the observation failure. */ } @@ -115,7 +194,7 @@ class WindowsJobOwner implements BoundProcessOwner { private close(): void { if (this.closed) return - this.operations.close(this.job) + this.operations.closeJob(this.job) this.closed = true } } @@ -135,12 +214,12 @@ export function launchWindowsJob( const [command, ...prefix] = invocation if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') /* v8 ignore next -- the native Windows suite exercises the real Job operations. */ - const jobs = internals.jobs ?? nativeJobOperations() + const operations = internals.operations ?? nativeProcessOperations() const jobName = `Local\\dsh-subprocess-${randomUUID()}` const files = runnerFiles(spec) let job: NativePtr try { - job = jobs.create(jobName) + job = operations.create(jobName) } catch (error) { cleanupRunnerFiles(files) throw error @@ -159,16 +238,41 @@ export function launchWindowsJob( files.eventsPath, ], { env: childEnv(), - stdio: runnerStdio(spec), + stdio: runnerStdio(spec, true), }) } catch (error) { - try { jobs.close(job) } catch { /* Preserve the launch failure. */ } + try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ } cleanupRunnerFiles(files) throw error } - const closed = observeChildClose(child) - const owner = new WindowsJobOwner(job, jobs, closed) - const result = runnerDirectResult(child, files, closed) - cleanupAfterRunner(files, result.direct, closed) - return { child, pid: result.pid, direct: result.direct, closed, owner } + const runnerExited = observeRunnerExit(child) + const closed = Promise.all([ + runnerExited, + observeCollectedStream(spec.stdio.stdout, child.stdout), + observeCollectedStream(spec.stdio.stderr, child.stderr), + ]).then(() => undefined) + const owner = new WindowsJobOwner(job, operations, runnerExited) + const transport = runnerDirectResult(child, files, runnerExited) + if (transport.pid <= 0) { + cleanupAfterRunner(files, transport.direct, runnerExited) + return { child, pid: transport.pid, direct: transport.direct, closed, owner } + } + // The launcher retains its original process handle until this process opens + // an independent one, preventing PID reuse during the ownership handoff. + // Its event reader then becomes intentionally irrelevant: Windows direct + // settlement is owned by the handle below, not by the released runner. + void transport.direct.catch(() => {}) + let direct: Promise + try { + direct = observeDirectProcess(transport.pid, operations) + } catch (error) { + direct = Promise.resolve().then(() => { throw error }) + } + const releaseFailure = releaseRunner(child) + if (releaseFailure !== undefined) { + void direct.catch(() => {}) + direct = Promise.resolve().then(() => { throw releaseFailure }) + } + cleanupAfterRunner(files, direct, runnerExited) + return { child, pid: transport.pid, direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index 2a159b9bff..c1ee1c07dc 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -9,10 +9,12 @@ appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) const configuredExit = Number(request.argv[1]) // Events carry target results; zero means the runner completed its own work. if (Number.isSafeInteger(configuredExit)) { - setTimeout(() => { + const finish = (): void => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exitCode = 0 - }, 10) + process.exit(0) + } + if (process.connected) process.once('disconnect', finish) + else setTimeout(finish, 10) } else { setInterval(() => {}, 1_000) } diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index a9d6fdece0..a21672cdb5 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -70,6 +70,33 @@ function directSpawnFailure(argv: string[]): Promise { const windowsNative = process.platform === 'win32' && probeWindowsJob() describe.skipIf(!windowsNative)('Windows Job native containment', () => { + it('releases raw stdout when the target closes it before exiting', async () => { + const request = { + ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']), + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, + } + const handle = bindManagedProcess(request, launchWindowsJob(request)) + if (handle.stdout === undefined) throw new Error('expected piped stdout') + const stdoutEnded = new Promise((resolve, reject) => { + handle.stdout?.once('end', resolve) + handle.stdout?.once('error', reject) + }) + handle.stdout.resume() + let directSettled = false + void handle.done.then( + () => { directSettled = true }, + () => { directSettled = true }, + ) + await expect(Promise.race([ + stdoutEnded.then(() => true), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + ])).resolves.toBe(true) + expect(directSettled).toBe(false) + handle.terminate() + await handle.done + await expect(handle.waitForExit()).resolves.toBe(true) + }) + it('terminates the direct target and its default-inheritance descendant', async () => { const pidFile = join(scratch, `job-child-${Date.now()}.pid`) const script = ` diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 419baaed07..cf37414a3e 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -87,6 +87,7 @@ describe('spawn runner transport', () => { it('maps every target stdio disposition', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) + expect(runnerStdio(spec(), true)).toEqual(['ignore', 'pipe', 'pipe', 'ipc']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, }))).toEqual(['pipe', 'inherit', 'inherit']) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index b734f43ffa..a25ad6580b 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -1,13 +1,14 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent } from '../src/runner-protocol.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' -import type { WindowsJobOperations } from '../src/windows-job.ts' +import type { WindowsProcessOperations } from '../src/windows-job.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) const invocation = [process.execPath, '--import', 'tsx/esm', fixture] @@ -21,23 +22,45 @@ function spec(argv: string[]): SubprocessSpawnSpec { } } -function jobOperations(overrides: Partial = {}): { - operations: WindowsJobOperations +function fakeRunner(pid: number): { child: ChildProcess; disconnect: ReturnType } { + const child = new EventEmitter() as ChildProcess + const disconnect = vi.fn(() => { + Object.assign(child, { connected: false }) + queueMicrotask(() => { + child.emit('exit', 0, null) + child.emit('close', 0, null) + }) + }) + Object.assign(child, { pid, connected: true, disconnect, kill: vi.fn(() => true) }) + return { child, disconnect } +} + +function processOperations(overrides: Partial = {}): { + operations: WindowsProcessOperations create: ReturnType + openProcess: ReturnType + pollProcess: ReturnType empty: ReturnType terminate: ReturnType - close: ReturnType + closeJob: ReturnType + closeProcess: ReturnType } { const create = vi.fn(() => 50n as NativePtr) + const openProcess = vi.fn(() => 60n as NativePtr) + const pollProcess = vi.fn(() => 0) const empty = vi.fn(() => true) const terminate = vi.fn() - const close = vi.fn() + const closeJob = vi.fn() + const closeProcess = vi.fn() return { - operations: { create, empty, terminate, close, ...overrides }, + operations: { create, openProcess, pollProcess, empty, terminate, closeJob, closeProcess, ...overrides }, create, + openProcess, + pollProcess, empty, terminate, - close, + closeJob, + closeProcess, } } @@ -62,32 +85,37 @@ describe('Windows Job runner adapter', () => { }) it('reports direct outcome separately from runner settlement', async () => { - const jobs = jobOperations() - const launch = launchWindowsJob(spec(['fake-target', '7']), { + const jobs = processOperations({ pollProcess: vi.fn(() => 7) }) + const request = { + ...spec(['fake-target', '7']), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' } as const, + } + const launch = launchWindowsJob(request, { spawn, runnerInvocation: invocation, - jobs: jobs.operations, + operations: jobs.operations, }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) expect(jobs.create).toHaveBeenCalledOnce() expect(jobs.create.mock.calls[0]?.[0]).toMatch(/^Local\\dsh-subprocess-/u) - expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.openProcess).toHaveBeenCalledWith(launch.pid) + expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) + expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) it('signals and waits through the parent-owned Job', async () => { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 321 }) + const { child, disconnect } = fakeRunner(321) let eventsPath = '' - let empty = false + const state = { empty: false, exitCode: undefined as number | undefined } const terminate = vi.fn(() => { - empty = true - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) - child.emit('close', 0, null) + state.empty = true + state.exitCode = 1 }) - const jobs = jobOperations({ - empty: vi.fn(() => empty), + const jobs = processOperations({ + pollProcess: vi.fn(() => state.exitCode), + empty: vi.fn(() => state.empty), terminate, }) const run = vi.fn((_command: string, args: readonly string[]) => { @@ -98,22 +126,25 @@ describe('Windows Job runner adapter', () => { const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') + expect(disconnect).toHaveBeenCalledOnce() expect(terminate).toHaveBeenCalledExactlyOnceWith(50n) - expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) it('does not treat runner exit as proof that the Job is empty', async () => { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 432 }) + const { child, disconnect } = fakeRunner(432) let eventsPath = '' - let empty = false - const jobs = jobOperations({ empty: vi.fn(() => empty) }) + const state = { empty: false, exitCode: undefined as number | undefined } + const jobs = processOperations({ + pollProcess: vi.fn(() => state.exitCode), + empty: vi.fn(() => state.empty), + }) const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) @@ -122,23 +153,20 @@ describe('Windows Job runner adapter', () => { const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, }) - const directFailure = launch.direct.catch((error: unknown) => error) - - child.emit('close', 127, null) - await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) - await expect(directFailure).resolves.toBeInstanceOf(Error) - empty = true + state.exitCode = 0 + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + state.empty = true await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') + expect(disconnect).toHaveBeenCalledOnce() expect(jobs.terminate).not.toHaveBeenCalled() }) it('reports Job termination failures through waitForExit', async () => { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 654 }) + const { child } = fakeRunner(654) let eventsPath = '' const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string @@ -146,22 +174,21 @@ describe('Windows Job runner adapter', () => { return child }) as unknown as typeof spawn const failure = new Error('TerminateJobObject failed') - const jobs = jobOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) }) + const jobs = processOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) }) const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, }) void launch.direct.catch(() => {}) launch.owner.signal('SIGTERM') await expect(launch.owner.waitForExit()).rejects.toBe(failure) await expect(launch.owner.waitForExit()).rejects.toBe(failure) - expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) it('keeps a Job observation failure visible on repeated waits', async () => { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 655 }) + const { child } = fakeRunner(655) let eventsPath = '' const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string @@ -169,42 +196,138 @@ describe('Windows Job runner adapter', () => { return child }) as unknown as typeof spawn const failure = new Error('QueryInformationJobObject failed') - const jobs = jobOperations({ empty: vi.fn(() => { throw failure }) }) + const jobs = processOperations({ empty: vi.fn(() => { throw failure }) }) const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, }) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(launch.owner.waitForExit()).rejects.toBe(failure) await expect(launch.owner.waitForExit()).rejects.toBe(failure) - expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) + }) + + it('reports direct-process observation failure and closes its handle', async () => { + const { child } = fakeRunner(656) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 656 }) + return child + }) as unknown as typeof spawn + const failure = new Error('WaitForSingleObject failed') + const jobs = processOperations({ pollProcess: vi.fn(() => { throw failure }) }) + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + operations: jobs.operations, + }) + await expect(launch.direct).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) + }) + + it('releases the runner when the parent cannot open the direct process', async () => { + const { child, disconnect } = fakeRunner(659) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 659 }) + return child + }) as unknown as typeof spawn + const failure = new Error('OpenProcess failed') + const jobs = processOperations({ openProcess: vi.fn(() => { throw failure }) }) + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + operations: jobs.operations, + }) + await expect(launch.direct).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(disconnect).toHaveBeenCalledOnce() + expect(jobs.closeProcess).not.toHaveBeenCalled() + }) + + it('reports a failed runner release after acquiring direct-process observation', async () => { + const child = new EventEmitter() as ChildProcess + const failure = new Error('IPC disconnect failed') + const kill = vi.fn(() => { + queueMicrotask(() => { child.emit('exit', 0, null) }) + return true + }) + Object.assign(child, { + pid: 657, + connected: true, + disconnect: vi.fn(() => { throw failure }), + kill, + }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 657 }) + return child + }) as unknown as typeof spawn + const jobs = processOperations() + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + operations: jobs.operations, + }) + await expect(launch.direct).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(kill).toHaveBeenCalledOnce() + }) + + it('keeps collected settlement pending until the runner and collected streams close', async () => { + const { child } = fakeRunner(658) + const stdout = new PassThrough() + const stderr = new PassThrough() + Object.assign(child, { stdout, stderr }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 658 }) + return child + }) as unknown as typeof spawn + const jobs = processOperations() + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: run, + runnerInvocation: ['fake-runner'], + operations: jobs.operations, + }) + let closed = false + void launch.closed.then(() => { closed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(closed).toBe(false) + stdout.resume() + stderr.resume() + stdout.end() + stderr.end() + await expect(launch.closed).resolves.toBeUndefined() }) it('closes the parent Job when spawning the runner throws synchronously', () => { const failure = new Error('runner spawn failed') - const jobs = jobOperations() + const jobs = processOperations() expect(() => launchWindowsJob(spec(['fake-target']), { spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, })).toThrow(failure) - expect(jobs.close).toHaveBeenCalledExactlyOnceWith(50n) + expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) it('passes a generated Job name to the runner and rejects an empty invocation', async () => { - const emptyJobs = jobOperations() + const emptyJobs = processOperations() expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [], - jobs: emptyJobs.operations, + operations: emptyJobs.operations, })) .toThrow('Windows runner invocation is empty') expect(emptyJobs.create).not.toHaveBeenCalled() - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 987 }) + const { child, disconnect } = fakeRunner(987) let eventsPath = '' let jobName = '' const run = vi.fn((_command: string, args: readonly string[]) => { @@ -213,16 +336,15 @@ describe('Windows Job runner adapter', () => { appendRunnerEvent(eventsPath, { type: 'started', pid: 987 }) return child }) as unknown as typeof spawn - const jobs = jobOperations() + const jobs = processOperations() const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'], - jobs: jobs.operations, + operations: jobs.operations, }) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(disconnect).toHaveBeenCalledOnce() expect(jobName).toMatch(/^Local\\dsh-subprocess-/u) expect(jobs.create).toHaveBeenCalledExactlyOnceWith(jobName) }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 5bd3df6a70..973d02ee96 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 71130d4289716e55d645743c5c9e9f6fa4cb40ca -README.zh.md: 3de82596dffdfa264ebd31ad87517b2e8e330052 +README.md: 61c9272a24e9007e2944992db6db1211942cdc99 +README.zh.md: 64ade89692a5a6edd73fe8a9cfdf214f8cb67405 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 71130d4289..61c9272a24 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A blocking process wait inside the isolated runner publishes direct exit, while the parent polls `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. -- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox or ordinary-runner process handle; parent-owned Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting independently continues until `ActiveProcesses` reaches zero. +- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 3de82596df..64ade89692 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。隔离 runner 内的 blocking process wait 会发布 direct exit,parent 则轮询 `QueryInformationJobObject(JobObjectBasicAccountingInformation)` 直到 `ActiveProcesses` 归零。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox 或 ordinary runner 的 process handle;parent-owned Job 的 accounting、termination 与 closure 仍是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 则独立持续到 `ActiveProcesses` 归零。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index ca77cd2e3a..82b4e75dae 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,8 +6,14 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF +/** WaitForSingleObject returned because a zero-time probe is not signalled. */ +export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** OpenProcess right required to read limited process information. */ +export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +/** Standard right required to wait on a process handle. */ +export const SYNCHRONIZE = 0x00100000 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 8c968a91e3..edd4702010 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -57,6 +57,7 @@ export interface ProcessInfoOutput { export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number getLastError(): number + openProcess(desiredAccess: number, inheritHandle: number, processId: number): NativePtr formatMessageW( flags: number, source: null, @@ -253,6 +254,7 @@ function bindings(): Win32ProcessBindings { cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [ 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 678cd7f515..f60cd27401 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -23,6 +23,8 @@ export { drainPipe, isJobEmpty, openJobForAssignment, + openProcessForWait, + pollProcessExit, spawnInheritedJobProcess, spawnOrdinaryProcessInJob, spawnPipedProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 64353ab0f1..d4c842acb3 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -346,6 +346,18 @@ export function openJobForAssignment(api: Win32ProcessBindings, name: string): N return job } +/** + * Open a process for non-blocking exit observation. + * @param api - active binding table. + * @param pid - direct process id published by the launcher. + * @returns caller-owned process handle with query and synchronize access. + */ +export function openProcessForWait(api: Win32ProcessBindings, pid: number): NativePtr { + const process = api.openProcess(abi.PROCESS_QUERY_LIMITED_INFORMATION | abi.SYNCHRONIZE, 0, pid) + if (isNullPtr(process)) throwLastError(api, 'OpenProcess', `pid ${String(pid)}`) + return process +} + /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, @@ -502,6 +514,25 @@ export function spawnOrdinaryProcessInJob( )) } +/** + * Poll one process handle without blocking the caller event loop. + * @param api - active binding table. + * @param process - caller-owned process handle. + * @returns the direct exit code when signalled, or undefined while running. + */ +export function pollProcessExit(api: Win32ProcessBindings, process: NativePtr): number | undefined { + const waitResult = api.waitForSingleObject(process, 0) + if (waitResult === abi.WAIT_TIMEOUT) return undefined + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + try { + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + return decodeUint32(exitCodeSlot) + } finally { + koffi.free(exitCodeSlot) + } +} + /** * Return whether a Job has no active processes. * @param api - active binding table. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 0d0fd82d1e..82caebacb1 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -5,6 +5,8 @@ import { createKillOnCloseJob, isJobEmpty, openJobForAssignment, + openProcessForWait, + pollProcessExit, spawnOrdinaryProcessInJob, terminateJob, Win32Error, @@ -15,6 +17,9 @@ import { JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, + PROCESS_QUERY_LIMITED_INFORMATION, + SYNCHRONIZE, + WAIT_TIMEOUT, } from '../src/abi.ts' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' @@ -23,6 +28,7 @@ function api(overrides: Partial = {}): Win32ProcessBinding return { createJobObjectW: vi.fn(() => 50n), openJobObjectW: vi.fn(() => 55n), + openProcess: vi.fn(() => 60n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -136,12 +142,16 @@ describe('ordinary Job process operations', () => { expect(closeHandle).not.toHaveBeenCalledWith(50n) }) - it('reads Job emptiness without blocking', () => { + it('polls a parent-owned process handle and reads Job emptiness without blocking', () => { const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) return 1 }) - const running = api({ queryInformationJobObject }) + const running = api({ + waitForSingleObject: vi.fn(() => WAIT_TIMEOUT), + queryInformationJobObject, + }) + expect(pollProcessExit(running, 60n as NativePtr)).toBeUndefined() expect(isJobEmpty(running, 50n as NativePtr)).toBe(false) expect(queryInformationJobObject).toHaveBeenCalledWith( 50n, @@ -150,10 +160,18 @@ describe('ordinary Job process operations', () => { JOBOBJECT_BASIC_ACCOUNTING_SIZE, null, ) - expect(isJobEmpty(api(), 50n as NativePtr)).toBe(true) + const exited = api() + expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) + expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) }) - it('reports a Job accounting query failure', () => { + it('reports process wait, exit-code query, and Job accounting failures', () => { + const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) + expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error) + + const exitCode = api({ getExitCodeProcess: vi.fn(() => 0) }) + expect(() => pollProcessExit(exitCode, 60n as NativePtr)).toThrow(Win32Error) + const jobQuery = api({ queryInformationJobObject: vi.fn(() => 0) }) expect(() => isJobEmpty(jobQuery, 50n as NativePtr)).toThrow(Win32Error) }) @@ -181,4 +199,14 @@ describe('ordinary Job process operations', () => { const missing = api({ openJobObjectW: vi.fn(() => 0n as NativePtr) }) expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error) }) + + it('opens a direct process for parent-side exit observation', () => { + const openProcess = vi.fn(() => 60n as NativePtr) + const bindings = api({ openProcess }) + expect(openProcessForWait(bindings, 1234)).toBe(60n) + expect(openProcess).toHaveBeenCalledWith(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, 1234) + + const missing = api({ openProcess: vi.fn(() => 0n as NativePtr) }) + expect(() => openProcessForWait(missing, 1234)).toThrow(Win32Error) + }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 89476bd04c..cf96a067c4 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,6 +21,9 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); + P(WAIT_TIMEOUT); + P(PROCESS_QUERY_LIMITED_INFORMATION); + P(SYNCHRONIZE); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); P(STD_ERROR_HANDLE); @@ -42,6 +45,9 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); + static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right"); + static_assert(SYNCHRONIZE == 0x100000, "synchronize right"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class"); From 60999be6023c695cf9e95e0ad37040e83157068d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 11:19:37 +0800 Subject: [PATCH 038/110] test(shell): remove stale lint suppression --- packages/shell/bash-sandbox/tests/sandbox.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index b17277bc77..0e733e4d81 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -570,7 +570,6 @@ describe('background sandbox facts', () => { stderr: undefined, collected: { stdout: emptyReader, stderr: emptyReader }, // Arbitrary subprocess providers can reject without a value; that edge is the point of this test. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors done: Promise.reject(undefined), terminate: vi.fn(), waitForExit: async () => true, From 32f369c3a01d2961836fdd751526f10b9a5703f3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 21 Aug 2026 11:57:52 +0800 Subject: [PATCH 039/110] test(subprocess): widen Windows stdout-end race budget to 5s The native Windows containment tests expect the stdout stream to end shortly after the direct target exits, but 1s was too tight under the Windows CI runner's cold-start and process-teardown jitter. Use a 5s bounded race so the assertion still proves EOF eventually arrives without flaking on slow runner startup. --- .../subprocess/subprocess-local/tests/native-windows.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index a21672cdb5..5b9a29bd57 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -89,7 +89,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { ) await expect(Promise.race([ stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), ])).resolves.toBe(true) expect(directSettled).toBe(false) handle.terminate() @@ -148,7 +148,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(Promise.race([ stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, From ac69b1e0c43590eb932b32a0c690b3e93d45e2f8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 12:56:34 +0800 Subject: [PATCH 040/110] fix(subprocess): decouple Windows target stdio --- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 6 +- ...-08-20-subprocess-native-containment.zh.md | 6 +- .../shell/bash-sandbox/tests/sandbox.spec.ts | 1 + .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/linux-scope.ts | 10 +- .../subprocess-local/src/managed-owner.ts | 5 +- .../subprocess-local/src/runner-launch.ts | 49 ++++-- .../subprocess-local/src/spawn-runner.ts | 109 +++++++++++--- .../subprocess/subprocess-local/src/spawn.ts | 35 +++-- .../subprocess-local/src/windows-job.ts | 74 ++++++--- .../subprocess-local/src/windows-stdio.ts | 140 ++++++++++++++++++ .../tests/linux-scope.spec.ts | 2 +- .../tests/managed-spawn.spec.ts | 28 +++- .../tests/native-windows.spec.ts | 50 ++++++- .../tests/spawn-runner.spec.ts | 15 +- .../tests/windows-job.spec.ts | 56 ++++--- .../tests/windows-stdio.spec.ts | 112 ++++++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- packages/subprocess/win32-process/src/abi.ts | 10 +- packages/subprocess/win32-process/src/ffi.ts | 12 ++ .../subprocess/win32-process/src/index.ts | 2 + .../subprocess/win32-process/src/process.ts | 42 +++++- .../tests/ordinary-process.spec.ts | 54 ++++++- .../win32-process/verify/abi-probe.cpp | 6 + vitest.config.ts | 7 +- 30 files changed, 716 insertions(+), 135 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/windows-stdio.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 9c36ca91d3..0f4a4eb1f4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 89b6648b6edf2b2e5e84a2900417ecd5229289e3 -2026-08-20-subprocess-native-containment.zh.md: af1b3cb960e03813dcaba67c027a785f1aab2943 +2026-08-20-subprocess-native-containment.md: 708de5d2ce4431222a7f36981d2fb4a3a801c5c0 +2026-08-20-subprocess-native-containment.zh.md: 883cf84f79e5ce00afe3fa9d00097923f6b65afa diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 89b6648b6e..708de5d2ce 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -12,9 +12,9 @@ The local subprocess provider treated a POSIX process group or a Windows direct- `LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows creates and retains a named kill-on-close Job; a local runner backed by `@deepseek-ai/dsh-win32-process` opens that Job, creates the target suspended, assigns it, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. -The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. +The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux runner report target spawn and exit facts and lets the Windows runner report target startup or failure independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The runner opens that Job only for suspended create, assignment, and resume; after the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes the target exit, terminates the Job, and polls `ActiveProcesses` without retaining a runner-owned copy of the target's stdio. Raw pipe EOF therefore follows the target and descendants that actually inherited the stream. Host exit closes the parent's owner handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. +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 parent-owned Job by default. The parent owns private named-pipe endpoints for every non-inherited standard stream. The runner opens only their target-side handles, opens the Job for suspended create, assignment, and resume, and closes its copies before publishing the target pid. After the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes target exit, stream EOF, Job termination, and `ActiveProcesses` through separate owned resources. Host exit closes the parent's Job handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. 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. @@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, while the Linux runner remains until the direct target result and the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, and it closes its target-side named-pipe handles before release; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry, short-lived private files, and per-spawn pipe names but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index af1b3cb960..883cf84f79 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -12,9 +12,9 @@ Status: implemented `LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 -common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 +common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux runner 报告 target spawn 与 exit facts,并让 Windows runner 报告 target startup 或 failure,两者都不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。runner 只为 suspended create、assignment 与 resume 打开该 Job;parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 观察 target exit、终止 Job 并轮询 `ActiveProcesses`,不再保留 runner 拥有的 target stdio 副本。因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant。host exit 会关闭 parent 的 owner handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 +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 默认继承 parent-owned Job。parent 为每条非 inherit 标准流持有 private named-pipe endpoint。runner 只打开其 target 端 handle,并打开 Job 完成 suspended create、assignment 与 resume;它在发布 target pid 前关闭自己的 pipe handle 副本。parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 分别观察 target exit、stream EOF、Job termination 与 `ActiveProcesses`。host exit 会关闭 parent 的 Job handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,且它的 target 端 named-pipe handle 会在释放前关闭;Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry、短期 private files 与 per-spawn pipe name,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 0e733e4d81..b17277bc77 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -570,6 +570,7 @@ describe('background sandbox facts', () => { stderr: undefined, collected: { stdout: emptyReader, stderr: emptyReader }, // Arbitrary subprocess providers can reject without a value; that edge is the point of this test. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors done: Promise.reject(undefined), terminate: vi.fn(), waitForExit: async () => true, diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index c384918da2..3289991e58 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: d62b946f274a4d3337ad7d5ced01a8d8b51924b2 -README.zh.md: 78405529cf054456496c68739bc2037ef2712111 +README.md: 12604fa74cc645340ca79b5734c34c7027b988eb +README.zh.md: e484b98df4b8411a8b78d2710c97067ac5a2bec6 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index d62b946f27..12604fa74c 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than Job observation. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, connects the target side of private parent-owned stdio pipes, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF and stdin lifetime follow the target-side handles rather than the runner process. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 78405529cf..e484b98df4 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observation。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job、连接由 parent 持有的 private stdio pipe 的 target 端,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 与 stdin 生命周期取决于 target 端 handle,而不取决于 runner process。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 411fc60b94..a6ca284a22 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -215,5 +215,13 @@ export function launchLinuxScope( const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child) const result = runnerDirectResult(child, files, closed) cleanupAfterRunner(files, result.direct, closed) - return { child, pid: result.pid, direct: result.direct, closed, owner } + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + pid: result.pid, + direct: result.direct, + closed, + owner, + } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 945701dbe8..b37f66d137 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -1,6 +1,7 @@ /** Minimal managed-range ownership bound to one ordinary subprocess handle. */ import type { ChildProcess } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' /** Platform owner used by termination and whole-range settlement. */ @@ -13,7 +14,9 @@ export interface BoundProcessOwner { /** Platform launch facts consumed by the common stdio and result lifecycle. */ export interface ManagedProcessLaunch { - child: ChildProcess + stdin: Writable | null + stdout: Readable | null + stderr: Readable | null pid: number direct: Promise closed: Promise diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 7aa3b96955..e4f98e3fb6 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,17 +41,14 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. - * @param ipc - append a private control channel for the Windows launcher. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { - const stdio: StdioOptions = [ +export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { + return [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] - if (ipc) stdio.push('ipc') - return stdio } /** @@ -72,6 +69,11 @@ interface RunnerHandshake { events: RunnerEvent[] } +/** Startup-only result for launchers that observe the direct process elsewhere. */ +export type RunnerStartResult = + | { ok: true; pid: number; events: RunnerEvent[] } + | { ok: false; pid: -1; error: Error } + /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ function runnerExited(child: ChildProcess, pid: number): boolean { if (child.exitCode !== null || child.signalCode !== null) return true @@ -102,7 +104,9 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner const events = readRunnerEvents(files.eventsPath) const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') if (terminal?.type === 'started') return { pid: terminal.pid, events } - if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } + if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { + throw deserializeSpawnError(terminal.error) + } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') Atomics.wait(handshakeWait, 0, 0, 5) @@ -110,6 +114,26 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) } +/** + * Read only the native runner's startup result. + * @param child - native wrapper process. + * @param files - private request and result paths. + * @returns target pid after publication, otherwise the launch failure. + */ +export function runnerStart(child: ChildProcess, files: RunnerFiles): RunnerStartResult { + try { + const handshake = waitForRunnerHandshake(child, files) + return { ok: true, pid: handshake.pid, events: handshake.events } + } catch (error) { + cleanupRunnerFiles(files) + return { + ok: false, + pid: -1, + error: error as Error, + } + } +} + async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], @@ -151,16 +175,11 @@ export function runnerDirectResult( pid: number direct: Promise } { - let handshake: RunnerHandshake - try { - handshake = waitForRunnerHandshake(child, files) - } catch (error) { - cleanupRunnerFiles(files) - return { pid: -1, direct: Promise.resolve().then(() => { throw error }) } - } + const start = runnerStart(child, files) + if (!start.ok) return { pid: -1, direct: Promise.resolve().then(() => { throw start.error }) } return { - pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, closed), + pid: start.pid, + direct: waitForDirectResult(files, start.events, closed), } } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index a65e875e1f..54d850081f 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -4,11 +4,12 @@ import { spawn } from 'node:child_process' import { closeHandleChecked, loadWin32ProcessBindings, + openNamedPipeForStdio, openJobForAssignment, spawnOrdinaryProcessInJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent, consumeRunnerRequest, @@ -20,13 +21,24 @@ type RunnerArgs = | { mode: 'probe-node' } | { mode: 'probe-win32' } | { mode: 'node'; requestPath: string; eventsPath: string } - | { mode: 'win32'; requestPath: string; eventsPath: string; jobName: string } + | { + mode: 'win32' + requestPath: string + eventsPath: string + jobName: string + stdinPipe?: string + stdoutPipe?: string + stderrPipe?: string + } function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined let jobName: string | undefined let requestPath: string | undefined let eventsPath: string | undefined + let stdinPipe: string | undefined + let stdoutPipe: string | undefined + let stderrPipe: string | undefined for (let index = 0; index < argv.length; index += 2) { const key = argv[index] const value = argv[index + 1] @@ -35,6 +47,9 @@ function parseArgs(argv: string[]): RunnerArgs { else if (key === '--job') jobName = value else if (key === '--request') requestPath = value else if (key === '--events') eventsPath = value + else if (key === '--stdin-pipe') stdinPipe = value + else if (key === '--stdout-pipe') stdoutPipe = value + else if (key === '--stderr-pipe') stderrPipe = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } if (mode === 'probe-node' || mode === 'probe-win32') return { mode } @@ -42,24 +57,36 @@ function parseArgs(argv: string[]): RunnerArgs { if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') if (mode === 'win32') { if (jobName === undefined || jobName.length === 0) throw new Error('subprocess runner requires a Windows Job name') - return { mode, requestPath, eventsPath, jobName } + return { + mode, + requestPath, + eventsPath, + jobName, + ...stdinPipe === undefined ? {} : { stdinPipe }, + ...stdoutPipe === undefined ? {} : { stdoutPipe }, + ...stderrPipe === undefined ? {} : { stderrPipe }, + } } return { mode, requestPath, eventsPath } } function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { - if (!(error instanceof Win32Error)) return serializeSpawnError(error) - const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 - ? 'ENOENT' - : error.win32Code === 5 - ? 'EPERM' - : error.win32Code === 193 - ? 'EFTYPE' - : 'UNKNOWN' + const serialized = serializeSpawnError(error) + const code = error instanceof Win32Error + ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? 'ENOENT' + : error.win32Code === 5 + ? 'EPERM' + : error.win32Code === 193 + ? 'EFTYPE' + : 'UNKNOWN' + : serialized.code + if (code === undefined) return serialized const program = request.argv[0] as string return { - name: 'Error', - message: `spawn ${program} ${code}: ${error.message}`, + ...serialized, + name: serialized.name, + message: `spawn ${program} ${code}: ${serialized.message}`, code, syscall: `spawn ${program}`, path: program, @@ -101,21 +128,64 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -async function runWin32(request: RunnerRequest, eventsPath: string, jobName: string): Promise { +function closeStdioHandles( + api: ReturnType, + handles: Array<{ handle: NativePtr; label: string }>, + reportFailure: boolean, +): void { + let failure: Error | undefined + for (const { handle, label } of handles.splice(0)) { + try { + closeHandleChecked(api, handle, label) + } catch (error) { + failure ??= error instanceof Error ? error : new Error(String(error)) + } + } + if (reportFailure && failure !== undefined) throw failure +} + +async function runWin32( + request: RunnerRequest, + eventsPath: string, + jobName: string, + pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, +): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false + let targetCreationAttempted = false + const openedStdio: Array<{ handle: NativePtr; label: string }> = [] try { if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel') const released = new Promise((resolve) => { process.once('disconnect', resolve) }) - process.chdir(request.cwd) jobHandle = openJobForAssignment(api, jobName) + const stdio: ChildStdioHandles = {} + for (const [key, path] of [ + ['stdin', pipes.stdinPipe], + ['stdout', pipes.stdoutPipe], + ['stderr', pipes.stderrPipe], + ] as const) { + if (path === undefined) continue + const handle = openNamedPipeForStdio(api, path) + stdio[key] = handle + openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) + } + // Node attributes an invalid cwd to the attempted target spawn rather + // than exposing the launcher's internal chdir operation. + targetCreationAttempted = true + process.chdir(request.cwd) const [command, ...args] = request.argv - const spawned = spawnOrdinaryProcessInJob(api, { command: command as string, args, cwd: process.cwd() }, jobHandle) + const spawned = spawnOrdinaryProcessInJob( + api, + { command: command as string, args, cwd: process.cwd() }, + jobHandle, + stdio, + ) processHandle = spawned.process targetStarted = true + closeStdioHandles(api, openedStdio, true) closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') jobHandle = undefined appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) @@ -126,10 +196,13 @@ async function runWin32(request: RunnerRequest, eventsPath: string, jobName: str } catch (error) { appendRunnerEvent(eventsPath, { type: targetStarted ? 'runner-error' : 'spawn-error', - error: targetStarted ? serializeSpawnError(error) : win32SpawnError(error, request), + error: targetStarted || !targetCreationAttempted + ? serializeSpawnError(error) + : win32SpawnError(error, request), }) process.exitCode = 127 } finally { + closeStdioHandles(api, openedStdio, false) if (processHandle !== undefined) { try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } } @@ -150,7 +223,7 @@ async function main(): Promise { if (args.mode === 'node') runNode(request, args.eventsPath) else { try { - await runWin32(request, args.eventsPath, args.jobName) + await runWin32(request, args.eventsPath, args.jobName, args) } finally { if (process.connected) process.disconnect() } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 1dcab55913..a4071c7a42 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -433,7 +433,7 @@ export function bindManagedProcess( ): LocalSubprocessHandle { validateSubprocessSpec(spec) const { spillDir } = prepareManagedProcessBinding(internals) - const child = launch.child + const { stdin, stdout, stderr } = launch const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => mode !== 'pipe' && mode !== 'inherit' @@ -447,11 +447,11 @@ export function bindManagedProcess( stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) return collector } - const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') - const stderrCollector = collectStream(errMode, child.stderr, 'stderr') + const stdoutCollector = collectStream(outMode, stdout, 'stdout') + const stderrCollector = collectStream(errMode, stderr, 'stderr') const stopCollectors = (): void => { - if (stdoutCollector !== undefined) child.stdout?.destroy() - if (stderrCollector !== undefined) child.stderr?.destroy() + if (stdoutCollector !== undefined) stdout?.destroy() + if (stderrCollector !== undefined) stderr?.destroy() stdoutCollector?.seal() stderrCollector?.seal() } @@ -501,9 +501,9 @@ export function bindManagedProcess( // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. - if (typeof stdinMode === 'object' && child.stdin !== null) { - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(stdinMode.data) + if (typeof stdinMode === 'object' && stdin !== null) { + stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -554,10 +554,11 @@ export function bindManagedProcess( return { pid: launch.pid, - /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ - stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, - stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, - stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + /* v8 ignore start -- pipe-mode streams exist on every conforming launch; + the null-coalesces guard an internal adapter defect only. */ + stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? stderr ?? undefined : undefined, /* v8 ignore stop */ collected: { ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, @@ -602,5 +603,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, direct, ) - return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding) + return bindManagedProcess(spec, { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + pid, + direct, + closed, + owner, + }, binding) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 8a87f83223..88f3497a72 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -19,13 +19,12 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts import { waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { - cleanupAfterRunner, - runnerDirectResult, runnerFiles, - runnerStdio, + runnerStart, spawnRunnerInvocation, } from './runner-launch.ts' import { cleanupRunnerFiles } from './runner-protocol.ts' +import { createWindowsStdioBridge } from './windows-stdio.ts' const JOB_POLL_INTERVAL_MS = 10 const PROCESS_POLL_INTERVAL_MS = 10 @@ -65,6 +64,13 @@ function releaseRunner(child: ReturnType): Error | undefined { } } +function stopFailedRunner(child: ReturnType): void { + if (child.connected) { + try { child.disconnect() } catch { /* A forced stop below remains authoritative. */ } + } + try { child.kill() } catch { /* The parent-owned Job remains responsible for any target. */ } +} + function observeRunnerExit(child: ReturnType): Promise { return new Promise((resolve) => { child.once('error', () => { resolve() }) @@ -203,7 +209,7 @@ class WindowsJobOwner implements BoundProcessOwner { * Launch one direct command through a runner into a parent-owned Job. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. * @param internals - injected process runner used by tests. - * @returns wrapper streams, target outcome, and the bound Job owner. + * @returns parent-owned streams, target outcome, and the bound Job owner. */ export function launchWindowsJob( spec: SubprocessSpawnSpec, @@ -215,7 +221,8 @@ export function launchWindowsJob( if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') /* v8 ignore next -- the native Windows suite exercises the real Job operations. */ const operations = internals.operations ?? nativeProcessOperations() - const jobName = `Local\\dsh-subprocess-${randomUUID()}` + const launchId = randomUUID() + const jobName = `Local\\dsh-subprocess-${launchId}` const files = runnerFiles(spec) let job: NativePtr try { @@ -224,6 +231,14 @@ export function launchWindowsJob( cleanupRunnerFiles(files) throw error } + let stdio: ReturnType + try { + stdio = createWindowsStdioBridge(spec, `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${launchId}`) + } catch (error) { + try { operations.closeJob(job) } catch { /* Preserve the stdio setup failure. */ } + cleanupRunnerFiles(files) + throw error + } let child: ReturnType try { child = run(command, [ @@ -236,35 +251,46 @@ export function launchWindowsJob( files.requestPath, '--events', files.eventsPath, + ...stdio.runnerArgs, ], { env: childEnv(), - stdio: runnerStdio(spec, true), + stdio: stdio.runnerStdio, }) } catch (error) { try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ } + stdio.dispose() cleanupRunnerFiles(files) throw error } const runnerExited = observeRunnerExit(child) const closed = Promise.all([ runnerExited, - observeCollectedStream(spec.stdio.stdout, child.stdout), - observeCollectedStream(spec.stdio.stderr, child.stderr), + observeCollectedStream(spec.stdio.stdout, stdio.stdout), + observeCollectedStream(spec.stdio.stderr, stdio.stderr), ]).then(() => undefined) const owner = new WindowsJobOwner(job, operations, runnerExited) - const transport = runnerDirectResult(child, files, runnerExited) - if (transport.pid <= 0) { - cleanupAfterRunner(files, transport.direct, runnerExited) - return { child, pid: transport.pid, direct: transport.direct, closed, owner } + const start = runnerStart(child, files) + if (!start.ok) { + stopFailedRunner(child) + stdio.dispose() + const direct = Promise.resolve().then(() => { throw start.error }) + return { + stdin: stdio.stdin, + stdout: stdio.stdout, + stderr: stdio.stderr, + pid: start.pid, + direct, + closed, + owner, + } } // The launcher retains its original process handle until this process opens // an independent one, preventing PID reuse during the ownership handoff. - // Its event reader then becomes intentionally irrelevant: Windows direct - // settlement is owned by the handle below, not by the released runner. - void transport.direct.catch(() => {}) + // Windows direct settlement is owned by the handle below, not by continued + // runner event polling after the startup handoff. let direct: Promise try { - direct = observeDirectProcess(transport.pid, operations) + direct = observeDirectProcess(start.pid, operations) } catch (error) { direct = Promise.resolve().then(() => { throw error }) } @@ -273,6 +299,18 @@ export function launchWindowsJob( void direct.catch(() => {}) direct = Promise.resolve().then(() => { throw releaseFailure }) } - cleanupAfterRunner(files, direct, runnerExited) - return { child, pid: transport.pid, direct, closed, owner } + void direct.then( + () => { stdio.closeInput() }, + () => { stdio.closeInput() }, + ) + void runnerExited.then(() => { cleanupRunnerFiles(files) }) + return { + stdin: stdio.stdin, + stdout: stdio.stdout, + stderr: stdio.stderr, + pid: start.pid, + direct, + closed, + owner, + } } diff --git a/packages/subprocess/subprocess-local/src/windows-stdio.ts b/packages/subprocess/subprocess-local/src/windows-stdio.ts new file mode 100644 index 0000000000..912fcb8a8e --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-stdio.ts @@ -0,0 +1,140 @@ +/** Parent-owned named-pipe streams for one Windows native launch. */ + +import { createServer } from 'node:net' +import type { Server, Socket } from 'node:net' +import { PassThrough } from 'node:stream' +import type { Readable, Writable } from 'node:stream' +import type { StdioOptions } from 'node:child_process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' + +interface PipeEndpoint { + readonly path: string + readonly stream: PassThrough + dispose(): void +} + +/** Streams and runner arguments for one Windows launch. */ +export interface WindowsStdioBridge { + readonly stdin: Writable | null + readonly stdout: Readable | null + readonly stderr: Readable | null + readonly runnerArgs: string[] + readonly runnerStdio: StdioOptions + closeInput(): void + dispose(): void +} + +function closeServer(server: Server): void { + try { + server.close() + } catch { + // A listen failure or an already-accepted connection can close first. + } +} + +function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint { + const stream = new PassThrough() + let socket: Socket | undefined + let disposed = false + const server = createServer({ allowHalfOpen: true }) + // Direct-result failure remains authoritative for setup errors. Keep the + // stream error observable without allowing an early server failure to become + // an unhandled process-level exception before bindManagedProcess attaches. + stream.on('error', () => {}) + server.once('error', (error) => { stream.destroy(error) }) + server.once('connection', (connection) => { + if (disposed) { + connection.destroy() + return + } + socket = connection + closeServer(server) + connection.once('error', (error) => { stream.destroy(error) }) + stream.once('close', () => { connection.destroy() }) + if (direction === 'output') { + connection.once('end', () => { connection.end() }) + connection.pipe(stream) + } else { + connection.resume() + stream.pipe(connection) + connection.once('end', () => { + stream.unpipe(connection) + connection.end() + stream.destroy() + }) + connection.once('close', () => { stream.destroy() }) + } + }) + try { + server.listen(path) + } catch (error) { + stream.destroy() + closeServer(server) + throw error + } + return { + path, + stream, + dispose() { + disposed = true + closeServer(server) + socket?.destroy() + stream.destroy() + }, + } +} + +/** + * Create private parent-owned streams whose peer handles are opened by the Windows runner. + * @param spec - target stdio dispositions. + * @param basePath - unique named-pipe base chosen by the launch owner. + * @returns public streams, runner arguments, and cleanup for pre-start failure. + */ +export function createWindowsStdioBridge( + spec: SubprocessSpawnSpec, + basePath: string, +): WindowsStdioBridge { + const endpoints: PipeEndpoint[] = [] + let stdin: PipeEndpoint | undefined + let stdout: PipeEndpoint | undefined + let stderr: PipeEndpoint | undefined + try { + if (spec.stdio.stdin !== 'ignore') { + stdin = createEndpoint(`${basePath}-stdin`, 'input') + endpoints.push(stdin) + } + if (spec.stdio.stdout !== 'inherit') { + stdout = createEndpoint(`${basePath}-stdout`, 'output') + endpoints.push(stdout) + } + if (spec.stdio.stderr !== 'inherit') { + stderr = createEndpoint(`${basePath}-stderr`, 'output') + endpoints.push(stderr) + } + } catch (error) { + for (const endpoint of endpoints) endpoint.dispose() + throw error + } + return { + stdin: stdin?.stream ?? null, + stdout: stdout?.stream ?? null, + stderr: stderr?.stream ?? null, + runnerArgs: [ + ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path], + ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path], + ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path], + ], + runnerStdio: [ + 'ignore', + spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore', + spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore', + 'ipc', + ], + closeInput() { + stdin?.dispose() + }, + dispose() { + for (const endpoint of endpoints) endpoint.dispose() + }, + } +} diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index fb003e6d9e..714b82053d 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -264,7 +264,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }), runnerInvocation: spawnRunnerInvocation(), }) - expect(launch.child.pid).toBeUndefined() + expect(launch.pid).toBe(-1) await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBe(true) await launch.closed diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index ed088f4808..3e01cf8ca8 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -62,7 +62,9 @@ describe('managed process binding', () => { }, } const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: 4242, direct: direct.promise, closed: observeChildClose(wrapper), @@ -86,7 +88,9 @@ describe('managed process binding', () => { const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), closed: observeChildClose(wrapper), @@ -102,7 +106,9 @@ describe('managed process binding', () => { }) const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), @@ -126,7 +132,9 @@ describe('managed process binding', () => { ...spec(1_000), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, }, { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: new Promise(() => {}), @@ -150,7 +158,9 @@ describe('managed process binding', () => { }) const failure = new Error('range observation failed') const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: new Promise(() => {}), closed: new Promise(() => {}), @@ -173,7 +183,9 @@ describe('managed process binding', () => { const direct = Promise.resolve().then(() => { throw rejection }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct, closed: new Promise(() => {}), @@ -202,7 +214,9 @@ describe('managed process binding', () => { }) const controller = new AbortController() const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index a21672cdb5..e2dce0241a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -55,14 +55,16 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } -function directSpawnFailure(argv: string[]): Promise { +type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } + +function directSpawnFailure(argv: string[], cwd = scratch): Promise { return new Promise((resolve, reject) => { try { - const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) + const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' }) child.once('error', resolve) child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) } catch (error) { - resolve(error as NodeJS.ErrnoException) + resolve(error as SpawnFailure) } }) } @@ -70,6 +72,33 @@ function directSpawnFailure(argv: string[]): Promise { const windowsNative = process.platform === 'win32' && probeWindowsJob() describe.skipIf(!windowsNative)('Windows Job native containment', () => { + it('keeps raw stdin writable after the launcher handoff', async () => { + const output = join(scratch, `stdin-${Date.now()}.txt`) + const script = ` + const { writeFileSync } = require('node:fs') + let input = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { input += chunk }) + process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) }) + ` + const request = { + ...spec([process.execPath, '-e', script]), + stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const, + } + const handle = bindManagedProcess(request, launchWindowsJob(request)) + if (handle.stdin === undefined) throw new Error('expected piped stdin') + await new Promise(resolve => setTimeout(resolve, 100)) + await new Promise((resolve, reject) => { + handle.stdin?.end('after-handoff', (error?: Error | null) => { + if (error !== undefined && error !== null) reject(error) + else resolve() + }) + }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).resolves.toBe(true) + expect(readFileSync(output, 'utf8')).toBe('after-handoff') + }) + it('releases raw stdout when the target closes it before exiting', async () => { const request = { ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']), @@ -175,6 +204,20 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const missing = spec([`missing-native-target-${Date.now()}.exe`]) const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing)) await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(missingHandle.waitForExit()).resolves.toBe(true) + + const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) + const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] + const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) + const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } + const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd)) + await expect(invalidCwdHandle.done).rejects.toMatchObject({ + code: expectedCwd.code, + syscall: expectedCwd.syscall, + path: expectedCwd.path, + spawnargs: expectedCwd.spawnargs, + }) + await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true) const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) writeFileSync(invalidExecutable, 'not a Windows executable\r\n') @@ -182,5 +225,6 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) + await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index cf37414a3e..335dbd4cda 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -87,7 +87,6 @@ describe('spawn runner transport', () => { it('maps every target stdio disposition', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) - expect(runnerStdio(spec(), true)).toEqual(['ignore', 'pipe', 'pipe', 'ipc']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, }))).toEqual(['pipe', 'inherit', 'inherit']) @@ -291,6 +290,20 @@ describe('spawn runner transport', () => { cleanupRunnerFiles(runnerFailure) } + const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) + appendRunnerEvent(afterStartFailure.eventsPath, { + type: 'runner-error', + error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, + }) + const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) + expect(result.pid).toBe(456) + await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) + } finally { + cleanupRunnerFiles(afterStartFailure) + } + const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index a25ad6580b..c125393115 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -17,7 +16,7 @@ function spec(argv: string[]): SubprocessSpawnSpec { return { argv, cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 100, } } @@ -279,34 +278,6 @@ describe('Windows Job runner adapter', () => { expect(kill).toHaveBeenCalledOnce() }) - it('keeps collected settlement pending until the runner and collected streams close', async () => { - const { child } = fakeRunner(658) - const stdout = new PassThrough() - const stderr = new PassThrough() - Object.assign(child, { stdout, stderr }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 658 }) - return child - }) as unknown as typeof spawn - const jobs = processOperations() - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - let closed = false - void launch.closed.then(() => { closed = true }) - await new Promise(resolve => setImmediate(resolve)) - expect(closed).toBe(false) - stdout.resume() - stderr.resume() - stdout.end() - stderr.end() - await expect(launch.closed).resolves.toBeUndefined() - }) - it('closes the parent Job when spawning the runner throws synchronously', () => { const failure = new Error('runner spawn failed') const jobs = processOperations() @@ -318,6 +289,31 @@ describe('Windows Job runner adapter', () => { expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) + it('force-stops a runner that times out before publishing target startup', async () => { + const child = new EventEmitter() as ChildProcess + const disconnect = vi.fn(() => { Object.assign(child, { connected: false }) }) + const kill = vi.fn(() => { + queueMicrotask(() => { child.emit('exit', 1, null) }) + return true + }) + Object.assign(child, { pid: process.pid, exitCode: null, signalCode: null, connected: true, disconnect, kill }) + const jobs = processOperations() + const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) + try { + const launch = launchWindowsJob(spec(['fake-target']), { + spawn: vi.fn(() => child) as unknown as typeof spawn, + runnerInvocation: ['fake-runner'], + operations: jobs.operations, + }) + await expect(launch.direct).rejects.toThrow('did not report target start') + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(disconnect).toHaveBeenCalledOnce() + expect(kill).toHaveBeenCalledOnce() + } finally { + now.mockRestore() + } + }) + it('passes a generated Job name to the runner and rejects an empty invocation', async () => { const emptyJobs = processOperations() expect(() => launchWindowsJob(spec(['fake-target']), { diff --git a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts new file mode 100644 index 0000000000..99df24b0d6 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { connect } from 'node:net' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { createWindowsStdioBridge } from '../src/windows-stdio.ts' + +function pipeBase(): string { + return process.platform === 'win32' + ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}` + : join('/tmp', `dsh-windows-stdio-${randomUUID()}`) +} + +function spec(): SubprocessSpawnSpec { + return { + argv: ['target'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } }, + graceMs: 100, + } +} + +function pathAfter(args: readonly string[], key: string): string { + const path = args[args.indexOf(key) + 1] + if (path === undefined) throw new Error(`missing ${key}`) + return path +} + +describe('Windows parent-owned stdio bridge', () => { + it('binds before returning so a synchronously launched peer can connect', async () => { + const bridge = createWindowsStdioBridge({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, + }, pipeBase()) + const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') + const result = spawnSync(process.execPath, ['-e', ` + const { connect } = require('node:net') + const socket = connect(${JSON.stringify(stdoutPath)}) + socket.once('connect', () => { + socket.write('blocked-parent', () => { + socket.destroy() + process.exit(0) + }) + }) + socket.once('error', () => { process.exit(1) }) + setTimeout(() => { process.exit(2) }, 2000) + `], { timeout: 5_000 }) + expect(result.status).toBe(0) + + const chunks: Buffer[] = [] + bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + await once(bridge.stdout as NodeJS.ReadableStream, 'end') + expect(Buffer.concat(chunks).toString()).toBe('blocked-parent') + bridge.dispose() + }) + + it('moves bytes in both directions and ends output with its target-side peer', async () => { + const bridge = createWindowsStdioBridge(spec(), pipeBase()) + const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') + const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe') + const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe') + expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc']) + await new Promise(resolve => setImmediate(resolve)) + + bridge.stdin?.end('in') + const stdoutPeer = connect(stdoutPath) + const stderrPeer = connect(stderrPath) + const stdinPeer = connect(stdinPath) + await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')]) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + const stdinChunks: Buffer[] = [] + bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) }) + bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) + stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) }) + const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end') + const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end') + const stdinEnded = once(stdinPeer, 'end') + + stdoutPeer.end('out') + stderrPeer.end('err') + await Promise.all([stdoutEnded, stderrEnded, stdinEnded]) + + expect(Buffer.concat(stdoutChunks).toString()).toBe('out') + expect(Buffer.concat(stderrChunks).toString()).toBe('err') + expect(Buffer.concat(stdinChunks).toString()).toBe('in') + bridge.dispose() + }) + + it('uses inherited output directly and disposes unconnected endpoints', () => { + const inherited = createWindowsStdioBridge({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }, pipeBase()) + expect(inherited.stdin).toBeNull() + expect(inherited.stdout).toBeNull() + expect(inherited.stderr).toBeNull() + expect(inherited.runnerArgs).toEqual([]) + expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc']) + inherited.dispose() + + const pending = createWindowsStdioBridge(spec(), pipeBase()) + pending.closeInput() + expect(pending.stdin?.destroyed).toBe(true) + pending.dispose() + expect(pending.stdout?.destroyed).toBe(true) + expect(pending.stderr?.destroyed).toBe(true) + }) +}) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 973d02ee96..5c24705011 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 61c9272a24e9007e2944992db6db1211942cdc99 -README.zh.md: 64ade89692a5a6edd73fe8a9cfdf214f8cb67405 +README.md: 5d2b6dc0f669fb14962183e6d761f80ef9ca7e2b +README.zh.md: 9799c781ec1362631801145f9b3ff3cdb096ed35 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 61c9272a24..5d2b6dc0f6 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,7 +10,7 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job, the runner opens it for assignment, and `spawnOrdinaryProcessInJob()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting independently continues until `ActiveProcesses` reaches zero. +- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job and private named-pipe endpoints, the runner opens their target-side handles, and `spawnOrdinaryProcessInJob()` applies explicit stdio, suspended creation, Job assignment, and resume through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting and parent-owned streams then continue independently until their own OS lifetimes end. - **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 64ade89692..9799c781ec 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,7 +10,7 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job,runner 以 assignment 权限打开该 Job,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 则独立持续到 `ActiveProcesses` 归零。 +- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job 与 private named-pipe endpoint,runner 打开它们的 target 端 handle,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用显式 stdio、suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 与 parent-owned stream 随后各自持续到对应 OS 生命周期结束。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 82b4e75dae..d762021788 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,14 +6,20 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** WaitForSingleObject returned because a zero-time probe is not signalled. */ -export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** WaitForSingleObject returned because a zero-time probe is not signalled. */ +export const WAIT_TIMEOUT = 258 /** OpenProcess right required to read limited process information. */ export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 /** Standard right required to wait on a process handle. */ export const SYNCHRONIZE = 0x00100000 +/** Read access requested for a private named-pipe client handle. */ +export const GENERIC_READ = 0x80000000 +/** Write access requested for a private named-pipe client handle. */ +export const GENERIC_WRITE = 0x40000000 +/** Open an existing named-pipe endpoint. */ +export const OPEN_EXISTING = 3 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index edd4702010..d2f3bf1e85 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -68,6 +68,15 @@ export interface Win32ProcessBindings { args: null, ): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + createFileW( + path: string, + desiredAccess: number, + shareMode: number, + securityAttributes: null, + creationDisposition: number, + flagsAndAttributes: number, + templateFile: null, + ): NativePtr setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, @@ -259,6 +268,9 @@ function bindings(): Win32ProcessBindings { 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + createFileW: bind(kernel32, 'CreateFileW', PVOID, [ + 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, + ]), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index f60cd27401..45a81f2beb 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -22,6 +22,7 @@ export { createKillOnCloseJob, drainPipe, isJobEmpty, + openNamedPipeForStdio, openJobForAssignment, openProcessForWait, pollProcessExit, @@ -32,6 +33,7 @@ export { waitForProcessExit, } from './process.ts' export type { + ChildStdioHandles, OrdinaryProcessSpawnOptions, SpawnedAssignedProcess, SpawnedJobProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index d4c842acb3..7b91efbef8 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -63,6 +63,13 @@ export interface OrdinaryProcessSpawnOptions { cwd: string } +/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */ +export interface ChildStdioHandles { + stdin?: NativePtr + stdout?: NativePtr + stderr?: NativePtr +} + /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ @@ -346,6 +353,28 @@ export function openJobForAssignment(api: Win32ProcessBindings, name: string): N return job } +/** + * Open one private named-pipe client for target stdio. + * @param api - active binding table. + * @param path - unique parent-owned named-pipe path. + * @returns caller-owned connected pipe handle. + */ +export function openNamedPipeForStdio(api: Win32ProcessBindings, path: string): NativePtr { + const handle = api.createFileW( + path, + abi.GENERIC_READ + abi.GENERIC_WRITE, + 0, + null, + abi.OPEN_EXISTING, + 0, + null, + ) + if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) { + throwLastError(api, 'CreateFileW', path) + } + return handle +} + /** * Open a process for non-blocking exit observation. * @param api - active binding table. @@ -363,6 +392,7 @@ function spawnJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, job: NativePtr, + stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, ): SpawnedAssignedProcess { @@ -372,9 +402,9 @@ function spawnJobProcess( const win32Code = api.getLastError() throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) } - const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const stdIn = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined @@ -469,7 +499,7 @@ export function spawnInheritedJobProcess( const commandLine = buildCommandLine(options.command, options.args) try { return { - ...spawnJobProcess(api, options, job, 'CreateProcessAsUserW', (startupInfo, processInfo) => + ...spawnJobProcess(api, options, job, {}, 'CreateProcessAsUserW', (startupInfo, processInfo) => createRestrictedProcess( api, options, @@ -491,15 +521,17 @@ export function spawnInheritedJobProcess( * @param api - active binding table. * @param options - command, cwd, and argv. * @param job - caller-owned Job handle that remains open after this call. + * @param stdio - optional explicit handles opened for this target. * @returns caller-owned process handle after successful resume. */ export function spawnOrdinaryProcessInJob( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, job: NativePtr, + stdio: ChildStdioHandles = {}, ): SpawnedAssignedProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, job, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, job, stdio, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 82caebacb1..6529b840d0 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -4,6 +4,7 @@ import { closeHandleChecked, createKillOnCloseJob, isJobEmpty, + openNamedPipeForStdio, openJobForAssignment, openProcessForWait, pollProcessExit, @@ -13,15 +14,18 @@ import { } from '../src/index.ts' import { CREATE_SUSPENDED, + GENERIC_READ, + GENERIC_WRITE, JOB_OBJECT_ASSIGN_PROCESS, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, + OPEN_EXISTING, PROCESS_QUERY_LIMITED_INFORMATION, SYNCHRONIZE, WAIT_TIMEOUT, } from '../src/abi.ts' -import { PROCESS_INFORMATION } from '../src/ffi.ts' +import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' function api(overrides: Partial = {}): Win32ProcessBindings { @@ -29,6 +33,7 @@ function api(overrides: Partial = {}): Win32ProcessBinding createJobObjectW: vi.fn(() => 50n), openJobObjectW: vi.fn(() => 55n), openProcess: vi.fn(() => 60n), + createFileW: vi.fn(() => 70n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -123,6 +128,35 @@ describe('ordinary Job process operations', () => { expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) + it('passes explicit target stdio handles without reading caller stdio', () => { + let startup: Record | undefined + const getStdHandle = vi.fn(() => 99n as NativePtr) + const bindings = api({ + getStdHandle, + createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { + startup = koffi.decode(infoPtr, STARTUPINFOW) as Record + koffi.encode(processInfo, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + }) + expect(spawnOrdinaryProcessInJob(bindings, { + command: 'probe.exe', + args: [], + cwd: 'C:\\work', + }, 50n as NativePtr, { + stdin: 71n as NativePtr, + stdout: 72n as NativePtr, + stderr: 73n as NativePtr, + })).toEqual({ pid: 1234, process: 60n }) + expect(getStdHandle).not.toHaveBeenCalled() + expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) + }) + it('terminates an assigned suspended process when resume fails', () => { const terminateProcess = vi.fn(() => 1) const closeHandle = vi.fn(() => 1) @@ -200,6 +234,24 @@ describe('ordinary Job process operations', () => { expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error) }) + it('opens a private named-pipe client for target stdio', () => { + const createFileW = vi.fn(() => 70n as NativePtr) + const bindings = api({ createFileW }) + expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-test')).toBe(70n) + expect(createFileW).toHaveBeenCalledWith( + '\\\\.\\pipe\\dsh-test', + GENERIC_READ + GENERIC_WRITE, + 0, + null, + OPEN_EXISTING, + 0, + null, + ) + + const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) }) + expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing')).toThrow(Win32Error) + }) + it('opens a direct process for parent-side exit observation', () => { const openProcess = vi.fn(() => 60n as NativePtr) const bindings = api({ openProcess }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index cf96a067c4..bcd8ae5c19 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,6 +21,9 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_EXISTING); P(WAIT_TIMEOUT); P(PROCESS_QUERY_LIMITED_INFORMATION); P(SYNCHRONIZE); @@ -45,6 +48,9 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(GENERIC_READ == 0x80000000, "generic read access"); + static_assert(GENERIC_WRITE == 0x40000000, "generic write access"); + static_assert(OPEN_EXISTING == 3, "open existing disposition"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right"); static_assert(SYNCHRONIZE == 0x100000, "synchronize right"); diff --git a/vitest.config.ts b/vitest.config.ts index cce205c03a..12dd238907 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,11 +57,12 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32' const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', - // The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill) - // executes only on win32; its decision logic is unit-pinned on every - // host through the injected-internals suites. + // The Win32 adapters (Koffi process inspection/Jobs and named-pipe + // streams) execute only on win32; their decisions are unit-pinned on + // every host through focused tests and injected operations. 'packages/subprocess/subprocess-local/src/windows-inspector.ts', 'packages/subprocess/subprocess-local/src/windows-job.ts', + 'packages/subprocess/subprocess-local/src/windows-stdio.ts', ] : [] From 153f1579e1084ec5e896bd9c74a1f07eb44ebf9d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 22:08:49 +0800 Subject: [PATCH 041/110] refactor(subprocess): restore native containment baseline --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 4 +- .../2026-07-06-timeout-deadline-library.zh.md | 4 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 2 +- .../2026-07-15-lsp-capability-seam.zh.md | 2 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.md | 4 +- .../2026-07-26-subprocess-seam.zh.md | 4 +- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 4 +- .../2026-07-27-dispose-ladder-to-consumer.md | 4 +- ...026-07-27-dispose-ladder-to-consumer.zh.md | 4 +- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 4 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 4 +- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 4 +- ...ct-subagent-providers-in-shared-host.zh.md | 4 +- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 10 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 10 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 8 +- ...-08-20-subprocess-native-containment.zh.md | 8 +- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +- ...typescript-sdk-and-sdk-subagent-backend.md | 2 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 22 +- ...ude-code-and-codex-subagent-backends.zh.md | 22 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 6 +- ...t-subagent-one-shot-background-tasks.zh.md | 6 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 4 +- ...-subagent-noninteractive-permissions.zh.md | 4 +- ...8-product-subagent-failure-facts.i18n.yaml | 4 +- ...26-08-18-product-subagent-failure-facts.md | 6 +- ...08-18-product-subagent-failure-facts.zh.md | 6 +- ...product-subagent-named-instances.i18n.yaml | 4 +- ...-08-18-product-subagent-named-instances.md | 2 +- ...-18-product-subagent-named-instances.zh.md | 2 +- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- AGENTS.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 +- docs/config-catalog.zh.md | 18 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 8 +- docs/subsystems/subprocess.zh.md | 8 +- packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 6 +- packages/fs/tool-fs-search/README.zh.md | 6 +- packages/fs/tool-fs-search/src/glob.ts | 2 +- packages/fs/tool-fs-search/src/grep.ts | 2 +- packages/fs/tool-fs-search/src/index.ts | 4 +- packages/fs/tool-fs-search/src/search-core.ts | 6 +- .../fs/tool-fs-search/tests/tools.spec.ts | 6 +- packages/lsp/lsp-stdio/README.i18n.yaml | 4 +- packages/lsp/lsp-stdio/README.md | 4 +- packages/lsp/lsp-stdio/README.zh.md | 4 +- packages/lsp/lsp-stdio/src/connection.ts | 18 +- packages/lsp/lsp-stdio/src/index.ts | 4 +- packages/lsp/lsp-stdio/src/instance.ts | 13 +- packages/lsp/lsp-stdio/tests/instance.spec.ts | 2 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + packages/shell/bash-local/README.i18n.yaml | 4 +- packages/shell/bash-local/README.md | 10 +- packages/shell/bash-local/README.zh.md | 10 +- packages/shell/bash-local/src/index.ts | 47 +-- .../shell/bash-local/tests/executor.spec.ts | 72 ---- packages/shell/bash-sandbox/src/index.ts | 5 +- .../shell/bash-sandbox/tests/sandbox.spec.ts | 4 +- packages/shell/pwsh-local/README.i18n.yaml | 4 +- packages/shell/pwsh-local/README.md | 8 +- packages/shell/pwsh-local/README.zh.md | 8 +- packages/shell/pwsh-local/src/index.ts | 41 +- .../shell/pwsh-local/tests/executor.spec.ts | 58 +-- packages/shell/pwsh-sandbox/src/index.ts | 5 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 6 +- packages/subagent/subagent-acp/README.zh.md | 6 +- packages/subagent/subagent-acp/src/index.ts | 4 +- packages/subagent/subagent-acp/src/run.ts | 33 +- .../subagent-acp/tests/subagent-acp.spec.ts | 27 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 6 +- .../subagent-claude-code/README.zh.md | 6 +- .../subagent-claude-code/src/index.ts | 2 +- .../subagent-claude-code/src/invariant.ts | 2 +- .../subagent-claude-code/src/process.ts | 10 +- .../subagent/subagent-claude-code/src/run.ts | 38 +- .../tests/subagent-claude-code.spec.ts | 29 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 6 +- packages/subagent/subagent-codex/README.zh.md | 6 +- packages/subagent/subagent-codex/src/index.ts | 2 +- .../subagent/subagent-codex/src/invariant.ts | 2 +- packages/subagent/subagent-codex/src/run.ts | 52 +-- .../tests/subagent-codex.spec.ts | 9 +- .../subagent/subagent/src/out-of-process.ts | 2 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 4 +- packages/subprocess/README.zh.md | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 6 +- .../subprocess/subprocess-local/README.zh.md | 6 +- .../subprocess/subprocess-local/src/index.ts | 16 +- .../subprocess-local/src/linux-scope.ts | 19 +- .../subprocess-local/src/managed-owner.ts | 5 +- .../subprocess-local/src/runner-launch.ts | 54 +-- .../subprocess-local/src/runner-protocol.ts | 16 +- .../subprocess-local/src/spawn-runner.ts | 170 ++++----- .../subprocess/subprocess-local/src/spawn.ts | 55 ++- .../subprocess-local/src/windows-job.ts | 299 +++------------ .../subprocess-local/src/windows-stdio.ts | 140 ------- .../tests/fixtures/fake-job-runner.ts | 24 +- .../tests/linux-scope.spec.ts | 39 +- .../subprocess-local/tests/local.spec.ts | 35 -- .../tests/managed-spawn.spec.ts | 30 +- .../tests/native-windows.spec.ts | 90 +---- .../tests/spawn-runner.spec.ts | 63 +-- .../tests/windows-job.spec.ts | 358 +++++------------- .../tests/windows-stdio.spec.ts | 112 ------ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 6 +- packages/subprocess/subprocess/src/types.ts | 6 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 16 +- packages/subprocess/win32-process/src/ffi.ts | 24 +- .../subprocess/win32-process/src/index.ts | 10 +- .../subprocess/win32-process/src/process.ts | 134 ++----- .../tests/ordinary-process.spec.ts | 118 +----- .../win32-process/verify/abi-probe.cpp | 10 - scripts/gen-doc-graphs.ts | 2 +- vitest.config.ts | 7 +- 152 files changed, 789 insertions(+), 2068 deletions(-) delete mode 100644 packages/subprocess/subprocess-local/src/windows-stdio.ts delete mode 100644 packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 0e523f1d20..580e74f217 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 0c29c1d82c7ebced3d6b5ce6c6ae0ecafff8af43 -2026-07-06-timeout-deadline-library.zh.md: 4c56061f7430ec6d220b2110939ad29c0160bf21 +2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a +2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 0c29c1d82c..38f048d16e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -97,7 +97,7 @@ The signal only *notifies*; termination is always the listener's job, and the li ## Consequences -- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The subprocess provider still owns termination of its managed range, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. +- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). @@ -113,4 +113,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing provider-owned managed-range termination path remains unchanged. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 4c56061f74..c8d189c2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -97,7 +97,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): ## 后果 -- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。subprocess provider 继续拥有其 managed range 的终止过程,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 +- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 @@ -113,4 +113,4 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 provider-owned managed-range 终止路径保持不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 8827fccdd1..277e0b097d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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-07-15-lsp-capability-seam.md -2026-07-15-lsp-capability-seam.md: b89b9afaa90aac8abf1f78c797f3219b6ef40248 -2026-07-15-lsp-capability-seam.zh.md: 40785612c3bfc219b0946ac09d6d0c36873b0c28 +2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc +2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index b89b9afaa9..90f9daf4b8 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -108,7 +108,7 @@ The transport-neutral presenter uses `{ card: 'generic', kind: 'search', title, The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. -Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for request-cancel grace plus subprocess termination and output draining; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The LSP provider uses `deadline()` and `timeoutOf()` and owns request cancellation, while the subprocess provider owns range termination and observation; timeout notification alone does not terminate work. +Provider disposal occurs outside tool execution, so `dsh-lsp-stdio` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. ## Workspace, filesystem, and document synchronization diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 40785612c3..bdb5e812e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -108,7 +108,7 @@ interface LspToolInput { seam 和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 -提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期,并提供给 subprocess 终止与输出排空;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。LSP provider 使用 `deadline()` 和 `timeoutOf()` 并拥有请求取消,subprocess provider 则拥有范围终止与观察;超时通知本身不会终止工作。 +提供方 dispose 发生在工具执行之外,因此 `dsh-lsp-stdio` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 ## 工作区、文件系统与文档同步 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index a2d9f7d146..166541f1f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: 973d3b2f9c88033ac61016c42ed3fe3320f4e0f6 -2026-07-26-subprocess-seam.zh.md: e535369b50f8f3e3b74c96de4e0f4341c185f1a6 +2026-07-26-subprocess-seam.md: 92d36bf6d522ab939ef6c0de1063e0accea2946f +2026-07-26-subprocess-seam.zh.md: d271ad95ce84bb34256d3bf2ee6d793e21623d2b diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 973d3b2f9c..92d36bf6d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -39,6 +39,6 @@ Observed stream and lifecycle needs then moved the eligible process consumers on ## Consequences -Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; managed-range signalling, termination scheduling, bounded collection, terminal mechanics, and credential scrubbing each have one owner; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. +Bought: "run and manage a process" is a swappable capability used by Bash, LSP, PTY, and ACP consumers; a containerized or remote process backend slots in without changing their domain semantics; tree signalling, escalation, bounded collection, terminal mechanics, and credential scrubbing each have one implementation; and background processes survive executor reloads, matching the job registry's lifetime model. Process and terminal plumbing is tested through `dsh-subprocess-local`; consumer suites pin only their owned behavior against the real service. -Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, managed-range lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. +Cost: one more package pair and one more composition row wherever a consumer loads; a missing subprocess provider leaves the consumer pending by standard service-injection behavior. Every backend implements executable lookup, three stdio modes, tree lifecycle, and one terminal primitive. The moved-vocabulary re-exports keep `dsh-shell` imports working but mean two packages name the same types; the subprocess seam is the owner. The spawn-failure note became single-delivery through Bash's consuming read cursor instead of repeatable stderr-buffer content. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index e535369b50..d271ad95ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -39,6 +39,6 @@ Status: implemented ## 后果 -换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;managed-range 信号、终止调度、有界收集、终端机制与凭据清除各自只有一个 owner;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 +换来的是:「运行并管理一个进程」成为 Bash、LSP、PTY 与 ACP 消费方共用的可替换能力;容器化或远程进程后端可以直接接入,而无需改变各领域语义;进程树信号、升级终止、有界收集、终端机制与凭据清除各自只剩一份实现;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。进程与终端管道通过 `dsh-subprocess-local` 测试;消费方测试套件只需针对真实服务固定各自拥有的行为。 -代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、managed-range 生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 +代价是:多出一对包,而且凡加载消费方之处都多一行组合配置;缺少 subprocess 提供方时,消费方会按标准服务注入行为保持挂起。每个后端都要实现可执行文件查找、三种 stdio 模式、进程树生命周期和一个终端原语。迁移词汇的重导出让 `dsh-shell` 的导入继续可用,但也意味着两个包命名同一批类型;进程 seam 是所有者。spawn 失败提示经由 Bash 的消费式读取游标变为单次交付,不再是可重复读取的 stderr 缓冲内容。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml index a96f2137a3..a81c4706e7 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.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-07-27-dispose-ladder-to-consumer.md -2026-07-27-dispose-ladder-to-consumer.md: bcd6aa98f45fabda702e41738bbec929f145f739 -2026-07-27-dispose-ladder-to-consumer.zh.md: 4e0dc565e3c19f3780a04d00991490f7a56d5c0f +2026-07-27-dispose-ladder-to-consumer.md: 58d835864d2f6544152fdf09cf6e93380793a2ae +2026-07-27-dispose-ladder-to-consumer.zh.md: dc7413e85fa6e52de14f51fd175c7b37b43029a4 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md index bcd6aa98f4..58d835864d 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -10,7 +10,7 @@ English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) ## Decision -The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call the provider-owned `terminate()` procedure and await an unbounded `waitForExit()` for the subprocess owner's managed-range exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real range exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then call `terminate()`, whose SIGTERM→spec-grace→SIGKILL escalation already owns the signal timer, and await an unbounded `waitForExit()` for the subprocess owner's whole-tree exit proof. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold the cooperative tier on real tree exit without deriving another timer from the termination grace. The seam's handle loses one method and one exported interface. ## Alternatives considered @@ -20,4 +20,4 @@ The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(c ## Consequences -Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns termination and the final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before termination and an unbounded managed-range join after it) instead of the composed policy. +Bought: the Service Definition is one method and one type smaller; Service Providers owe four verbs and no teardown policy; the cooperative EOF window lives beside the ACP config field that tunes it, while the subprocess owner alone owns the termination window and final join. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier tests live in the ACP suite, and the Service Definition suite pins the verbs the ladder composes (bounded `waitForExit` false before escalation and an unbounded whole-tree join after it) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md index 4e0dc565e3..dc7413e85f 100644 --- a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 provider-owned `terminate()` 过程,再无界等待 `waitForExit()`,由子进程责任方证明 managed range 已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认范围真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后调用 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已拥有信号定时器),再无界等待 `waitForExit()`,由子进程责任方证明整棵进程树已经退出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在协作层确认进程树真正退出所需的完全停稳探针,无需从终止宽限期再派生一个定时器。seam 的句柄少了一个方法和一个导出接口。 ## 曾考虑的替代方案 @@ -20,4 +20,4 @@ Status: implemented ## 后果 -买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止与最终的 managed-range 等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(终止前有界 `waitForExit` 返回假,终止后无界等待 managed range 退出),而非组合后的策略。 +买到的:Service Definition 少了一个方法和一个类型;Service Provider 只欠四个动词,不欠拆卸策略;协作式 EOF 时间窗与调节它的 ACP 配置字段住在一起,而终止时间窗与最终的整树退出等待仅由子进程责任方拥有。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试位于 ACP 套件,Service Definition 套件转而钉住阶梯所组合的动词(升级前有界 `waitForExit` 返回假,升级后无界等待整棵进程树退出),而非组合后的策略。 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index b0f05e0482..870bf8ec05 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: bc73a6ebc6edfcbc2f4e4a932806217562d3e73d -2026-08-01-packaged-ripgrep-search.zh.md: 43e907bdc7114daa25ac56338c1be2999ed9c65a +2026-08-01-packaged-ripgrep-search.md: c401bc1e30df0c3443ad58b37f64523b1c10cb31 +2026-08-01-packaged-ripgrep-search.zh.md: 6a195e59f77aa73b9d0918461cef45360e617098 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index bc73a6ebc6..c401bc1e30 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,9 +12,9 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The termination/output-drain grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. -Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess provider starts managed-range termination, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. +Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-tool-call-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize). diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 43e907bdc7..6a195e59f7 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止/输出排空宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 -退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess provider 启动 managed-range 终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 +退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-tool-call-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 `fs-glob-sampling` ACP(Agent Client Protocol)快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 2f4dd87029..66fa66d233 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: 7ea5a17fcdab23621b0017ede56e8bfb11507f35 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 4623dde43d0e47cc0bc9bf5c6714c8572d1a5f01 +2026-08-10-product-subagent-providers-in-shared-host.md: 196e28c1263c4b6d71eaeb59b9ba8457b36f3ff4 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 1451890e1b250a2095e3366c59d6ce0873b55fe9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index 7ea5a17fcd..196e28c126 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -14,13 +14,13 @@ The placement decision must preserve two independent facts. Loading a provider m Product providers remain process-scoped host-plane registrations. The [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) supersedes only this note's former base-bundle installation choice: production `dsh-base` neither depends on nor mounts them. A Profile that opts in installs the selected provider Bundle; its patch mounts the default instance, and the Profile may mount additional named instances on the host plane. The [named-instance decision](../feature/2026-08-18-product-subagent-named-instances.md) owns each row's registry identity: both products accept multiple unique `providerName` values while preserving `codex` and `claude-code` as their defaults. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows whose `provider` and `toolName` values expose exactly the configured instances needed by one agent without changing the Host registry. -Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, managed-range lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. +Each provider package owns its directly installable Bundle patch and private product runtime. This note continues to own process-wide Host placement whenever either provider is installed. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. Each Bundle delegates executable selection to its package-owned product runtime: the Codex package runs its declared wrapper, while the Claude Code package lets its pinned Agent SDK select the private native executable. Neither provider consults or falls back to a host product command. Profile loading creates no product state, probes no version or authentication, and may supply each mounted Provider instance's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing platform payloads and product failures remain local to the attempted delegation. ## Verification -The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and managed-range quiescence. +The base bundle test proves production `dsh-base` contains neither product provider dependency nor provider row. The Web composition installs both optional Bundles and covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Package-owned Loader compositions prove each Bundle default and additional named instances register without starting a product process. Keyless ACP snapshots pin the Codex two-tool roster and the final four-tool combination, while provider tests separately prove private platform-payload selection without host fallback, configuration isolation, failure, cancellation, and process-tree quiescence. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index 4623dde43d..1451890e1b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -14,13 +14,13 @@ Status: implemented 产品提供方仍是进程级的 host plane(宿主平面)注册。[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)只取代本说明原先由 base bundle 安装提供方的选择:生产 `dsh-base` 既不依赖也不挂载它们。选择产品集成的 Profile 会安装目标提供方 Bundle;其 patch 挂载默认实例,而 Profile 可以在 host plane 挂载更多命名实例。[命名实例决策](../feature/2026-08-18-product-subagent-named-instances.zh.md)负责每个配置项的注册身份:两个产品都接受多个唯一的 `providerName`,同时保留 `codex` 与 `claude-code` 作为默认值。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 通过普通 `dsh-tool-subagent` 配置项的 `provider` 与 `toolName` 准确公开单个 agent 所需的已配置实例,而无需更改 Host 注册表。 -每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、managed-range 生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 +每个提供方包都拥有可直接安装的 Bundle patch 与私有产品运行时。本说明继续负责每个已安装提供方的进程级 Host 放置。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.zh.md)继续负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 每个 Bundle 都把可执行文件选择交给包自有的产品运行时:Codex 包运行自身声明的 wrapper,Claude Code 包则让锁定的 Agent SDK 选择私有原生可执行文件。两个提供方都不会查询或回退宿主产品命令。加载 Profile 不会创建产品状态、探测版本或测试身份验证;它可以提供每个已挂载 Provider 实例的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。平台载荷缺失和产品故障仍局限于发生问题的那次委派。 ## 验证 -base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和 managed-range 完全停稳。 +base bundle 测试证明生产 `dsh-base` 既不包含产品提供方依赖,也不包含提供方配置项。Web 组装会安装两个可选 Bundle,并覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。由包负责的 Loader 组装证明每个 Bundle 默认实例与额外命名实例都会完成注册,而不会启动产品进程。无密钥 ACP(Agent Client Protocol)快照固定 Codex 双工具集合与最终四工具组合,提供方测试则另行证明私有平台载荷选择与无宿主回退、配置隔离、失败、取消和进程树完全停稳。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 5393e1c3ff..b3aae39a5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 086bc83c2c146cc836d32c1b4b73991fa00f6fc0 -2026-08-19-shared-win32-process-primitives.zh.md: 62a519e4e327ad238eeb4fd861ea14646ef28b85 +2026-08-19-shared-win32-process-primitives.md: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0 +2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 086bc83c2c..a3ab8ebcfa 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, termination, and handle operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, polling, termination, and handle operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner retains its process handle only until the parent opens a separate wait handle, while the subprocess parent owns direct-result polling and Job accounting, termination, and closure. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately and closes the Job only after it is empty. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time direct-exit reads, parent-side process opening, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered @@ -28,7 +28,7 @@ The shared suite covers x64 ABI values, command-line quoting, binding extension, **Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. -**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, direct wait/poll, and Job controls were added only with their runner and parent consumers. +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused operations would freeze speculative obligations. The ordinary CreateProcess, polling, and Job controls were added only with their runner consumer. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 62a519e4e3..e64f7537cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、termination 与 handle 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、polling、termination 与 handle 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 只把自己的 process handle 保留到 parent 打开独立 wait handle,而 subprocess parent 拥有 direct-result polling 以及 Job accounting、termination 与 closure。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,并只在 Job 为空后关闭它。 该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time direct-exit 读取、parent-side process opening、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered @@ -28,7 +28,7 @@ shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF **为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 -**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、direct wait/poll 与 Job control 只随实际 runner 和 parent consumer 一起加入。 +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的操作会冻结推测性义务。ordinary CreateProcess、polling 与 Job control 只随实际 runner consumer 一起加入。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 4b9440bce5..216e14e98c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 8e0e9b338f1c0f5f2aa0d6a7acde5807025f82ad -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 723d93c9bfb6898563b10c05ac2705a3dee50646 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 517b7e82f00ea16ec6d9f8731be67963a46035da +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: f9555bba3d7fb2dc9971aef21f8a2c8c17d25398 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 8e0e9b338f..517b7e82f0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -6,7 +6,7 @@ English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) ## Problem -The local subprocess provider owns ordinary managed ranges and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and staged-termination timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. @@ -16,15 +16,15 @@ The public subprocess seam correctly promises awaited quiescence during normal d The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: -- An ordinary handle synchronously signals its bound native scope or parent-held Job when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. +- An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. -Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: each ordinary handle runs its provider-owned termination procedure, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. | Host path | Local provider action | Completion evidence | | --- | --- | --- | -| Normal Cordis disposal | Provider-owned termination and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | | `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | | Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` | @@ -32,7 +32,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. -Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, staged POSIX and immediate Windows disposal, live-set retention during pending disposal, and listener removal after disposal. +Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 723d93c9bf..f9555bba3d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -本地 subprocess provider拥有普通 managed range和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与分阶段终止 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.zh.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 @@ -16,15 +16,15 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: -- 普通 handle在可用时同步向绑定的 native scope 或 parent-held Job 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 +- 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 -正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:每个普通 handle 执行其 provider-owned termination procedure,并等待每个普通或 terminal 清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 | 宿主路径 | 本地 provider动作 | 完成证据 | | --- | --- | --- | -| 正常 Cordis dispose | provider-owned termination,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | | `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | | 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 | @@ -32,7 +32,7 @@ Status: implemented 父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 -单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、POSIX 分阶段与 Windows 立即终止、dispose等待期间保留存活集合,以及 dispose后移除 listener。 +单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index 0f4a4eb1f4..a3cf978af0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 708de5d2ce4431222a7f36981d2fb4a3a801c5c0 -2026-08-20-subprocess-native-containment.zh.md: 883cf84f79e5ce00afe3fa9d00097923f6b65afa +2026-08-20-subprocess-native-containment.md: d5a405cddaa2878ca67e7b057d78c79297de26ca +2026-08-20-subprocess-native-containment.zh.md: abd450fe840e4e1e2f388cf40cc24f6b5b7b8860 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 708de5d2ce..d5a405cdda 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -10,11 +10,11 @@ The local subprocess provider treated a POSIX process group or a Windows direct- ## Decision -`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows creates and retains a named kill-on-close Job; a local runner backed by `@deepseek-ai/dsh-win32-process` opens that Job, creates the target suspended, assigns it, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. +`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. -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 runner report target spawn and exit facts and lets the Windows runner report target startup or failure independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. +The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. Windows target descendants inherit the parent-owned Job by default. The parent owns private named-pipe endpoints for every non-inherited standard stream. The runner opens only their target-side handles, opens the Job for suspended create, assignment, and resume, and closes its copies before publishing the target pid. After the parent opens its own direct-process wait handle, it releases the runner through their private IPC channel. The parent then observes target exit, stream EOF, Job termination, and `ActiveProcesses` through separate owned resources. Host exit closes the parent's Job handle, and any still-open runner assignment handle closes as the runner exits, so kill-on-close terminates remaining members. +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. 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. @@ -34,4 +34,4 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports. The Windows runner remains only until the parent acquires direct-process observation, and it closes its target-side named-pipe handles before release; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux event-file reads use asynchronous 100 ms polling, Windows direct-process state uses 10 ms polling, and Linux scope state uses 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry, short-lived private files, and per-spawn pipe names but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 883cf84f79..abd450fe84 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -10,11 +10,11 @@ Status: implemented ## Decision -`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 创建并持有 named kill-on-close Job;由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner 打开该 Job,以 suspended 状态创建目标、完成分配后再恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 +`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 -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 runner 报告 target spawn 与 exit facts,并让 Windows runner 报告 target startup 或 failure,两者都不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 +common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;如果 scope KILL 阻止最终 target event 写入,`.done` 会拒绝而不是虚构结果。Windows target descendant 默认继承 parent-owned Job。parent 为每条非 inherit 标准流持有 private named-pipe endpoint。runner 只打开其 target 端 handle,并打开 Job 完成 suspended create、assignment 与 resume;它在发布 target pid 前关闭自己的 pipe handle 副本。parent 打开自己的 direct-process wait handle 后,通过双方的 private IPC channel 释放 runner。随后由 parent 分别观察 target exit、stream EOF、Job termination 与 `ActiveProcesses`。host exit 会关闭 parent 的 Job handle;如果 runner 的 assignment handle 仍然打开,它会在 runner 退出时关闭,因此 kill-on-close 会终止剩余成员。 +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。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -34,4 +34,4 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒。Windows runner 只保留到 parent 取得 direct-process observation,且它的 target 端 named-pipe handle 会在释放前关闭;Linux runner 则保留到 direct target result,后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux event file 每 100 ms、Windows direct-process state 每 10 ms、Linux scope state 每 200 ms 异步轮询,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry、短期 private files 与 per-spawn pipe name,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 76d7c3faa7..a9a33f4ebf 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: f26c92fbda4fafe9e5bdc469dae7ed0ab530b0b9 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e72aff91daa7f9e96007a09c0ced25e56881bf2e +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index f26c92fbda..84314eaf58 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -15,7 +15,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). - **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. -- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, managed-range teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. +- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. `dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index e72aff91da..a822aac655 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -15,7 +15,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 - **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 -- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、managed-range 拆卸)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 +- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 `dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index e5b9b1d0f5..30ab9e2b09 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3c77321d3f5252a26f484fc78f586eb172e6d465 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: f9b675735fa3e15693b558d8dfd0eaea38f33e88 +2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 8ba4d259872558ced89083bb53f9228f46c3d45c diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 3c77321d3f..9b47fcf49d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,19 +8,19 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. Each route must hand the product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or managed ranges. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official integration, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Decision The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration. -Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, managed-range termination, and quiescence observation. +Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> managed-range exit + both -> provider disposal -> dsh-subprocess -> whole-tree exit ``` ### Ownership and lifecycle @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | Delegation lifecycle | `ctx.subagents` | Resolve the named provider request and pair lifecycle events around the published `SubagentRun` | Unsupported context or malformed input fails before a run is published; start and terminal events remain paired | | Scheduling and adaptation | `dsh-tool-subagent` | Interpret `run_in_background`, choose foreground collection or one-shot Job registration, and map the shared stop reason | Foreground returns the product outcome; background returns a Job id after registration | | Job state and control | `ctx.jobs` and `dsh-tool-jobs` | Own Job state, output, cancellation, owner cleanup, completion notices, and model-facing controls | The exact parent can collect, list, or stop background work and receives its completion notice | -| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove managed-range exit | Foreground return and Job settlement both wait for idempotent disposal and managed-range exit | +| Native run and teardown | Product provider and `dsh-subprocess` | Produce one native result, close the product protocol, request best-effort native cancellation, and prove process-tree exit | Foreground return and Job settlement both wait for idempotent disposal and whole-tree exit | ## Codex provider @@ -42,7 +42,7 @@ Before publication, the provider validates a non-empty text-only task, starts th For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, invokes termination for any acquired managed range, waits for it to empty, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -54,7 +54,7 @@ The public configuration contains a non-empty `providerName`, an explicit `env` The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact. -Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke the subprocess provider's termination procedure, and wait for managed-range exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's quiescence proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit. @@ -62,13 +62,13 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native managed-range exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and managed-range exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. -The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves managed-range exit without calling the Messages API directly from the test. +The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. The project owner's distribution authorization is scoped to the official `@anthropic-ai/claude-agent-sdk` identity and the official Claude Code CLI/platform payloads each SDK version declares through `optionalDependencies`. [`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) derives and discloses the current payload set without reclassifying its declared terms as permissive. Version, license-field, and payload-set changes still undergo ordinary dependency, lockfile, compatibility, terms, and notices review; unrelated non-permissive runtime packages continue to fail closed. @@ -76,7 +76,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead. -**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and managed-range concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. +**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership without deleting either private product adapter, so each adapter calls the existing seams directly. **A model-visible product selector.** Product availability, instance configuration, and authentication are deployment facts. Profile-bound tools keep each schema and provider binding explicit and avoid adding dynamic selection state to the common service. @@ -88,7 +88,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and managed-range quiescence. +Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index f9b675735f..8ba4d25987 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,19 +8,19 @@ Status: implemented 命名的 [`ctx.subagents`](2026-06-21-subagent-capability-seam.zh.md) 注册表让父 agent(智能体)无需了解子 agent 的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。每条路径都必须向产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或 managed range 的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,所需证据要区分三个事实:无密钥真实产品测试证明官方集成、原生身份验证形态、确定性答案与资源清理;Loader 组合测试证明公开包和文档所示的工具配置无需启动产品即可加载;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务取得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代上述任一产品运行层级;手工挂载插件无法取代 Loader 层级。 ## 决策 harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。 -这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、managed-range 终止与完全停稳观测。 +这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 ```text configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product process foreground <- final product outcome background -> ctx.jobs / dsh-tool-jobs -> Job id / state / notice / controls - both -> provider disposal -> dsh-subprocess -> managed-range exit + both -> provider disposal -> dsh-subprocess -> whole-tree exit ``` ### 归属与生命周期 @@ -30,7 +30,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro | 委派生命周期 | `ctx.subagents` | 解析具名提供方请求,并为已发布的 `SubagentRun` 配对生命周期事件 | 不受支持的上下文或格式错误的输入会在发布运行前报错;启动与终态事件保持成对 | | 调度与适配 | `dsh-tool-subagent` | 解释 `run_in_background`,选择前台收集或 one-shot Job 登记,并映射共享停止原因 | 前台返回产品结果;后台在登记完成后返回 Job id | | Job 状态与控制 | `ctx.jobs` 与 `dsh-tool-jobs` | 负责 Job 状态、输出、取消、owner 清理、完成通知与面向模型的控制工具 | 准确父级可以收集、列出或停止后台工作,并收到完成通知 | -| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明 managed range 退出 | 前台返回与 Job 结算都会等待幂等资源释放和 managed range 退出 | +| 原生运行与清理 | 产品提供方与 `dsh-subprocess` | 产生一个原生结果、关闭产品协议、请求尽力而为的原生取消,并证明进程树退出 | 前台返回与 Job 结算都会等待幂等资源释放和整棵进程树退出 | ## Codex 提供方 @@ -42,7 +42,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、请求终止所有已取得的 managed range、等待范围为空、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -54,7 +54,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 -启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用 subprocess provider 的终止过程,并等待 managed range 退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的完全停稳证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。 @@ -62,13 +62,13 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生 managed range 退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责 schema、失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及 managed range 退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.zh.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 -带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明 managed range 退出,且测试不会直接调用 Messages API。 +带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 项目所有者的分发授权范围限定为官方 `@anthropic-ai/claude-agent-sdk` 身份,以及每个 SDK 版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 会推导并披露当前载荷集合,但不会将其声明条款重新归类为宽松条款。版本、许可证字段和载荷集合发生变化时,仍须经过常规的依赖、锁文件、兼容性、条款和声明评审;无关的非宽松运行时包继续以默认拒绝方式失败。 @@ -76,7 +76,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD **直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展集成接口,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都改用相应的官方产品集成。 -**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和 managed range 的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 +**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。新辅助包无法删除任一私有产品适配器,只会造成责任重复,因此每个适配器都会直接调用现有 seam。 **面向模型的产品选择器。** 产品可用性、实例配置和身份验证属于部署事实。由 Profile 绑定的工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 @@ -88,7 +88,7 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SD ## 后果 -用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与 managed-range 完全停稳的责任。 +用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.zh.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 434fd6f19a..48455ab7b7 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 8fc35ad82d75114dccd39a1176996665d0c28376 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 3c6bb848688f891b29a94683573f3479e50da6e2 +2026-08-12-product-subagent-one-shot-background-tasks.md: 5e9522f6fac6eadb874ba2d1d4f45100f962b9e2 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: fdf7fa58e49f6aa9b6fe7a21eb21e6208e56a86f diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 8fc35ad82d..5e9522f6fa 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -16,7 +16,7 @@ Production `dsh` does not install the optional product providers. A Profile that The [named-instance decision](2026-08-18-product-subagent-named-instances.md) allows multiple rows for either product. Each additional host provider row has its own `providerName`, and each exposed preset tool row binds that exact name through `provider` while keeping a unique `toolName`; the foreground/background scheduling choice does not constrain the number of instances. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and managed-range quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. @@ -39,7 +39,7 @@ product tool call | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | -| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one managed range | Job settlement and foreground return consume the same result and both wait for disposal | +| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal | ## Published composition @@ -69,4 +69,4 @@ The Web composition test explicitly mounts both optional providers from the repo Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one. -Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and managed-range exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. +Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 3c6bb84868..fdf7fa58e4 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -16,7 +16,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 [命名实例决策](2026-08-18-product-subagent-named-instances.zh.md)允许两个产品分别拥有多个配置项。每个新增宿主提供方配置项都有独立的 `providerName`,每个公开的 preset 工具配置项都通过 `provider` 绑定该名称并保持唯一的 `toolName`;前台或后台调度选择不会限制实例数量。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与 managed-range 完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.zh.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.zh.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 @@ -39,7 +39,7 @@ product tool call | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | -| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一个 managed range | Job 结算与前台返回消费同一结果,且都会等待资源释放 | +| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 | ## 发布组装 @@ -69,4 +69,4 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供 agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。 -每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和 managed-range 退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 +每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 44f4718609..89bfdef747 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: aa69d1a296f55df01db2632b105511d24ad6b067 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 8525969f189dcb74dec55d19fc869c47f3a1e992 +2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd +2026-08-15-product-subagent-noninteractive-permissions.zh.md: cbf3c3cd14fcecd2c24e335a8b71cc3b5370e247 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index aa69d1a296..8788fba349 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -59,11 +59,11 @@ The foreground consumer presents the stop-reason headline, then the optional dia | Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. | | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | -| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent managed-range disposal. | +| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and managed-range quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native managed range exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 8525969f18..cbf3c3cd14 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -59,11 +59,11 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 | 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 | | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | -| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的 managed-range 资源释放。 | +| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与 managed-range 完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native managed range 会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml index 2bace94aa2..dca365d854 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.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/feature/2026-08-18-product-subagent-failure-facts.md -2026-08-18-product-subagent-failure-facts.md: 79947c57a267b23e9f73dd595ebf89e209fed066 -2026-08-18-product-subagent-failure-facts.zh.md: 01a88322081b048ee1bf044ffb40ad16e88a1693 +2026-08-18-product-subagent-failure-facts.md: 50d8e918f288a6b8a9b90474499b2ed20731643f +2026-08-18-product-subagent-failure-facts.zh.md: abdbf8c0ebddb1fb30cef3c7e80fcf7040e45d2d diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md index 79947c57a2..50d8e918f2 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_ | `query-start` | SDK query construction, native platform-payload startup, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback | | `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category | | `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | -| `teardown` | Query close and managed-range release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | +| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | ### Codex facts @@ -48,7 +48,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian | `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | | `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | | `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | -| `teardown` | Wire close and managed-range release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | +| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | `contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. @@ -64,7 +64,7 @@ Codex app-server 0.147.0 defines eleven string categories and five object varian ## Verification -Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and managed-range quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md index 01a8832208..abdbf8c0eb 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -35,7 +35,7 @@ Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`erro | `query-start` | SDK query 构造、原生平台载荷启动与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 | | `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 | | `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | -| `teardown` | Query 关闭与 managed-range 释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | +| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | ### Codex 事实 @@ -48,7 +48,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant | `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | | `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | | `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | -| `teardown` | Wire 关闭与 managed-range 释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | +| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | `contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 @@ -64,7 +64,7 @@ Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant ## Verification -Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与 managed-range 完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml index 2387488610..614f036a6a 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.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/feature/2026-08-18-product-subagent-named-instances.md -2026-08-18-product-subagent-named-instances.md: 997c555699e8de8c47be9a68bea729a596dbda10 -2026-08-18-product-subagent-named-instances.zh.md: 96b2bc34c082bbc372e7af43f86b1ee76289e83e +2026-08-18-product-subagent-named-instances.md: 759d3941ff8404138954c409f0fd4949e357200e +2026-08-18-product-subagent-named-instances.zh.md: 6faf0e70f639cbc6528e27b800b8e5f99f0d6c86 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md index 997c555699..759d3941ff 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.md @@ -29,7 +29,7 @@ Removing one provider row blocks new starts and removes only tools bound to that ## Verification -Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and managed-range quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. +Both product packages pin their default and custom names, empty-name rejection, duplicate rollback, actual-name diagnostics, two concurrent instances with different permission modes, environments, and cleanup grace, cancellation isolation, and removal of one instance while its published run remains valid. The official product loopback tests run two named instances in one Host against separate model fixtures and prove independent unload and process-tree quiescence. Public Loader compositions mount two rows and two distinct tools for each product without starting either product, while keyless ACP snapshots pin the four-tool combined roster and the absence of a dynamic provider parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md index 96b2bc34c0..6faf0e70f6 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-named-instances.zh.md @@ -29,7 +29,7 @@ Profile 可以用多个配置项挂载同一个 Cordis 插件包,但 Codex 与 ## 验证 -两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与 managed-range 完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 +两个产品包测试都会固定默认与自定义名称、空名称拒绝、重复注册回滚、实际名称诊断、使用不同权限模式、环境与清理宽限期的两个并发实例、取消隔离,以及移除一个实例后其已发布运行仍然有效。官方产品回环测试会在同一个 Host 中针对独立模型 fixture(测试前置数据)运行两个命名实例,并证明独立卸载与进程树完全停稳。公共 Loader 组合会为每个产品挂载两个配置项与两个不同工具,而且不启动任一产品;无密钥 ACP 快照固定最终四工具组合,并证明没有动态提供方参数。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 5338856f63..5fb38dbf24 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 8c3e7b0788a8816143b15aad148eb927e66297d0 -2026-08-08-native-windows-pull-request-ci.zh.md: 57fa551291adde0c98eb198b344aef85186f7b7e +2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10 +2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 8c3e7b0788..d4883cf136 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -32,7 +32,7 @@ Windows durable JSONL paths keep drive roots in native spelling and apply the ex Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. -Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and managed-range exit on each host. +Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 57fa551291..0ba9629e87 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -32,7 +32,7 @@ Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并 启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 -Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和 managed-range 退出。 +Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 ## 曾考虑的替代方案 diff --git a/AGENTS.md b/AGENTS.md index 2024b12749..a66d6cdc54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local managed-range provider + shared Win32 library + subprocess/ subprocess capability + local process-tree provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 55343b9635..dbafe8c766 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: 263e870061467b89961f7e7026e567962acbfdf4 -capability-seams.zh.md: d5def25b4dc5b6a3c6bba7b419c0512cf51552ea +capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c +capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 263e870061..9e99ebbdc0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -460,7 +460,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d5def25b4d..611902310e 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -462,7 +462,7 @@ flowchart LR | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、managed-range/session 生命周期、stdio 处置、终端机制和 provider-defined termination。 | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 | | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | 面向模型的 shell 工具和钩子桥接消费此 seam;沙箱、远程或 PowerShell 执行器可以替换 bash-local,而无需改动这些消费方。 | | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | 插件声明限定于 effect 作用域的 DSH_* 事实;每个 shell 工具在每次执行时收集一份可信快照,其执行器据此重建命名空间。 | | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | 注册表负责精确到 Agent 的会话身份和清理;后端负责终端机制,tool-terminal 则提供限定于所有者作用域的模型接口。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a124170716..432da9e27f 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 879484a1f5b6641cd22eef0a95ff46228b8e6d14 -config-catalog.zh.md: 65c9e1f9041d30e0e0ace6ef2da8fe999a60cc7f +config-catalog.md: 3595bc650153be25a4156f103af4980c9452f4f0 +config-catalog.zh.md: 66f35350640d5fcea7c9cb90d65bf0878ac68806 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 879484a1f5..3595bc6501 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -361,7 +361,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1323,9 +1323,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1520,7 +1520,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2171,11 +2171,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2208,7 +2208,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } @@ -2236,7 +2236,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } @@ -2553,7 +2553,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 65c9e1f904..66f3535064 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -363,7 +363,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } ``` @@ -1325,9 +1325,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` @@ -1523,7 +1523,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -2174,11 +2174,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } @@ -2211,7 +2211,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } @@ -2239,7 +2239,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } @@ -2556,7 +2556,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 8966af73e1..8b51742982 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 1cc704a30eeb7587c784886d31b7ca65e085ad93 -subprocess.zh.md: b861d99742956f5cf469851f8f78bd235706ebc8 +subprocess.md: 701d70629587c202c50047fb88158f39179920d1 +subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 1cc704a30e..701d706295 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index b861d99742..643998ae02 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -114,9 +114,9 @@ interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** @@ -284,7 +284,7 @@ Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. - spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 8697b9cfbb..8129d42c0a 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.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 packages/README.md -README.md: 287e59a54dcd088894bcfe3946727169749220bd -README.zh.md: 6407754a10d1ca441df4c6138125efa4e6356fdc +README.md: ad88e895cec423b71dbc3c0e961ddb8901e0f66d +README.zh.md: 81557ab3b9f3d7a00af6a5ad45a9e04cc7ea063b diff --git a/packages/README.md b/packages/README.md index 287e59a54d..ad88e895ce 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | | [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local managed-range provider, and shared Win32 process library | Product — stable API | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition, local process-tree provider, and shared Win32 process library | Product — stable API | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6407754a10..81557ab3b9 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -19,7 +19,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`identity/`](identity/README.zh.md) | 共享匿名身份 | 产品:稳定 API | | [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | | [`e2b/`](e2b/README.zh.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地 managed-range 提供方与共享 Win32 进程库 | 产品:稳定 API | +| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition、本地进程树提供方与共享 Win32 进程库 | 产品:稳定 API | | [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | | [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | | [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 432015ade2..488c2d756c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1866,7 +1866,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so consumer teardown can await real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 9976f2a8b6..b1a2080662 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.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 packages/fs/tool-fs-search/README.md -README.md: ae232afa161f7b8f0d4b28f4da5c0992643a5f4f -README.zh.md: 4b9372bbce129259a6e01694f2964a5528c6c19e +README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 +README.zh.md: d13581d7e584f97e0779eb232a47d2d119404647 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index ae232afa16..84a3adc31f 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, managed-range termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. +Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background job — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails. ## Deployment requirement: no host rg, co-located workdir/filesystem @@ -29,8 +29,8 @@ Node deployments receive the `@vscode/ripgrep` platform package on supported mac | `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. | | `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. | | `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. | -| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; abort starts the subprocess provider's termination procedure. | -| `graceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. | +| `graceMs` | `3000` | Positive terminate-escalation grace the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). | ## Tools diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 4b9372bbce..d13581d7e5 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -12,7 +12,7 @@ await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false }) await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local ``` -采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、managed-range 终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 +采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。 ## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 @@ -29,8 +29,8 @@ Node 部署在受支持的 macOS、Linux 与 Windows x64/arm64 目标上获得 ` | `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 | | `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 | | `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 | -| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;中止会启动 subprocess provider 的终止过程。 | -| `graceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-tool-call-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 | +| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期须为正值;超过后搜索以 `SEARCH_ABORTED` 失败;该宽限期不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | | `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 | ## 工具 diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 60ff5b4d80..871dc16ae8 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -47,7 +47,7 @@ export interface GlobToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Grace for subprocess termination and output draining (ms). */ + /** Terminate-escalation grace period (ms) for the search process. */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 439dde2310..38f54aa2a5 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -45,7 +45,7 @@ export interface GrepToolCaps { maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number - /** Grace for subprocess termination and output draining (ms). */ + /** Terminate-escalation grace period (ms) for the search process. */ graceMs: number /** Cap on the retained stderr diagnostic tail. */ stderrMaxBytes: number diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index fabd12df43..1765d1b781 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -13,7 +13,7 @@ * ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} / * {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing, * retention, formatted-result spill, and timeout declaration; the subprocess - * seam owns spawn execution, managed-range termination, environment scrubbing, + * seam owns spawn execution, process-tree termination, environment scrubbing, * and raw output capture. The package injects `tools`, `systemPrompt`, and * `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read * opportunistically with `ctx.get()` because formatted-result spill is optional. @@ -83,7 +83,7 @@ export interface Config { searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number - /** Grace for subprocess termination and output draining (ms), bounded by `MAX_TIMER_DELAY_MS`. */ + /** Terminate-escalation grace (ms), handed to the subprocess seam and bounded by `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0852cbb36d..5ac5521033 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -182,8 +182,8 @@ export function resolveRgPath(): Promise { * complete raw stdout. The working directory is the calling agent's session * cwd (`exec.agent.session.header.cwd`) when available, else * `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout - * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation start the - * provider's managed-range termination procedure. + * (`@deepseek-ai/dsh-tool-call-timeout-policy`) and caller cancellation terminate the + * process tree. * * The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` * is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the @@ -208,7 +208,7 @@ export function resolveRgPath(): Promise { * @param toolName - `glob` or `grep`, used in error messages. * @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists). * @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse. - * @param graceMs - grace supplied to subprocess termination and output draining. + * @param graceMs - the seam's terminate-escalation grace period. * @param stderrMaxBytes - cap on the retained stderr diagnostic tail. * @returns the complete stdout, the zero-result flag, and the resolved workdir. */ diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 3a237e648d..abb7e75773 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -93,7 +93,7 @@ class FakeReader implements SubprocessOutputReader { * A scriptable subprocess handle: `done` resolves with the scripted outcome * (or rejects with the scripted error), `terminate()` records the call, and * the spec's abort signal marks the handle terminated — mirroring the seam's - * abort-triggered provider termination. + * abort→terminate escalation. */ class FakeHandle implements SubprocessHandle { readonly pid = 4242 @@ -434,8 +434,8 @@ describe('workdir derivation and signal forwarding', () => { it('reports an abort fired during the run as SEARCH_ABORTED', async () => { // The cooperative tool timeout or caller cancellation aborts exec.signal; - // the subprocess provider then starts managed-range termination. The tool - // classifies the first cause it owns: the abort. + // the subprocess seam then kills the process tree. The tool classifies + // the first cause it owns: the abort. const { ctx, subprocess } = await setup() const controller = new AbortController() subprocess.handler = () => { diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index e08a01a19f..6ca3618083 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.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 packages/lsp/lsp-stdio/README.md -README.md: 5b5e805f2e48d17aeed3cd5f4dce47928b0c9dc5 -README.zh.md: 6dd690a536cd96af889c8c13f2b62282a1cc9328 +README.md: beadc34ec738ae3f6511cae764240c0e9d8c61d7 +README.zh.md: 2d6516a25147fb7593e8d45d843315098cdff7d3 diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 5b5e805f2e..beadc34ec7 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -12,7 +12,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server. -- After protocol shutdown fails, invokes the subprocess provider's termination procedure and awaits the same managed range through `waitForExit()`. The provider owns signal delivery and observation failures; the LSP host owns only protocol-first teardown and the final quiescence wait. +- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome. - Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process. - Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. @@ -32,7 +32,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v | `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | | `maxDocumentBytes` | `4000000` | Largest source file this host will open. | | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | -| `killGraceMs` | `2000` | Grace supplied to subprocess termination and output draining. | +| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | `servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index 6dd690a536..2d6516a251 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。服务器仍存活时返回的错误不会触发重试;如果选中的池化传输在只读查询之前或期间发生故障,提供方会等待其 dispose(资源释放)完成,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。 -- 协议 shutdown 失败后,调用 subprocess provider 的终止过程,并通过 `waitForExit()` 等待同一个 managed range。信号投递与观察失败归 provider 所有;LSP Host 只拥有协议优先的拆卸过程与最终完全停稳等待。 +- 协议 shutdown 失败后,经由子进程 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 - 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程和协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID namespace 不得监视 harness 进程。 - 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验证,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 @@ -32,7 +32,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) | `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 | | `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 | | `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown`/`exit` 的预算。 | -| `killGraceMs` | `2000` | 提供给 subprocess 终止与输出排空的宽限期。 | +| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 | `servers` 必须至少包含一个配置项,每个 id 都必须非空。定时器预算必须是正整数,且不超过 Node 的 `2_147_483_647` ms 定时器上限。所有可执行文件都会在清理 credential 后于加载时解析;后面的坏配置项会阻止所有提供方注册。进程在第一次匹配查询时惰性启动。 diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index 55af39341a..5cfcf10b0c 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -4,8 +4,8 @@ * server→client requests: it answers `workspace/configuration` from static * config, and rejects `workspace/applyEdit` (this host never applies edits or * runs commands). It caps stderr, surfaces framing/decoder failures as a - * fatal close, and exposes managed-range termination through the handle so the - * instance owns teardown; range mechanics live in the subprocess + * fatal close, and exposes tree-scoped termination through the handle so the + * instance owns teardown; group/tree mechanics live in the subprocess * Service Provider. * @module @deepseek-ai/dsh-lsp-stdio/connection */ @@ -29,9 +29,9 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * The subprocess spec's `graceMs`: available to the provider's termination - * procedure and used to bound draining pipes a survivor still holds after - * the server exits. + * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of + * {@link LspConnection.terminate}'s escalation, and the bound for draining + * pipes a surviving helper still holds after the server exits. */ readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ @@ -88,7 +88,7 @@ export class LspConnection { this.decoder = new MessageDecoder(spec.maxMessageBytes) // stdin/stdout are piped protocol streams this endpoint frames itself; // stderr is a collected diagnostic tail (no spill — the bounded tail IS - // the contract). The seam owns managed-range signalling and quiescence. + // the contract). The seam owns detachment and tree-scoped signalling. this.handle = spawner({ argv: [spec.command, ...spec.args], cwd: spec.cwd, @@ -209,15 +209,15 @@ export class LspConnection { return this.nextId } - /** Start the provider's idempotent termination procedure for the server's managed range. */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { this.handle.terminate() } /** - * Wait until the provider-managed range is empty. + * Wait until the owned process tree has exited. * @param signal - optional bound for the wait. - * @returns `true` when the range is empty, or `false` when the signal aborted first. + * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { return await this.handle.waitForExit(signal) diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index abd7ab35c5..ebc54702da 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -72,9 +72,9 @@ export interface LspLocalServerConfig { maxStderrBytes?: number /** Largest source file this host will open (bytes). Default 4000000. */ maxDocumentBytes?: number - /** Graceful `shutdown`/`exit` budget before provider termination (ms). Default 5000. */ + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** Grace supplied to subprocess termination and output draining (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index 88e80b88b0..2028129de7 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -269,8 +269,8 @@ export class LspInstance { } /** - * Reject queued work, attempt graceful `shutdown`/`exit`, then request the - * provider's termination procedure and await process close and range quiescence. + * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting + * process close so nothing outlives disposal. */ async dispose(): Promise { await this.startTeardown() @@ -288,7 +288,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative. + // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -303,9 +303,10 @@ export class LspInstance { } /** - * Start the provider's termination procedure, then await command transport - * and managed-range exit. The awaits are unbounded on purpose: quiescence, - * not another consumer timer, is the postcondition disposal owes its callers. + * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), + * then await leader and helper exit. The awaits are unbounded on purpose: + * the seam's escalation already committed to SIGKILL, so quiescence — not + * another timer — is the postcondition disposal owes its callers. */ private async forceTerminate(): Promise { this.connection.terminate() diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index a2dcebcc8f..9efc091919 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -312,7 +312,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it('awaits a surviving managed-range helper on every concurrent dispose', async () => { + it('awaits a surviving process-tree helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 0e50bf4392..983f5953f8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -28,6 +28,7 @@ const PPVOID: Ptr = koffi.pointer(PVOID) /** ACL/token calls composed with the generic Win32 process binding table. */ export interface Win32Bindings extends Win32ProcessBindings { + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number localAlloc(flags: number, bytes: number): NativePtr localFree(memory: NativePtr): NativePtr @@ -221,6 +222,7 @@ let cached: Win32Bindings | undefined function bindings(): Win32Bindings { if (cached !== undefined) return cached cached = extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => ({ + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 70141eb599..82a7387566 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.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 packages/shell/bash-local/README.md -README.md: 051b8aba0c66d2970f42ada9d3437265c29cd839 -README.zh.md: ff5a0178f8dedaf2f4bea5eb603e51bd2ffa74bc +README.md: 5e62ea24676f3bedf32b1b48f8618d703d5fb462 +README.zh.md: 92f842a777aebec2e2cb6a8c54966e46202531f1 diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 051b8aba0c..5e62ea2467 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call in a provider-managed range through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. +Local Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. @@ -17,14 +17,14 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## Behavior - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs. -- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Background processes** — `start()` returns a live `ShellProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Job ids, ownership, polling, and notices belong to the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with. @@ -41,7 +41,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary is hardcoded, so this executor is not composed on Windows. -- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index ff5a0178f8..92f842a777 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `bash -c `,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、终止和 dispose(资源释放)等 managed-range 机制则由 subprocess 服务负责。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 Service Provider,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -17,14 +17,14 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## 行为 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 - **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 -- **在 managed range 之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。 - **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **后台进程**:`start()` 会立即返回活动的 `ShellProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。job id、所有权、轮询和通知属于通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md),工具层会在其中注册该句柄。 @@ -41,7 +41,7 @@ - **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要隔离的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.zh.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 -- **仅支持 POSIX**:`bash` 二进制已硬编码,因此本执行器不会在 Windows 上组装。 -- **后台失败提示只交付一次**:`done` 拒绝且没有真实 stderr 时,执行器会把一条诊断注入恰好一个 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 记录;这些机制归它所有。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 171ab0e4aa..6c37c5b794 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -31,7 +31,7 @@ export const ENV_OVERRIDES = { GIT_PAGER: 'cat', } as const -/** Default subprocess termination and output-drain grace (the `graceMs` config; matches OpenCode's 3s). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -49,7 +49,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number } @@ -66,15 +66,6 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } -/** Whether a rejection carries direct evidence that argv[0] never started. */ -function isSpawnFailure(error: unknown, program: string): boolean { - if (typeof error !== 'object' || error === null) return false - const { path, syscall } = error as { path?: unknown; syscall?: unknown } - if (typeof syscall !== 'string') return false - if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false - return path === undefined || path === program -} - function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -103,8 +94,8 @@ export function assertServiceableBashConfig(config: Config): void { /** * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and - * provider-owned managed-range termination are the subprocess service's - * mechanics; this executor supplies the configured budgets per spawn, so a + * process-group SIGTERM→SIGKILL escalation are the subprocess service's + * mechanics; this executor supplies their configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ @@ -254,7 +245,7 @@ export class LocalBashExecutor extends ShellExecutor { /** * Start an explicit argv with the background lifecycle, environment, output, - * cancellation, and managed-range ownership semantics of this executor. + * cancellation, and process-tree ownership semantics of this executor. * Subclasses use this after replacing the public command's shell argv at an * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. @@ -266,12 +257,12 @@ export class LocalBashExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) - // A rejected subprocess result has no settled outcome. Its diagnostic is - // delivered exactly once through the read path. - let failureNote: string | undefined - const consumeFailure = (): string => { - const note = failureNote ?? '' - failureNote = undefined + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined return note } @@ -290,10 +281,10 @@ export class LocalBashExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) + // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` - this.onProcessDone(proc, failureNote, spawnFailed, error) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -301,9 +292,9 @@ export class LocalBashExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A rejected subprocess may have no process output; its synthetic note - // is used only when no real stderr is available. - const errText = err.text.length > 0 ? err.text : consumeFailure() + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -328,13 +319,13 @@ export class LocalBashExecutor extends ShellExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. - * Called after exit facts or rejection output are stamped and before + * Called after exit facts or spawn-failure output are stamped and before * {@link ShellProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the subprocess promise rejected before a process started. - * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. + * @param _spawnError - the original spawn rejection reason, which may itself be undefined. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 11299b0ed9..c081ad7078 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -4,11 +4,9 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import SubprocessRuntime from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { ShellProcess } from '@deepseek-ai/dsh-shell' -import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) @@ -22,43 +20,6 @@ async function setup(config: ConstructorParameters[1] return { ctx, bash } } -class RejectingSubprocessRuntime extends SubprocessRuntime { - private readonly reader: SubprocessOutputReader = { - readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), - } - - constructor(ctx: Context, private readonly failure: unknown, private readonly processId = 123) { - super(ctx) - } - - override async resolveExecutable(command: string): Promise { return command } - override spawnTerminal(): Promise { throw new Error('bash spawns pipes, never terminals') } - override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { - return { - pid: this.processId, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: { stdout: this.reader, stderr: this.reader }, - done: Promise.resolve().then(() => { throw this.failure }), - terminate: () => {}, - waitForExit: async () => true, - } - } -} - -class ObservingBashExecutor extends LocalBashExecutor { - spawnFailed: boolean | undefined - - protected override onProcessDone( - _proc: ShellProcess, - _stderr: string, - spawnFailed: boolean, - ): void { - this.spawnFailed = spawnFailed - } -} - /** * Poll a handle's consuming readOutput until the ACCUMULATED delta contains * `expected`; returns the accumulation (reads never re-deliver, so the caller @@ -335,39 +296,6 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.status).toBe('killed') expect(proc.readOutput().delta).toContain('spawn failed:') }) - - it('does not label a post-start provider rejection as a spawn failure', async () => { - const ctx = new Context() - const failure = Object.assign(new Error('managed owner became unreadable'), { - code: 'ENOENT', - syscall: 'spawn bash', - path: 'bash', - }) - new RejectingSubprocessRuntime(ctx, failure) - await ctx.plugin(ObservingBashExecutor) - const bash = ctx.shell as ObservingBashExecutor - const proc = bash.start(bash.resolve({ command: 'true' })) - await proc.done - expect(proc.readOutput().delta).toContain('subprocess failed:') - expect(proc.readOutput().delta).toBe('') - expect(bash.spawnFailed).toBe(false) - }) - - it.each([ - ['non-object rejection', undefined, 'subprocess failed:', false], - ['non-string syscall', { syscall: 1 }, 'subprocess failed:', false], - ['non-spawn syscall', { syscall: 'kill', path: 'bash' }, 'subprocess failed:', false], - ['matching syscall without path', { syscall: 'spawn bash' }, 'spawn failed:', true], - ])('classifies a pre-start %s from structured evidence', async (_label, failure, note, spawnFailed) => { - const ctx = new Context() - new RejectingSubprocessRuntime(ctx, failure, -1) - await ctx.plugin(ObservingBashExecutor) - const bash = ctx.shell as ObservingBashExecutor - const proc = bash.start(bash.resolve({ command: 'true' })) - await proc.done - expect(proc.readOutput().delta).toContain(note) - expect(bash.spawnFailed).toBe(spawnFailed) - }) }) describe('process lifecycle ownership (the subprocess service, not the executor)', () => { diff --git a/packages/shell/bash-sandbox/src/index.ts b/packages/shell/bash-sandbox/src/index.ts index 2f6b92f772..be9c6647ec 100644 --- a/packages/shell/bash-sandbox/src/index.ts +++ b/packages/shell/bash-sandbox/src/index.ts @@ -151,9 +151,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A definite spawn rejection never started the confined launch. A - // settled runner failure outranks denial because its diagnostics may - // contain denial terms; unclassified provider rejection proves neither. + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index b17277bc77..750fb870c3 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -558,7 +558,7 @@ describe('background sandbox facts', () => { } }) - it('does not invent spawn or runner evidence for an unstructured subprocess rejection', async () => { + it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -579,7 +579,7 @@ describe('background sandbox facts', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done - expect(task.readOutput().delta).toContain('subprocess failed: undefined') + expect(task.readOutput().delta).toContain('spawn failed: undefined') expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index beff18aafd..a7712c1df6 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.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 packages/shell/pwsh-local/README.md -README.md: 44ea2f3d9face0499f0b7088a257a391a7370225 -README.zh.md: 81e46ad52f0c00817f31f0ba51379f2f1cbbc840 +README.md: 2eccc59b919d1f729eef52a42581f4da0f1d9e60 +README.zh.md: b7072ebd9be4e148cedd4edd6c20b12536814856 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 44ea2f3d9f..2eccc59b91 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call in a provider-managed range through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Managed-range mechanics (bounded spill-backed output, credential scrub, termination, disposal) are the subprocess service's. +Local PowerShell Service Provider for the `@deepseek-ai/dsh-shell` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `PwshLocalExecutor` spawns `pwsh -NoLogo -NoProfile -NonInteractive -Command ` per call as a managed process through `ctx.subprocess`, and owns everything PowerShell-shaped — executable resolution, command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. The command string rides as ONE argv element to `-Command`: PowerShell itself parses the text, and no intermediate shell exists, so there is no shell-quoting layer to escape (the `bash -c` string domain has no equivalent here). Native Win32 paths (`C:\...`) pass through unchanged. @@ -19,7 +19,7 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../shell/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.shell`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. - **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. -- **Configured budgets over managed ranges** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Provider-owned termination, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `ShellExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. - **Background processes** — `start()` returns a live `ShellProcess` handle immediately, no timeout applies, and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.jobs` runtime](../../jobs/jobs/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. @@ -49,7 +49,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose a sandboxing bash executor or policy instead. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. -- **A background failure note is single-delivery** — when `done` rejects and no real stderr is available, the executor injects one diagnostic into exactly one `readOutput()` delta. A Node-shaped rejection that identifies `argv[0]` uses `spawn failed: …`; a provider failure without proof that the target never started uses `subprocess failed: …`. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification (POSIX `killed`) does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using namespace`/`using assembly` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble. Wrap a `param(...)` script in `& { … }` (a param block legally heads a script block); `using` statements and `#requires` have no in-command workaround (`#requires` is inert inside `-Command` regardless of position) — run such scripts from a file instead. - **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws. pwsh 7 defaults to UTF-8 and is unaffected. diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index 81e46ad52f..b7072ebd9b 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用都通过 `ctx.subprocess` 在 provider-managed range 中 spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。Managed-range 机制(有界 spill 输出、凭据清理、终止、dispose(资源释放))属于 subprocess 服务。 +`@deepseek-ai/dsh-shell` 执行器 seam 的本地 PowerShell Service Provider,基于 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 服务:`PwshLocalExecutor` 每次调用以受管进程的方式通过 `ctx.subprocess` spawn `pwsh -NoLogo -NoProfile -NonInteractive -Command `,并负责所有 PowerShell 相关事项——可执行文件解析、命令默认化与上限、超时/取消分类、面向模型的终端环境,以及后台读取的 stdout/stderr 合并。进程组机制(有界 spill 输出、凭据清理、终止升级、dispose(资源释放))属于 subprocess 服务。 命令字符串作为单个 argv 元素传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层(这里不存在与 `bash -c` 字符串域对应的层)。原生 Win32 路径(`C:\...`)原样通过。 @@ -19,7 +19,7 @@ maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk maxSpillBytes: 67108864 # per-stream full-output spill cap - graceMs: 3000 # subprocess termination and post-exit pipe-drain grace + graceMs: 3000 # kill escalation and post-exit pipe-drain grace pwshPath: C:\Program Files\PowerShell\7\pwsh.exe # explicit executable; else well-known locations, then PATH ``` @@ -31,7 +31,7 @@ - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../shell/README.zh.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.shell` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess 收集器以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 - **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 -- **Managed range 之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。Provider-owned termination、退出后管道排空、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 +- **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.zh.md) 的机制。前台 `ShellExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 - **后台进程**——`start()` 立即返回存活的 `ShellProcess` 句柄,不设超时;句柄的 `readOutput()` 把服务基于偏移的 stdout/stderr 读取合并为一条按分段标记、通过消费游标推进的增量。仍在运行的进程属于 subprocess 服务,因此它跨执行器重载存活,并随服务 dispose(被终止并 join)。一切任务相关职责(job id、所有权、轮询、通知)都在通用 [`ctx.jobs` 运行时](../../jobs/jobs/README.zh.md) 中,由工具层把句柄注册进去——本执行器从不接触会话或注册表。 @@ -49,7 +49,7 @@ - **自身不设沙箱**——本执行器始终以 harness 进程的权限运行命令;需要隔离的部署应组合启用沙箱的 bash 执行器或策略。 - **无持久 shell 或 PTY**——每次调用都是全新的 `pwsh -Command`。 - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 -- **后台失败提示只投递一次**——`done` 拒绝且没有真实 stderr 时,执行器只把一条诊断注入一次 `readOutput()` 增量。能以 Node-shaped 字段确认 `argv[0]` 未启动的拒绝使用 `spawn failed: …`;无法证明目标未启动的 provider failure 使用 `subprocess failed: …`。 +- **后台 spawn 失败提示只投递一次**——subprocess 服务不会为从未运行的进程缓冲输出,因此执行器只把 `spawn failed: …` 注入一次 `readOutput()` 增量;丢弃该增量的读取方无法恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结束,因此基于信号的状态分类(POSIX `killed`)在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`。 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using namespace`/`using assembly` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行。`param(...)` 脚本可包进 `& { … }`(param 块可以合法地位于脚本块开头);`using` 语句与 `#requires` 在命令内没有变通办法(`#requires` 在 `-Command` 中无论位置如何都不生效)——此类脚本请改从文件运行。 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常。pwsh 7 默认 UTF-8,不受影响。 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 8867245e33..b7a2d9f915 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -48,7 +48,7 @@ export const ENV_OVERRIDES = { export const ENCODING_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' -/** Default subprocess termination and output-drain grace (the `graceMs` config). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config). */ const DEFAULT_GRACE_MS = 3_000 /** Default per-stream spill cap (the `maxSpillBytes` config). */ @@ -66,7 +66,7 @@ export interface Config { maxOutputBytes?: number /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ maxSpillBytes?: number - /** Grace for subprocess termination and inherited-pipe draining; at most `MAX_TIMER_DELAY_MS`. */ + /** Grace period for kill escalation and inherited pipes; at most `MAX_TIMER_DELAY_MS`. */ graceMs?: number /** * Explicit pwsh executable. When omitted, well-known Windows install @@ -94,15 +94,6 @@ function finalOutput(reader: SubprocessOutputReader): CollectedOutput { } } -/** Whether a rejection carries direct evidence that argv[0] never started. */ -function isSpawnFailure(error: unknown, program: string): boolean { - if (typeof error !== 'object' || error === null) return false - const { path, syscall } = error as { path?: unknown; syscall?: unknown } - if (typeof syscall !== 'string') return false - if (syscall !== 'spawn' && syscall !== `spawn ${program}`) return false - return path === undefined || path === program -} - function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`pwsh-local: ${name} must be a positive finite number`) @@ -131,7 +122,7 @@ export function assertServiceablePwshConfig(config: Config): void { /** * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill - * files, and managed-range termination are the subprocess service's mechanics; + * files, and process-tree termination are the subprocess service's mechanics; * this executor supplies their configured budgets per spawn. */ export class PwshLocalExecutor extends ShellExecutor { @@ -295,12 +286,12 @@ export class PwshLocalExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) - // A rejected subprocess result has no settled outcome. Its diagnostic is - // delivered exactly once through the read path. - let failureNote: string | undefined - const consumeFailure = (): string => { - const note = failureNote ?? '' - failureNote = undefined + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined return note } @@ -319,10 +310,10 @@ export class PwshLocalExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - const spawnFailed = running.pid <= 0 && isSpawnFailure(error, argv[0] as string) + // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - failureNote = `${spawnFailed ? 'spawn' : 'subprocess'} failed: ${String(error)}` - this.onProcessDone(proc, failureNote, spawnFailed, error) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -330,9 +321,9 @@ export class PwshLocalExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A rejected subprocess may have no process output; its synthetic note - // is used only when no real stderr is available. - const errText = err.text.length > 0 ? err.text : consumeFailure() + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -363,7 +354,7 @@ export class PwshLocalExecutor extends ShellExecutor { * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. * @param _spawnFailed - whether the spawn rejected before any process existed. - * @param _spawnError - the original subprocess rejection when settlement failed; it may itself be undefined. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 2938ac63f9..e26211b268 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -2,7 +2,7 @@ * Real-process tests for `@deepseek-ai/dsh-pwsh-local`: the LOCAL subprocess * service plus a REAL pwsh executable, exercised through the executor seam * (`resolve` → `run`/`start`). These verify the world — actual PowerShell - * runs, output capture, truncation and spill, deadlines, termination, and + * runs, output capture, truncation and spill, deadlines, kill escalation, and * the background-handle contract. The suite self-skips when no usable `pwsh` * resolves (a CI accommodation for hosts without PowerShell); the pure unit tests * (config validation, executable resolution) run on every platform. PowerShell @@ -175,43 +175,6 @@ describe('spawn construction (pure, every platform)', () => { } } - class RejectingSubprocessRuntime extends SubprocessRuntime { - private readonly reader: SubprocessOutputReader = { - readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), - } - - constructor(ctx: Context, private readonly failure: unknown) { - super(ctx) - } - - override async resolveExecutable(command: string): Promise { return command } - override spawnTerminal(): Promise { throw new Error('pwsh spawns pipes, never terminals') } - override spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { - return { - pid: 123, - stdin: undefined, - stdout: undefined, - stderr: undefined, - collected: { stdout: this.reader, stderr: this.reader }, - done: Promise.resolve().then(() => { throw this.failure }), - terminate: () => {}, - waitForExit: async () => true, - } - } - } - - class ObservingPwshExecutor extends PwshLocalExecutor { - spawnFailed: boolean | undefined - - protected override onProcessDone( - _proc: ShellProcess, - _stderr: string, - spawnFailed: boolean, - ): void { - this.spawnFailed = spawnFailed - } - } - it('runs every command as ONE argv element under the UTF-8 encoding preamble', async () => { const ctx = new Context() const subprocess = new CapturingSubprocessRuntime(ctx) @@ -224,23 +187,6 @@ describe('spawn construction (pure, every platform)', () => { expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') }) - - it('does not label a post-start provider rejection as a spawn failure', async () => { - const ctx = new Context() - const failure = Object.assign(new Error('managed owner became unreadable'), { - code: 'ENOENT', - syscall: 'spawn pwsh', - path: 'pwsh', - }) - new RejectingSubprocessRuntime(ctx, failure) - await ctx.plugin(ObservingPwshExecutor, { pwshPath: 'pwsh' }) - const pwsh = ctx.shell as ObservingPwshExecutor - const proc = pwsh.start(pwsh.resolve({ command: 'Write-Output ok' })) - await proc.done - expect(proc.readOutput().delta).toContain('subprocess failed:') - expect(proc.readOutput().delta).toBe('') - expect(pwsh.spawnFailed).toBe(false) - }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { @@ -454,7 +400,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' expect(lf(read.delta)).toContain('[stderr]') }) - it('kill() terminates the managed range: true once, false after settlement', async () => { + it('kill() terminates the process tree: true once, false after settlement', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) expect(proc.kill()).toBe(true) diff --git a/packages/shell/pwsh-sandbox/src/index.ts b/packages/shell/pwsh-sandbox/src/index.ts index ec3e8fb98d..66bc73bcf0 100644 --- a/packages/shell/pwsh-sandbox/src/index.ts +++ b/packages/shell/pwsh-sandbox/src/index.ts @@ -157,9 +157,8 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A definite spawn rejection never started the confined launch. A - // settled runner failure outranks denial because its diagnostics may - // contain denial terms; unclassified provider rejection proves neither. + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. const runnerFailed = spawnFailed ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 0a82434228..4aaf1b7552 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.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 packages/subagent/subagent-acp/README.md -README.md: eeeaa9350103761b1507c54d3bb48de97d735906 -README.zh.md: d70e30f7b85fbb052101ef7cc43adc2970630295 +README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 +README.zh.md: 7ae89ece0ce4282ad5b9a20142a2ba9b111f6d88 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index eeeaa93501..3bccddbca0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's provider-owned `terminate()` procedure and await managed-range exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then invoke the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly) and await the subprocess owner's whole-tree exit proof. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context @@ -31,7 +31,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | -| `disposeGraceMs` | `3000` | Positive grace supplied to subprocess termination and output draining; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | +| `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess provider's termination procedure and managed-range join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal applies this plugin's EOF window before the subprocess-owned SIGTERM→SIGKILL escalation and whole-tree join. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index d70e30f7b8..7ae89ece0c 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose(资源释放)请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄由 provider 拥有的 `terminate()` 过程并等待 managed range 退出。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后使用该 seam 定义的操作运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作式完全停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),并等待子进程责任方给出整棵进程树的退出证明。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 @@ -31,7 +31,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | +| `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md)。 | ```yaml - id: subagent-acp @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由 subprocess provider 执行其终止过程并等待 managed range 停稳。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) seam spawn:共享的凭据清除先移除疑似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 会继承到父进程自身的流,dispose 则先应用本插件的 EOF 时间窗,再由子进程责任方执行 SIGTERM→SIGKILL 升级并等待整棵进程树退出。ACP 协议格式(wire format)是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘(postmortem)0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)。 diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 0b4ad14baa..4b526279ba 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -55,11 +55,11 @@ export interface Config { /** * Grace period (ms) for the child's EOF-driven quiesce on dispose — its * window to flush persistence and tear down its own nested subprocesses - * before the parent invokes provider termination. Must not exceed + * before the parent escalates to a signal. Must not exceed * `MAX_TIMER_DELAY_MS`. */ disposeEofGraceMs?: number - /** Grace for subprocess termination and output draining (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ + /** Termination-escalation grace (ms); must not exceed `MAX_TIMER_DELAY_MS`. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 688dc3c8a4..5ba7bc1718 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -58,18 +58,19 @@ export interface AcpRunSpec { /** * Grace period (ms) for the child's EOF-driven quiesce in * {@link SubagentRun.dispose} — the window to flush persistence and tear down - * its OWN nested subprocesses before the parent invokes provider termination. The + * its OWN nested subprocesses before the parent escalates to a signal. The * plugin fills this from its `disposeEofGraceMs` config. */ disposeEofGraceMs: number /** - * Grace supplied to subprocess termination and output draining in - * {@link SubagentRun.dispose}. The plugin fills it from `disposeGraceMs`. + * Termination-escalation grace (ms) in {@link SubagentRun.dispose}; POSIX + * waits this long after `SIGTERM` before `SIGKILL`, while Windows + * force-terminates directly. The plugin fills it from `disposeGraceMs`. */ disposeGraceMs: number /** * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the - * child rides the shared scrub, managed-range teardown, and service-owned + * child rides the shared scrub, tree-scoped teardown, and service-owned * lifetime instead of a package-local child_process path. */ spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -84,13 +85,13 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** EOF grace for child flush and nested-process teardown; wider than the termination grace below. */ +/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 -/** Default subprocess termination and output-drain grace (the `disposeGraceMs` config). */ +/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Bounded managed-range wait: polls the handle until its owned range exits or `ms` elapses. */ +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { const controller = new AbortController() const timer = setTimeout(() => { controller.abort() }, ms) @@ -103,20 +104,24 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - // 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(() => {}) + // 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 + } child.stdin?.end() if (await treeExitsWithin(child, eofGraceMs)) return - // terminate() owns the provider-specific procedure. Its unbounded wait is - // the range owner's exit proof, not a second derived grace that can overflow. + // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is + // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() await child.waitForExit() } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index a05ea83630..26ec4cd0b3 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } 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 { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +import type { 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,29 +196,6 @@ 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', () => { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 61fcdc56f4..ff129c60e2 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.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 packages/subagent/subagent-claude-code/README.md -README.md: df7eadd15d6c15b93b0cb888f740bbbbf47161d5 -README.zh.md: aba699d520f541a69c2f9a96d85a0ee789361f51 +README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331 +README.zh.md: 484241f1ca23f2b8ee843b6f412d0f779832d400 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index df7eadd15d..0260d9c82d 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -6,11 +6,11 @@ This package registers a Profile-named Claude Code subagent provider whose defau ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired managed range, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the subprocess provider's termination procedure, and waits for managed-range exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. ## Native settings and interaction @@ -29,7 +29,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `providerName` | `claude-code` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), supplied to subprocess termination and output draining; disposal then waits for managed-range exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | | `permissionMode` value | Native behavior | |---|---| diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index aba699d520..484241f1ca 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -6,11 +6,11 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的 managed range 并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用 subprocess provider 的终止过程,并等待 managed range 退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 @@ -29,7 +29,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `providerName` | `claude-code` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待 managed range 退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | 原生行为 | |---|---| diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1bbfaead73..741f93a6fb 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -50,7 +50,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code termination and output draining. */ + /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts index e61a4ee735..44fa400e16 100644 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and managed-range ownership belongs to the subprocess service. + * service and process-tree ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index a01b4c560f..e9c3d81156 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -40,7 +40,7 @@ export function sdkEnvironmentOverlay( /** * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. - * @param graceMs - subprocess termination and output-drain grace. + * @param graceMs - process-tree termination grace. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( @@ -73,7 +73,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** * Project a managed process with piped stdin and stdout. - * @param child - shared handle that remains the managed-range authority. + * @param child - shared handle that remains the process-tree authority. */ constructor(private readonly child: SubprocessHandle) { this.stdin = child.stdin as NonNullable @@ -93,7 +93,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { ) } - /** Whether the SDK has requested managed-range termination. */ + /** Whether the SDK has requested managed tree termination. */ get killed(): boolean { return this.killRequested } @@ -114,8 +114,8 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { } /** - * Route the SDK's termination request to the managed-range owner. - * @param _signal - SDK-selected signal; the provider owns its termination procedure. + * Route the SDK's termination request to the tree-scoped process owner. + * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. * @returns false only after exit or a previous termination request. */ kill(_signal: NodeJS.Signals): boolean { diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index dfcc9cdd31..3b0a19073d 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its * real CLI process under the shared subprocess owner, map only strict SDK - * success to completion, and dispose to managed-range quiescence. + * success to completion, and dispose to whole-tree quiescence. * * @module @deepseek-ai/dsh-subagent-claude-code/run */ @@ -36,7 +36,7 @@ import { ManagedClaudeCodeProcess, } from './process.ts' -/** Default subprocess termination and output-drain grace. */ +/** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** Claude Code permission modes that cannot wait for a human response. */ @@ -154,7 +154,7 @@ export interface ClaudeCodeRunSpec { readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Grace passed to the shared subprocess owner. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -259,10 +259,10 @@ export async function consumeClaudeQuery( } /** - * Close the official query, start managed-range termination, and wait for the - * subprocess owner to prove the range is empty. + * Close the official query, terminate the managed process tree, and wait for + * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. - * @param child - live shared-service handle that owns the CLI managed range; + * @param child - live shared-service handle that owns the CLI process tree; * spawn-failed handles settle at the startup boundary instead. */ export async function disposeClaudeCodeChild( @@ -454,30 +454,26 @@ export async function startClaudeCodeRun( ) requestCancel() if (child !== undefined && child.pid <= 0) { - let spawnError = thrown(error) - void child.done.catch((childError: unknown) => { spawnError = thrown(childError) }) - const cleanupErrors: Error[] = [] + let closeError: Error | undefined try { query?.close() } catch (disposeError: unknown) { - cleanupErrors.push(thrown(disposeError)) + closeError = thrown(disposeError) } - child.terminate() - try { - await child.waitForExit() - } catch (disposeError: unknown) { - cleanupErrors.push(thrown(disposeError)) - } - await Promise.resolve() - if (cleanupErrors.length > 0) { + let spawnError = thrown(error) + try { + await child.done + } catch (childError: unknown) { + spawnError = thrown(childError) + } + + if (closeError !== undefined) { const failure = startupFailure(spawnError) const cleanupFailure = new ClaudeCodeFailure({ stage: 'teardown', category: 'unknown', - }, cleanupErrors.length === 1 - ? cleanupErrors[0] - : new AggregateError(cleanupErrors, 'Claude Code teardown failures')) + }, closeError) const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 44455f3d65..3298e07037 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/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).toHaveBeenCalledOnce() - expect(failedSpawn.waitForExit).toHaveBeenCalledOnce() + expect(failedSpawn.terminate).not.toHaveBeenCalled() + expect(failedSpawn.waitForExit).not.toHaveBeenCalled() const failedSpawnAbort = new AbortController() const cancelledFailedSpawn = fakeChild({ @@ -1505,12 +1505,10 @@ describe('run publication, cancellation, and settlement', () => { expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() const failedSpawnCloseError = new Error('query close failed') - const failedSpawnWaitError = new Error('managed range wait failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ pid: -1, doneError: spawnError, - waitForExitError: failedSpawnWaitError, }) queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) @@ -1520,18 +1518,17 @@ describe('run publication, cancellation, and settlement', () => { ...unused.spec, spawn: () => failedSpawnWithCloseFailure.handle, }) - const failedWithCloseError = await failedWithCloseFailure.catch((error: unknown) => error) - expect(failedWithCloseError).toBeInstanceOf(AggregateError) - expect(String(failedWithCloseError)).toContain(expectedFailureDiagnostic('query-start', 'unknown')) - expect(String(failedWithCloseError)).not.toContain('spawn /sdk/claude EACCES') - const failures = (failedWithCloseError as AggregateError).errors as unknown[] - expect(failures[0]).toMatchObject({ cause: spawnError }) - const cleanupCause = errorCause(failures[1]) - expect(cleanupCause).toBeInstanceOf(AggregateError) - expect((cleanupCause as AggregateError).errors).toEqual([ - failedSpawnCloseError, - failedSpawnWaitError, - ]) + await expect(failedWithCloseFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedWithCloseFailure) + .rejects.not.toThrow('spawn /sdk/claude EACCES') + await expect(failedWithCloseFailure).rejects.toMatchObject({ + message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + errors: [ + expect.objectContaining({ cause: spawnError }), + expect.objectContaining({ cause: failedSpawnCloseError }), + ], + }) const cleanupError = new Error('live child cleanup failed') const constructionError = new Error( diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 6345a1581e..0f8ed31eab 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.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 packages/subagent/subagent-codex/README.md -README.md: a0ae6a2f614e20dce0b20220706f63b5a993a8cf -README.zh.md: 4562285158283ba239182642565f83479331e343 +README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 +README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index a0ae6a2f61..975f353b9f 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -6,7 +6,7 @@ This package registers a Profile-named Codex subagent provider whose default nam ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, invokes managed-range termination, waits for the range to empty, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. @@ -14,7 +14,7 @@ For command and file approvals, the unattended provider selects a non-approval d Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts. -`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the subprocess provider's termination procedure, waits for managed-range exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. +`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. ## Capabilities and context @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `providerName` | `codex` | Non-empty registry name on `ctx.subagents`; each mounted instance needs a unique value. | | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | -| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), supplied to subprocess termination and output draining; disposal then waits for managed-range exit. | +| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | | `permissionMode` value | `thread/start` fields | Native behavior | |---|---|---| diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 4562285158..2ea256afb3 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -6,7 +6,7 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、启动 managed-range 终止并等待该范围为空,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.zh.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 @@ -14,7 +14,7 @@ 本地取消会在结果竞态中胜出并映射为 `aborted`。对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start`、`turn` 或 `process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded` 仍映射为 `max-tokens`;其他任何远端中断或失败仍映射为 `error`,且该提供方不会产生 `refusal`。参与失败的权限决定会跟在结构化失败行之后。成功与本地取消都不附带这两类事实。 -`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用 subprocess provider 的终止过程,等待 managed range 退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 +`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 ## 能力与上下文 @@ -27,7 +27,7 @@ | `providerName` | `codex` | `ctx.subagents` 中的非空注册名称;每个已挂载实例都需要唯一值。 | | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | | `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | -| `disposeGraceMs` | `3000` | 提供给 subprocess 终止与输出排空的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待 managed range 退出。 | +| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.zh.md);随后资源释放会等待整棵进程树退出。 | | `permissionMode` 值 | `thread/start` 字段 | 原生行为 | |---|---|---| diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 6089a69d75..79fbdab078 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -43,7 +43,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server termination and output draining. */ + /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index 0c350fffa7..ec9a6302c4 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and managed-range ownership belongs to the subprocess service. + * service and process-tree ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 55382e7e55..9db5d7086c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Codex child lifecycle: spawn the real app-server through the * subprocess seam, publish only after initialization and ephemeral thread - * creation, flatten post-publication failures, and dispose to managed-range + * creation, flatten post-publication failures, and dispose to whole-tree * quiescence. * * @module @deepseek-ai/dsh-subagent-codex/run @@ -31,7 +31,7 @@ import { type CodexWireFailureFacts, } from './wire.ts' -/** Default subprocess termination and output-drain grace. */ +/** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 interface CodexPackageManifest { @@ -141,7 +141,7 @@ export interface CodexRunSpec { readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Grace passed to the shared subprocess owner. */ + /** Subprocess termination grace passed to the shared process-tree owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -177,10 +177,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { } /** - * Close the private wire, start managed-range termination, and wait for the - * subprocess owner to prove the range is empty. + * Close the private wire, terminate the managed process tree, and wait for the + * subprocess owner to prove it is gone. * @param wire - private app-server protocol connection. - * @param child - shared-service handle that owns the managed range. + * @param child - shared-service handle that owns the process tree. */ export async function disposeCodexChild( wire: CodexAppServerWire, @@ -188,32 +188,32 @@ export async function disposeCodexChild( ): Promise { wire.close() - const targetPublished = child.pid > 0 - let outcome: SubprocessOutcome | undefined - if (targetPublished) { + if (child.pid > 0) { + let outcome: SubprocessOutcome | undefined 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(() => {}) } /** diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index bdd5ee4aeb..38c93dee23 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1922,8 +1922,7 @@ describe('run lifecycle and quiescence', () => { await expect(asyncSpawnFailure) .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN') - expect(asyncSpawnFailureChild.terminate).toHaveBeenCalledOnce() - expect(asyncSpawnFailureChild.waitForExit).toHaveBeenCalledOnce() + expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled() const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) @@ -2297,7 +2296,7 @@ describe('disposeCodexChild', () => { .resolves.toBeUndefined() }) - it('asks an unpublished owner to settle after a spawn-level failure', async () => { + it('handles a spawn-level failure with no process tree', async () => { const child = fakeChild({ pid: -1, doneError: new Error('spawn failed'), @@ -2305,8 +2304,8 @@ describe('disposeCodexChild', () => { const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() - expect(child.terminate).toHaveBeenCalledOnce() - expect(child.waitForExit).toHaveBeenCalledOnce() + expect(child.terminate).not.toHaveBeenCalled() + expect(child.waitForExit).not.toHaveBeenCalled() }) it('reports tree-wait failure with safe teardown facts', async () => { diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index edaf0286ef..abb6dd50e7 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -5,7 +5,7 @@ * working-directory resolution (config override, else the delegating parent * session's workspace), the never-reject result settlement, and the standard * run-handle publication. Backends compose these with their own wire drivers; - * the process machinery itself (spawn, env scrub, managed-range teardown) + * the process machinery itself (spawn, env scrub, tree-scoped teardown) * belongs to the `dsh-subprocess` seam. * * @module @deepseek-ai/dsh-subagent/out-of-process diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index a240b04ac4..cb27192356 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: dfb5763d3ac867ddd5b941d5619bf88a2da73cda -README.zh.md: 3f0e7f930133468dcb83ef47e5ec35c67f44ce63 +README.md: 1b516c36d81a51fc2e0023d69f748cb4b46f36a4 +README.zh.md: cb83d0a9d417114f20e8fbd970f57c5d917f736f diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index dfb5763d3a..1b516c36d8 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -The shared process substrate for one execution world: executable lookup, fully specified provider-managed ranges with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared process substrate for one execution world: executable lookup, fully-specified managed child-process trees with raw or collected stdio, and one deep terminal-process primitive that owns PTY allocation, foreground groups, and provider-observable session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../shell/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../terminal/README.md), and [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: native and fallback managed ranges, bounded collection/spill, `node-pty`, foreground/session inspection, signalling, and terminate-and-join disposal | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | | [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 3f0e7f9301..cb83d0a9d4 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完整 provider-managed range,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和 provider 仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 +这里集中提供一个执行世界的共享进程基底:可执行文件查找、具有原始或收集式 stdio 的完全明确指定的受管子进程树,以及一项底层终端进程原语,负责 PTY 分配、前台进程组和提供方仍可观察到的会话成员清理。命令默认值补全、shell 语义、时限、协议分帧、就绪状态与呈现留在消费方:[bash 执行器](../shell/README.zh.md)、[LSP 主机](../lsp/README.zh.md)、[PTY shell 后端](../terminal/README.zh.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.zh.md)。参见 [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)。 | 包 | ctx 键 | 角色 | |---|---|---| | [`subprocess`](subprocess/README.zh.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | -| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:native 与 fallback managed range、有界收集/spill、`node-pty`、前台/会话检查、信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | | [`win32-process`](win32-process/README.zh.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名管道 stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 3289991e58..18ed126f0e 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 12604fa74cc645340ca79b5734c34c7027b988eb -README.zh.md: e484b98df4b8411a8b78d2710c97067ac5a2bec6 +README.md: af971f2f9706117d9a7be8fdeb29eea4d33f72e9 +README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 12604fa74c..af971f2f97 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. Windows creates a parent-owned kill-on-close Job; its runner opens that Job, connects the target side of private parent-owned stdio pipes, creates the target suspended, assigns it, and resumes it. The parent opens its own direct-process wait handle before releasing the runner, so raw pipe EOF and stdin lifetime follow the target-side handles rather than the runner process. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure, while the parent observes the Windows target exit separately from range lifetime; only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). @@ -14,7 +14,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes. -- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the parent-owned Windows Job receives immediate termination and its handle also closes with the host; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. The Windows runner is released as soon as the parent acquires direct-process observation; the Linux runner remains until the direct target result, while the OS-owned scope or parent-held Job persists for later descendants. After publication, Linux runner events and Windows direct-process state are polled asynchronously every 100 ms and 10 ms respectively, while Linux scope state is polled every 200 ms. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e484b98df4..e829dce28d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows 创建由 parent 持有的 kill-on-close Job;runner 打开该 Job、连接由 parent 持有的 private stdio pipe 的 target 端,以 suspended 状态创建目标、完成分配后再恢复。parent 会先打开自己的 direct-process wait handle,再释放 runner,因此 raw pipe EOF 与 stdin 生命周期取决于 target 端 handle,而不取决于 runner process。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 报告目标启动失败,而 parent 独立观察 Windows target exit,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 @@ -14,7 +14,7 @@ - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;parent-owned Windows Job 会被立即终止,其 handle 也会随 host 关闭;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 ## 模型体验 @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。Windows runner 会在 parent 取得 direct-process observation 后立即释放;Linux runner 保留到 direct target result,而后续 descendant 继续由 OS-owned scope 或 parent-held Job 管理。handle 发布后,Linux runner event 与 Windows direct-process state 分别每 100 ms 和 10 ms 异步轮询,Linux scope state 每 200 ms 轮询。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 14bd6c3bda..5d30fa50ba 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -94,13 +94,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const pending: Promise[] = [] for (const handle of this.live) { handle.terminate() - // 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)) + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } for (const terminal of this.terminals) { pending.push(terminal.terminate()) @@ -170,9 +165,10 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) - // Release ownership only once the managed range is empty, not at spawned- - // command settlement. A surviving helper remains owned until provider - // termination and observation reach quiescence. + // Release ownership only once the whole TREE is gone, not at direct-child + // settlement — a TERM-trapping helper that outlives the leader must stay + // owned so teardown can still escalate it. For the common no-survivor + // case waitForExit resolves immediately after settlement. const release = (): Promise => handle.waitForExit().then(() => { this.live.delete(handle) }) void handle.done.then(release, release).catch(() => {}) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index a6ca284a22..24f7bb947b 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/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 childEnv({ LC_ALL: 'C' }) + return { ...process.env, LC_ALL: 'C' } } function querySystemctl(command: string, args: readonly string[]): Promise { @@ -106,6 +106,7 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | undefined + private killConfirmed = false private killFailure: Error | undefined constructor( @@ -126,6 +127,7 @@ 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') { @@ -172,6 +174,9 @@ class SystemdScopeOwner implements BoundProcessOwner { return waitWithAbort(this.observation, signal) } + forcedOutcome(): { exitCode: null; signal: 'SIGKILL' } | undefined { + return this.killConfirmed ? { exitCode: null, signal: 'SIGKILL' } : undefined + } } /** @@ -213,15 +218,7 @@ export function launchLinuxScope( }) const closed = observeChildClose(child) const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child) - const result = runnerDirectResult(child, files, closed) + const result = runnerDirectResult(child, files, closed, () => owner.forcedOutcome()) cleanupAfterRunner(files, result.direct, closed) - return { - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, - pid: result.pid, - direct: result.direct, - closed, - owner, - } + return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index b37f66d137..945701dbe8 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -1,7 +1,6 @@ /** Minimal managed-range ownership bound to one ordinary subprocess handle. */ import type { ChildProcess } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' /** Platform owner used by termination and whole-range settlement. */ @@ -14,9 +13,7 @@ export interface BoundProcessOwner { /** Platform launch facts consumed by the common stdio and result lifecycle. */ export interface ManagedProcessLaunch { - stdin: Writable | null - stdout: Readable | null - stderr: Readable | null + child: ChildProcess pid: number direct: Promise closed: Promise diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index e4f98e3fb6..f09da5adca 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,14 +41,17 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. + * @param ipc - append a Node IPC channel for the Windows runner. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { - return [ +export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { + const stdio: StdioOptions = [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] + if (ipc) stdio.push('ipc') + return stdio } /** @@ -69,11 +72,6 @@ interface RunnerHandshake { events: RunnerEvent[] } -/** Startup-only result for launchers that observe the direct process elsewhere. */ -export type RunnerStartResult = - | { ok: true; pid: number; events: RunnerEvent[] } - | { ok: false; pid: -1; error: Error } - /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ function runnerExited(child: ChildProcess, pid: number): boolean { if (child.exitCode !== null || child.signalCode !== null) return true @@ -104,9 +102,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner const events = readRunnerEvents(files.eventsPath) const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') if (terminal?.type === 'started') return { pid: terminal.pid, events } - if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { - throw deserializeSpawnError(terminal.error) - } + if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') Atomics.wait(handshakeWait, 0, 0, 5) @@ -114,30 +110,11 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) } -/** - * Read only the native runner's startup result. - * @param child - native wrapper process. - * @param files - private request and result paths. - * @returns target pid after publication, otherwise the launch failure. - */ -export function runnerStart(child: ChildProcess, files: RunnerFiles): RunnerStartResult { - try { - const handshake = waitForRunnerHandshake(child, files) - return { ok: true, pid: handshake.pid, events: handshake.events } - } catch (error) { - cleanupRunnerFiles(files) - return { - ok: false, - pid: -1, - error: error as Error, - } - } -} - async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], closed: Promise, + missingResult?: () => SubprocessOutcome | undefined, ): Promise { let seen = 0 const wrapperState = { closed: false } @@ -154,6 +131,8 @@ 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) @@ -165,21 +144,28 @@ 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, + missingResult?: () => SubprocessOutcome | undefined, ): { pid: number direct: Promise } { - const start = runnerStart(child, files) - if (!start.ok) return { pid: -1, direct: Promise.resolve().then(() => { throw start.error }) } + let handshake: RunnerHandshake + try { + handshake = waitForRunnerHandshake(child, files) + } catch (error) { + cleanupRunnerFiles(files) + return { pid: -1, direct: Promise.resolve().then(() => { throw error }) } + } return { - pid: start.pid, - direct: waitForDirectResult(files, start.events, closed), + pid: handshake.pid, + direct: waitForDirectResult(files, handshake.events, closed, missingResult), } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 73762bf2dc..0eab839b9f 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -2,10 +2,9 @@ import { appendFileSync, - lstatSync, mkdtempSync, readFileSync, - rmdirSync, + rmSync, unlinkSync, writeFileSync, } from 'node:fs' @@ -219,18 +218,7 @@ export function deserializeSpawnError(serialized: SerializedSpawnError): Error { */ export function cleanupRunnerFiles(files: RunnerFiles): void { try { - 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) + rmSync(files.directory, { recursive: true, force: true }) } catch { // A crash residue remains private and is not reused by later spawns. } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 54d850081f..1636ce0fe5 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,15 +1,17 @@ /** Native managed-range runner for ordinary local subprocesses. */ import { spawn } from 'node:child_process' +import { closeSync } from 'node:fs' import { closeHandleChecked, + isJobEmpty, loadWin32ProcessBindings, - openNamedPipeForStdio, - openJobForAssignment, - spawnOrdinaryProcessInJob, + pollProcessExit, + spawnOrdinaryJobProcess, + terminateJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent, consumeRunnerRequest, @@ -20,73 +22,40 @@ import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = | { mode: 'probe-node' } | { mode: 'probe-win32' } - | { mode: 'node'; requestPath: string; eventsPath: string } - | { - mode: 'win32' - requestPath: string - eventsPath: string - jobName: string - stdinPipe?: string - stdoutPipe?: string - stderrPipe?: string - } + | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined - let jobName: string | undefined let requestPath: string | undefined let eventsPath: string | undefined - let stdinPipe: string | undefined - let stdoutPipe: string | undefined - let stderrPipe: string | undefined for (let index = 0; index < argv.length; index += 2) { const key = argv[index] const value = argv[index + 1] if (value === undefined) throw new Error(`subprocess runner missing value after ${String(key)}`) if (key === '--mode') mode = value - else if (key === '--job') jobName = value else if (key === '--request') requestPath = value else if (key === '--events') eventsPath = value - else if (key === '--stdin-pipe') stdinPipe = value - else if (key === '--stdout-pipe') stdoutPipe = value - else if (key === '--stderr-pipe') stderrPipe = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } if (mode === 'probe-node' || mode === 'probe-win32') return { mode } if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') - if (mode === 'win32') { - if (jobName === undefined || jobName.length === 0) throw new Error('subprocess runner requires a Windows Job name') - return { - mode, - requestPath, - eventsPath, - jobName, - ...stdinPipe === undefined ? {} : { stdinPipe }, - ...stdoutPipe === undefined ? {} : { stdoutPipe }, - ...stderrPipe === undefined ? {} : { stderrPipe }, - } - } return { mode, requestPath, eventsPath } } function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { - const serialized = serializeSpawnError(error) - const code = error instanceof Win32Error - ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 - ? 'ENOENT' - : error.win32Code === 5 - ? 'EPERM' - : error.win32Code === 193 - ? 'EFTYPE' - : 'UNKNOWN' - : serialized.code - if (code === undefined) return serialized + if (!(error instanceof Win32Error)) return serializeSpawnError(error) + const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? 'ENOENT' + : error.win32Code === 5 + ? 'EPERM' + : error.win32Code === 193 + ? 'EFTYPE' + : 'UNKNOWN' const program = request.argv[0] as string return { - ...serialized, - name: serialized.name, - message: `spawn ${program} ${code}: ${serialized.message}`, + name: 'Error', + message: `spawn ${program} ${code}: ${error.message}`, code, syscall: `spawn ${program}`, path: program, @@ -128,81 +97,78 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -function closeStdioHandles( - api: ReturnType, - handles: Array<{ handle: NativePtr; label: string }>, - reportFailure: boolean, -): void { - let failure: Error | undefined - for (const { handle, label } of handles.splice(0)) { +/** Release the runner's copies after the Windows target inherits its standard handles. */ +function releaseRunnerStdio(): void { + for (const fd of [0, 1, 2]) { try { - closeHandleChecked(api, handle, label) + closeSync(fd) } catch (error) { - failure ??= error instanceof Error ? error : new Error(String(error)) + if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error } } - if (reportFailure && failure !== undefined) throw failure } -async function runWin32( - request: RunnerRequest, - eventsPath: string, - jobName: string, - pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, -): Promise { +async function runWin32(request: RunnerRequest, eventsPath: string): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false - let targetCreationAttempted = false - const openedStdio: Array<{ handle: NativePtr; label: string }> = [] try { - if (!process.connected) throw new Error('Windows subprocess runner requires a parent IPC channel') - const released = new Promise((resolve) => { process.once('disconnect', resolve) }) - jobHandle = openJobForAssignment(api, jobName) - const stdio: ChildStdioHandles = {} - for (const [key, path] of [ - ['stdin', pipes.stdinPipe], - ['stdout', pipes.stdoutPipe], - ['stderr', pipes.stderrPipe], - ] as const) { - if (path === undefined) continue - const handle = openNamedPipeForStdio(api, path) - stdio[key] = handle - openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) + let spawned + try { + const [command, ...args] = request.argv + spawned = spawnOrdinaryJobProcess(api, { command: command as string, args, cwd: request.cwd }) + } catch (error) { + appendRunnerEvent(eventsPath, { type: 'spawn-error', error: win32SpawnError(error, request) }) + return } - // Node attributes an invalid cwd to the attempted target spawn rather - // than exposing the launcher's internal chdir operation. - targetCreationAttempted = true - process.chdir(request.cwd) - const [command, ...args] = request.argv - const spawned = spawnOrdinaryProcessInJob( - api, - { command: command as string, args, cwd: process.cwd() }, - jobHandle, - stdio, - ) processHandle = spawned.process + jobHandle = spawned.job targetStarted = true - closeStdioHandles(api, openedStdio, true) - closeHandleChecked(api, jobHandle, 'ordinary process Job assignment') - jobHandle = undefined + let terminationRequested = false + const terminate = (): void => { + if (terminationRequested || jobHandle === undefined) return + terminationRequested = true + terminateJob(api, jobHandle, 1) + } + process.on('message', (message: unknown) => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() + }) + process.on('disconnect', terminate) + releaseRunnerStdio() appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) - await released - const directProcess = processHandle - processHandle = undefined - closeHandleChecked(api, directProcess, 'ordinary direct process handoff') + + await new Promise((resolve, reject) => { + const timer = setInterval(() => { + try { + if (processHandle !== undefined) { + const exitCode = pollProcessExit(api, processHandle) + if (exitCode !== undefined) { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) + closeHandleChecked(api, processHandle, 'ordinary direct process') + processHandle = undefined + } + } + if (processHandle === undefined && jobHandle !== undefined && isJobEmpty(api, jobHandle)) { + closeHandleChecked(api, jobHandle, 'ordinary process Job') + jobHandle = undefined + clearInterval(timer) + resolve() + } + } catch (error) { + clearInterval(timer) + reject(error instanceof Error ? error : new Error(String(error))) + } + }, 10) + }) } catch (error) { appendRunnerEvent(eventsPath, { type: targetStarted ? 'runner-error' : 'spawn-error', - error: targetStarted || !targetCreationAttempted - ? serializeSpawnError(error) - : win32SpawnError(error, request), + error: targetStarted ? serializeSpawnError(error) : win32SpawnError(error, request), }) process.exitCode = 127 } finally { - closeStdioHandles(api, openedStdio, false) if (processHandle !== undefined) { try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } } @@ -223,7 +189,7 @@ async function main(): Promise { if (args.mode === 'node') runNode(request, args.eventsPath) else { try { - await runWin32(request, args.eventsPath, args.jobName, args) + await runWin32(request, args.eventsPath) } finally { if (process.connected) process.disconnect() } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index a4071c7a42..b579fb022a 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,6 +1,6 @@ /** - * Process plumbing for the local subprocess service: ordinary process launch - * with per-stream stdio dispositions, tail-keep collection with spill + * Process plumbing for the local subprocess service: detached process-tree + * spawn with per-stream stdio dispositions, tail-keep collection with spill * files, provider-owned range signalling, and common termination scheduling. * POSIX owners stage TERM before KILL; Windows owners terminate immediately. * This layer reacts to an abort signal; callers own deadlines, teardown @@ -420,7 +420,7 @@ function fallbackOwner( } /** - * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle. + * Bind platform launch facts to the existing stdio, outcome, abort, and escalation lifecycle. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. * @param launch - platform child streams, direct outcome, and managed-range owner. * @param internals - test-only spill-directory override. @@ -433,7 +433,7 @@ export function bindManagedProcess( ): LocalSubprocessHandle { validateSubprocessSpec(spec) const { spillDir } = prepareManagedProcessBinding(internals) - const { stdin, stdout, stderr } = launch + const child = launch.child const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => mode !== 'pipe' && mode !== 'inherit' @@ -447,14 +447,8 @@ export function bindManagedProcess( stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) return collector } - const stdoutCollector = collectStream(outMode, stdout, 'stdout') - const stderrCollector = collectStream(errMode, stderr, 'stderr') - const stopCollectors = (): void => { - if (stdoutCollector !== undefined) stdout?.destroy() - if (stderrCollector !== undefined) stderr?.destroy() - stdoutCollector?.seal() - stderrCollector?.seal() - } + const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') + const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: ReturnType | undefined let rangeExitObserved = false @@ -501,9 +495,9 @@ export function bindManagedProcess( // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. - if (typeof stdinMode === 'object' && stdin !== null) { - stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - stdin.end(stdinMode.data) + if (typeof stdinMode === 'object' && child.stdin !== null) { + child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + child.stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -515,7 +509,10 @@ 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. - stopCollectors() + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() resolve(outcome) } @@ -532,7 +529,8 @@ export function bindManagedProcess( if (settled) return settled = true terminate() - stopCollectors() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() reject(error instanceof Error ? error : new Error(String(error))) }) @@ -541,8 +539,8 @@ export function bindManagedProcess( if (directOutcome !== undefined) settle(directOutcome) }) function cleanup(): void { - // graceTimer deliberately NOT cleared: the forced termination call must - // still reach range survivors after the spawned command settles. + // graceTimer deliberately NOT cleared: the SIGKILL escalation must be + // able to reach tree survivors after the direct child settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) } }) @@ -554,11 +552,10 @@ export function bindManagedProcess( return { pid: launch.pid, - /* v8 ignore start -- pipe-mode streams exist on every conforming launch; - the null-coalesces guard an internal adapter defect only. */ - stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, - stdout: outMode === 'pipe' ? stdout ?? undefined : undefined, - stderr: errMode === 'pipe' ? stderr ?? undefined : undefined, + /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ + stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, /* v8 ignore stop */ collected: { ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, @@ -603,13 +600,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, direct, ) - return bindManagedProcess(spec, { - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, - pid, - direct, - closed, - owner, - }, binding) + return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 88f3497a72..3de46915da 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,140 +1,23 @@ /** Windows Job runner launch and managed-range ownership. */ import { spawn, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import type { Readable } from 'node:stream' -import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { - closeHandleChecked, - createKillOnCloseJob, - isJobEmpty, - loadWin32ProcessBindings, - openProcessForWait, - pollProcessExit, - terminateJob, -} from '@deepseek-ai/dsh-win32-process' -import type { NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { waitWithAbort } from './managed-owner.ts' +import { observeChildClose, waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { + cleanupAfterRunner, + runnerDirectResult, runnerFiles, - runnerStart, + runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' -import { cleanupRunnerFiles } from './runner-protocol.ts' -import { createWindowsStdioBridge } from './windows-stdio.ts' - -const JOB_POLL_INTERVAL_MS = 10 -const PROCESS_POLL_INTERVAL_MS = 10 - -/** Parent-side operations for one Windows managed launch. */ -export interface WindowsProcessOperations { - create(name: string): NativePtr - openProcess(pid: number): NativePtr - pollProcess(process: NativePtr): number | undefined - empty(job: NativePtr): boolean - terminate(job: NativePtr): void - closeJob(job: NativePtr): void - closeProcess(process: NativePtr): void -} - -function nativeProcessOperations(): WindowsProcessOperations { - const api = loadWin32ProcessBindings() - return { - create: name => createKillOnCloseJob(api, name), - openProcess: pid => openProcessForWait(api, pid), - pollProcess: process => pollProcessExit(api, process), - empty: job => isJobEmpty(api, job), - terminate: (job) => { terminateJob(api, job, 1) }, - closeJob: (job) => { closeHandleChecked(api, job, 'ordinary process Job') }, - closeProcess: (process) => { closeHandleChecked(api, process, 'ordinary direct process') }, - } -} - -function releaseRunner(child: ReturnType): Error | undefined { - if (!child.connected) return undefined - try { - child.disconnect() - return undefined - } catch (error) { - try { child.kill() } catch { /* The direct process and Job remain parent-owned. */ } - return error instanceof Error ? error : new Error(String(error)) - } -} - -function stopFailedRunner(child: ReturnType): void { - if (child.connected) { - try { child.disconnect() } catch { /* A forced stop below remains authoritative. */ } - } - try { child.kill() } catch { /* The parent-owned Job remains responsible for any target. */ } -} - -function observeRunnerExit(child: ReturnType): Promise { - return new Promise((resolve) => { - child.once('error', () => { resolve() }) - child.once('exit', () => { resolve() }) - }) -} - -function observeCollectedStream( - mode: SubprocessSpawnSpec['stdio']['stdout'], - stream: Readable | null | undefined, -): Promise { - if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined - || stream.readableEnded || stream.destroyed) { - return Promise.resolve() - } - return new Promise((resolve) => { - const settle = (): void => { - stream.off('end', settle) - stream.off('close', settle) - stream.off('error', settle) - resolve() - } - stream.once('end', settle) - stream.once('close', settle) - stream.once('error', settle) - }) -} /** Test seams for the runner process. */ export interface WindowsJobInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync runnerInvocation?: string[] - operations?: WindowsProcessOperations -} - -function observeDirectProcess( - pid: number, - operations: WindowsProcessOperations, -): Promise { - const processHandle = operations.openProcess(pid) - let closed = false - const close = (): void => { - if (closed) return - operations.closeProcess(processHandle) - closed = true - } - return new Promise((resolve, reject) => { - const poll = (): void => { - try { - const exitCode = operations.pollProcess(processHandle) - if (exitCode === undefined) { - setTimeout(poll, PROCESS_POLL_INTERVAL_MS) - return - } - close() - resolve({ exitCode, signal: null }) - } catch (error) { - try { close() } catch { /* Preserve the observation failure. */ } - reject(error instanceof Error ? error : new Error(String(error))) - } - } - poll() - }) } /** @@ -156,60 +39,42 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private stopped = false - private closed = false - private terminationRequested = false - private terminationFailure: Error | undefined - private observation: Promise | undefined + private readonly observation: Promise - constructor( - private readonly job: NativePtr, - private readonly operations: WindowsProcessOperations, - private readonly runnerExited: Promise, - ) {} + constructor(private readonly runner: ReturnType) { + this.observation = new Promise((resolve) => { + runner.once('close', () => { + this.stopped = true + resolve() + }) + }) + } signal(_signal: NodeJS.Signals): void { - if (this.stopped || this.terminationRequested) return - this.terminationRequested = true + if (this.stopped) return try { - this.operations.terminate(this.job) - } catch (error) { - this.terminationFailure = error instanceof Error ? error : new Error(String(error)) + if (this.runner.connected) { + this.runner.send({ type: 'terminate' }, (error) => { + if (error !== null) this.runner.kill() + }) + } else { + this.runner.kill() + } + } catch { + this.runner.kill() } } waitForExit(signal?: AbortSignal): Promise { - if (this.observation !== undefined) return waitWithAbort(this.observation, signal) - if (this.stopped) return Promise.resolve(true) - this.observation = (async () => { - try { - while (!this.operations.empty(this.job)) { - if (this.terminationFailure !== undefined) throw this.terminationFailure - await sleepMs(JOB_POLL_INTERVAL_MS) - } - this.stopped = true - this.close() - await this.runnerExited - } catch (error) { - this.stopped = true - try { this.close() } catch { /* Preserve the observation failure. */ } - throw error - } - })() - return waitWithAbort(this.observation, signal) - } - - private close(): void { - if (this.closed) return - this.operations.closeJob(this.job) - this.closed = true + return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal) } } /** - * Launch one direct command through a runner into a parent-owned Job. + * Launch one direct command through the Job-owning runner. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. * @param internals - injected process runner used by tests. - * @returns parent-owned streams, target outcome, and the bound Job owner. + * @returns wrapper streams, target outcome, and the bound Job owner. */ export function launchWindowsJob( spec: SubprocessSpawnSpec, @@ -219,98 +84,22 @@ export function launchWindowsJob( const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') - /* v8 ignore next -- the native Windows suite exercises the real Job operations. */ - const operations = internals.operations ?? nativeProcessOperations() - const launchId = randomUUID() - const jobName = `Local\\dsh-subprocess-${launchId}` const files = runnerFiles(spec) - let job: NativePtr - try { - job = operations.create(jobName) - } catch (error) { - cleanupRunnerFiles(files) - throw error - } - let stdio: ReturnType - try { - stdio = createWindowsStdioBridge(spec, `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${launchId}`) - } catch (error) { - try { operations.closeJob(job) } catch { /* Preserve the stdio setup failure. */ } - cleanupRunnerFiles(files) - throw error - } - let child: ReturnType - try { - child = run(command, [ - ...prefix, - '--mode', - 'win32', - '--job', - jobName, - '--request', - files.requestPath, - '--events', - files.eventsPath, - ...stdio.runnerArgs, - ], { - env: childEnv(), - stdio: stdio.runnerStdio, - }) - } catch (error) { - try { operations.closeJob(job) } catch { /* Preserve the launch failure. */ } - stdio.dispose() - cleanupRunnerFiles(files) - throw error - } - const runnerExited = observeRunnerExit(child) - const closed = Promise.all([ - runnerExited, - observeCollectedStream(spec.stdio.stdout, stdio.stdout), - observeCollectedStream(spec.stdio.stderr, stdio.stderr), - ]).then(() => undefined) - const owner = new WindowsJobOwner(job, operations, runnerExited) - const start = runnerStart(child, files) - if (!start.ok) { - stopFailedRunner(child) - stdio.dispose() - const direct = Promise.resolve().then(() => { throw start.error }) - return { - stdin: stdio.stdin, - stdout: stdio.stdout, - stderr: stdio.stderr, - pid: start.pid, - direct, - closed, - owner, - } - } - // The launcher retains its original process handle until this process opens - // an independent one, preventing PID reuse during the ownership handoff. - // Windows direct settlement is owned by the handle below, not by continued - // runner event polling after the startup handoff. - let direct: Promise - try { - direct = observeDirectProcess(start.pid, operations) - } catch (error) { - direct = Promise.resolve().then(() => { throw error }) - } - const releaseFailure = releaseRunner(child) - if (releaseFailure !== undefined) { - void direct.catch(() => {}) - direct = Promise.resolve().then(() => { throw releaseFailure }) - } - void direct.then( - () => { stdio.closeInput() }, - () => { stdio.closeInput() }, - ) - void runnerExited.then(() => { cleanupRunnerFiles(files) }) - return { - stdin: stdio.stdin, - stdout: stdio.stdout, - stderr: stdio.stderr, - pid: start.pid, - direct, - closed, - owner, - } + const child = run(command, [ + ...prefix, + '--mode', + 'win32', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec, true), + }) + const closed = observeChildClose(child) + const owner = new WindowsJobOwner(child) + const result = runnerDirectResult(child, files, closed) + cleanupAfterRunner(files, result.direct, closed) + return { child, pid: result.pid, direct: result.direct, closed, owner } } diff --git a/packages/subprocess/subprocess-local/src/windows-stdio.ts b/packages/subprocess/subprocess-local/src/windows-stdio.ts deleted file mode 100644 index 912fcb8a8e..0000000000 --- a/packages/subprocess/subprocess-local/src/windows-stdio.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** Parent-owned named-pipe streams for one Windows native launch. */ - -import { createServer } from 'node:net' -import type { Server, Socket } from 'node:net' -import { PassThrough } from 'node:stream' -import type { Readable, Writable } from 'node:stream' -import type { StdioOptions } from 'node:child_process' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' - -interface PipeEndpoint { - readonly path: string - readonly stream: PassThrough - dispose(): void -} - -/** Streams and runner arguments for one Windows launch. */ -export interface WindowsStdioBridge { - readonly stdin: Writable | null - readonly stdout: Readable | null - readonly stderr: Readable | null - readonly runnerArgs: string[] - readonly runnerStdio: StdioOptions - closeInput(): void - dispose(): void -} - -function closeServer(server: Server): void { - try { - server.close() - } catch { - // A listen failure or an already-accepted connection can close first. - } -} - -function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint { - const stream = new PassThrough() - let socket: Socket | undefined - let disposed = false - const server = createServer({ allowHalfOpen: true }) - // Direct-result failure remains authoritative for setup errors. Keep the - // stream error observable without allowing an early server failure to become - // an unhandled process-level exception before bindManagedProcess attaches. - stream.on('error', () => {}) - server.once('error', (error) => { stream.destroy(error) }) - server.once('connection', (connection) => { - if (disposed) { - connection.destroy() - return - } - socket = connection - closeServer(server) - connection.once('error', (error) => { stream.destroy(error) }) - stream.once('close', () => { connection.destroy() }) - if (direction === 'output') { - connection.once('end', () => { connection.end() }) - connection.pipe(stream) - } else { - connection.resume() - stream.pipe(connection) - connection.once('end', () => { - stream.unpipe(connection) - connection.end() - stream.destroy() - }) - connection.once('close', () => { stream.destroy() }) - } - }) - try { - server.listen(path) - } catch (error) { - stream.destroy() - closeServer(server) - throw error - } - return { - path, - stream, - dispose() { - disposed = true - closeServer(server) - socket?.destroy() - stream.destroy() - }, - } -} - -/** - * Create private parent-owned streams whose peer handles are opened by the Windows runner. - * @param spec - target stdio dispositions. - * @param basePath - unique named-pipe base chosen by the launch owner. - * @returns public streams, runner arguments, and cleanup for pre-start failure. - */ -export function createWindowsStdioBridge( - spec: SubprocessSpawnSpec, - basePath: string, -): WindowsStdioBridge { - const endpoints: PipeEndpoint[] = [] - let stdin: PipeEndpoint | undefined - let stdout: PipeEndpoint | undefined - let stderr: PipeEndpoint | undefined - try { - if (spec.stdio.stdin !== 'ignore') { - stdin = createEndpoint(`${basePath}-stdin`, 'input') - endpoints.push(stdin) - } - if (spec.stdio.stdout !== 'inherit') { - stdout = createEndpoint(`${basePath}-stdout`, 'output') - endpoints.push(stdout) - } - if (spec.stdio.stderr !== 'inherit') { - stderr = createEndpoint(`${basePath}-stderr`, 'output') - endpoints.push(stderr) - } - } catch (error) { - for (const endpoint of endpoints) endpoint.dispose() - throw error - } - return { - stdin: stdin?.stream ?? null, - stdout: stdout?.stream ?? null, - stderr: stderr?.stream ?? null, - runnerArgs: [ - ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path], - ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path], - ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path], - ], - runnerStdio: [ - 'ignore', - spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore', - spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore', - 'ipc', - ], - closeInput() { - stdin?.dispose() - }, - dispose() { - for (const endpoint of endpoints) endpoint.dispose() - }, - } -} diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index c1ee1c07dc..c3971b5077 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -7,14 +7,24 @@ const request = consumeRunnerRequest(requestPath) appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) const configuredExit = Number(request.argv[1]) -// Events carry target results; zero means the runner completed its own work. if (Number.isSafeInteger(configuredExit)) { - const finish = (): void => { + setTimeout(() => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exit(0) - } - if (process.connected) process.once('disconnect', finish) - else setTimeout(finish, 10) + process.exitCode = configuredExit + }, 10) } else { - setInterval(() => {}, 1_000) + const hold = setInterval(() => {}, 1_000) + let terminated = false + const terminate = (): void => { + if (terminated) return + terminated = true + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) + clearInterval(hold) + if (process.connected) process.disconnect() + process.exitCode = 1 + } + process.on('message', (message: unknown) => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() + }) + process.on('disconnect', terminate) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 714b82053d..ffbc047518 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -28,9 +28,6 @@ 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 = [] const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { @@ -39,24 +36,18 @@ 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'] - 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 - } + 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') const oldSystemd = vi.fn((command: string) => ({ status: command === 'systemctl' ? 0 : 1, @@ -113,7 +104,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 without fabricating a direct result', async () => { + it('still escalates after a missing-unit TERM response and uses the authoritative scope KILL', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') @@ -142,7 +133,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) launch.owner.signal('SIGTERM') launch.owner.signal('SIGKILL') - await expect(launch.direct).rejects.toThrow('exited without a direct-command result') + await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(launch.owner.waitForExit()).resolves.toBe(true) }) @@ -264,7 +255,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }), runnerInvocation: spawnRunnerInvocation(), }) - expect(launch.pid).toBe(-1) + expect(launch.child.pid).toBeUndefined() await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBe(true) await launch.closed diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 37bcae6866..e7e24ca07b 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -87,41 +87,6 @@ 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 - terminate(): void - terminateForHostExit(): void - waitForExit(): Promise - }> - }).live - live.add({ - done: new Promise(() => {}), - terminate, - terminateForHostExit, - waitForExit: async () => { throw rangeFailure }, - }) - - await expect(Promise.race([ - fiber.dispose().then(() => 'disposed'), - new Promise(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() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 3e01cf8ca8..4ea1581fa3 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -62,9 +62,7 @@ describe('managed process binding', () => { }, } const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: 4242, direct: direct.promise, closed: observeChildClose(wrapper), @@ -88,9 +86,7 @@ describe('managed process binding', () => { const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), closed: observeChildClose(wrapper), @@ -106,9 +102,7 @@ describe('managed process binding', () => { }) const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), @@ -132,9 +126,7 @@ describe('managed process binding', () => { ...spec(1_000), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: new Promise(() => {}), @@ -158,9 +150,7 @@ describe('managed process binding', () => { }) const failure = new Error('range observation failed') const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: new Promise(() => {}), closed: new Promise(() => {}), @@ -183,9 +173,7 @@ describe('managed process binding', () => { const direct = Promise.resolve().then(() => { throw rejection }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct, closed: new Promise(() => {}), @@ -194,8 +182,6 @@ 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') } @@ -214,9 +200,7 @@ describe('managed process binding', () => { }) const controller = new AbortController() const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, + child: wrapper, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index ab28d1affc..c89b46581a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -55,16 +55,14 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } -type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } - -function directSpawnFailure(argv: string[], cwd = scratch): Promise { +function directSpawnFailure(argv: string[]): Promise { return new Promise((resolve, reject) => { try { - const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' }) + const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) child.once('error', resolve) child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) } catch (error) { - resolve(error as SpawnFailure) + resolve(error as NodeJS.ErrnoException) } }) } @@ -72,60 +70,6 @@ function directSpawnFailure(argv: string[], cwd = scratch): Promise { - it('keeps raw stdin writable after the launcher handoff', async () => { - const output = join(scratch, `stdin-${Date.now()}.txt`) - const script = ` - const { writeFileSync } = require('node:fs') - let input = '' - process.stdin.setEncoding('utf8') - process.stdin.on('data', chunk => { input += chunk }) - process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) }) - ` - const request = { - ...spec([process.execPath, '-e', script]), - stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const, - } - const handle = bindManagedProcess(request, launchWindowsJob(request)) - if (handle.stdin === undefined) throw new Error('expected piped stdin') - await new Promise(resolve => setTimeout(resolve, 100)) - await new Promise((resolve, reject) => { - handle.stdin?.end('after-handoff', (error?: Error | null) => { - if (error !== undefined && error !== null) reject(error) - else resolve() - }) - }) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(handle.waitForExit()).resolves.toBe(true) - expect(readFileSync(output, 'utf8')).toBe('after-handoff') - }) - - it('releases raw stdout when the target closes it before exiting', async () => { - const request = { - ...spec([process.execPath, '-e', 'process.stdout.end(); setInterval(() => {}, 1000)']), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, - } - const handle = bindManagedProcess(request, launchWindowsJob(request)) - if (handle.stdout === undefined) throw new Error('expected piped stdout') - const stdoutEnded = new Promise((resolve, reject) => { - handle.stdout?.once('end', resolve) - handle.stdout?.once('error', reject) - }) - handle.stdout.resume() - let directSettled = false - void handle.done.then( - () => { directSettled = true }, - () => { directSettled = true }, - ) - await expect(Promise.race([ - stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), - ])).resolves.toBe(true) - expect(directSettled).toBe(false) - handle.terminate() - await handle.done - await expect(handle.waitForExit()).resolves.toBe(true) - }) - it('terminates the direct target and its default-inheritance descendant', async () => { const pidFile = join(scratch, `job-child-${Date.now()}.pid`) const script = ` @@ -170,14 +114,12 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { handle.stdout?.once('end', resolve) handle.stdout?.once('error', reject) }) - // A Readable reports `end` only after the consumer drains any buffered bytes. - handle.stdout.resume() const descendant = await waitForPid(pidFile) try { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(Promise.race([ stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), + new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, @@ -194,30 +136,9 @@ 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' }) - await expect(missingHandle.waitForExit()).resolves.toBe(true) - - const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) - const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] - const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) - const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } - const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd)) - await expect(invalidCwdHandle.done).rejects.toMatchObject({ - code: expectedCwd.code, - syscall: expectedCwd.syscall, - path: expectedCwd.path, - spawnargs: expectedCwd.spawnargs, - }) - await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true) const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) writeFileSync(invalidExecutable, 'not a Windows executable\r\n') @@ -225,6 +146,5 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) - await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 335dbd4cda..c54d831c43 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, statSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' @@ -85,11 +84,11 @@ describe('spawn runner transport', () => { expect(result.status).toBe(0) }) - it('maps every target stdio disposition', () => { + it('maps every target stdio disposition and optional IPC channel', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, - }))).toEqual(['pipe', 'inherit', 'inherit']) + }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc']) }) it('materializes and consumes the exact runner request once', () => { @@ -110,35 +109,6 @@ 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('contains an unexpected owned-path cleanup failure', () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - rmSync(files.requestPath, { force: true }) - mkdirSync(files.requestPath) - try { - expect(() => { cleanupRunnerFiles(files) }).not.toThrow() - expect(existsSync(files.directory)).toBe(true) - } finally { - rmSync(files.directory, { recursive: true, force: true }) - } - }) - it.each([ ['non-object request', null, 'no executable'], ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'], @@ -290,20 +260,6 @@ describe('spawn runner transport', () => { cleanupRunnerFiles(runnerFailure) } - const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) - appendRunnerEvent(afterStartFailure.eventsPath, { - type: 'runner-error', - error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, - }) - const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) - expect(result.pid).toBe(456) - await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) - } finally { - cleanupRunnerFiles(afterStartFailure) - } - const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) @@ -314,6 +270,19 @@ 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 () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index c125393115..0d69a52331 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -4,10 +4,8 @@ import { EventEmitter } from 'node:events' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type { NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent } from '../src/runner-protocol.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' -import type { WindowsProcessOperations } from '../src/windows-job.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) const invocation = [process.execPath, '--import', 'tsx/esm', fixture] @@ -16,53 +14,11 @@ function spec(argv: string[]): SubprocessSpawnSpec { return { argv, cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 100, } } -function fakeRunner(pid: number): { child: ChildProcess; disconnect: ReturnType } { - const child = new EventEmitter() as ChildProcess - const disconnect = vi.fn(() => { - Object.assign(child, { connected: false }) - queueMicrotask(() => { - child.emit('exit', 0, null) - child.emit('close', 0, null) - }) - }) - Object.assign(child, { pid, connected: true, disconnect, kill: vi.fn(() => true) }) - return { child, disconnect } -} - -function processOperations(overrides: Partial = {}): { - operations: WindowsProcessOperations - create: ReturnType - openProcess: ReturnType - pollProcess: ReturnType - empty: ReturnType - terminate: ReturnType - closeJob: ReturnType - closeProcess: ReturnType -} { - const create = vi.fn(() => 50n as NativePtr) - const openProcess = vi.fn(() => 60n as NativePtr) - const pollProcess = vi.fn(() => 0) - const empty = vi.fn(() => true) - const terminate = vi.fn() - const closeJob = vi.fn() - const closeProcess = vi.fn() - return { - operations: { create, openProcess, pollProcess, empty, terminate, closeJob, closeProcess, ...overrides }, - create, - openProcess, - pollProcess, - empty, - terminate, - closeJob, - closeProcess, - } -} - describe('Windows Job runner adapter', () => { it('probes the runner before a user command is selected', () => { const runSync = vi.fn(() => ({ status: 0, error: undefined })) as unknown as typeof spawnSync @@ -84,264 +40,126 @@ describe('Windows Job runner adapter', () => { }) it('reports direct outcome separately from runner settlement', async () => { - const jobs = processOperations({ pollProcess: vi.fn(() => 7) }) - const request = { - ...spec(['fake-target', '7']), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' } as const, - } - const launch = launchWindowsJob(request, { + const launch = launchWindowsJob(spec(['fake-target', '7']), { spawn, runnerInvocation: invocation, - operations: jobs.operations, }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(jobs.create).toHaveBeenCalledOnce() - expect(jobs.create.mock.calls[0]?.[0]).toMatch(/^Local\\dsh-subprocess-/u) - expect(jobs.openProcess).toHaveBeenCalledWith(launch.pid) - expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) - it('signals and waits through the parent-owned Job', async () => { - const { child, disconnect } = fakeRunner(321) - let eventsPath = '' - const state = { empty: false, exitCode: undefined as number | undefined } - const terminate = vi.fn(() => { - state.empty = true - state.exitCode = 1 - }) - const jobs = processOperations({ - pollProcess: vi.fn(() => state.exitCode), - empty: vi.fn(() => state.empty), - terminate, - }) - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) - return child - }) as unknown as typeof spawn + it('signals the Job runner and waits for its managed range to stop', async () => { const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, + spawn, + runnerInvocation: invocation, }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBe(true) launch.owner.signal('SIGKILL') - expect(disconnect).toHaveBeenCalledOnce() - expect(terminate).toHaveBeenCalledExactlyOnceWith(50n) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) }) - it('does not treat runner exit as proof that the Job is empty', async () => { - const { child, disconnect } = fakeRunner(432) - let eventsPath = '' - const state = { empty: false, exitCode: undefined as number | undefined } - const jobs = processOperations({ - pollProcess: vi.fn(() => state.exitCode), - empty: vi.fn(() => state.empty), - }) - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.owner.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) - state.exitCode = 0 - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - state.empty = true - await expect(launch.owner.waitForExit()).resolves.toBe(true) - launch.owner.signal('SIGKILL') - expect(disconnect).toHaveBeenCalledOnce() - expect(jobs.terminate).not.toHaveBeenCalled() - }) + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { + for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { + if (mode === 'throw') throw new Error('send threw') + callback(mode === 'callback-error' ? new Error('send failed') : null) + return true + }) + Object.assign(child, { + pid: 321, + connected: mode !== 'disconnected', + kill, + send, + }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - it('reports Job termination failures through waitForExit', async () => { - const { child } = fakeRunner(654) + launch.owner.signal('SIGTERM') + if (mode === 'callback-error' || mode === 'throw' || mode === 'disconnected') { + expect(kill).toHaveBeenCalledOnce() + } + if (mode === 'disconnected') expect(send).not.toHaveBeenCalled() + + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + const sends = send.mock.calls.length + const kills = kill.mock.calls.length + launch.owner.signal('SIGKILL') + expect(send).toHaveBeenCalledTimes(sends) + expect(kill).toHaveBeenCalledTimes(kills) + } + + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { + callback(null) + return true + }) + Object.assign(child, { pid: 654, connected: true, kill, send }) let eventsPath = '' const run = vi.fn((_command: string, args: readonly string[]) => { eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 654 }) return child }) as unknown as typeof spawn - const failure = new Error('TerminateJobObject failed') - const jobs = processOperations({ empty: vi.fn(() => false), terminate: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - void launch.direct.catch(() => {}) + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) launch.owner.signal('SIGTERM') - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) + expect(send).toHaveBeenCalledOnce() + expect(kill).not.toHaveBeenCalled() + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await launch.direct + await launch.owner.waitForExit() }) - it('keeps a Job observation failure visible on repeated waits', async () => { - const { child } = fakeRunner(655) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 655 }) - return child - }) as unknown as typeof spawn - const failure = new Error('QueryInformationJobObject failed') - const jobs = processOperations({ empty: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) - }) - - it('reports direct-process observation failure and closes its handle', async () => { - const { child } = fakeRunner(656) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 656 }) - return child - }) as unknown as typeof spawn - const failure = new Error('WaitForSingleObject failed') - const jobs = processOperations({ pollProcess: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(jobs.closeProcess).toHaveBeenCalledExactlyOnceWith(60n) - }) - - it('releases the runner when the parent cannot open the direct process', async () => { - const { child, disconnect } = fakeRunner(659) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 659 }) - return child - }) as unknown as typeof spawn - const failure = new Error('OpenProcess failed') - const jobs = processOperations({ openProcess: vi.fn(() => { throw failure }) }) - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(jobs.closeProcess).not.toHaveBeenCalled() - }) - - it('reports a failed runner release after acquiring direct-process observation', async () => { - const child = new EventEmitter() as ChildProcess - const failure = new Error('IPC disconnect failed') - const kill = vi.fn(() => { - queueMicrotask(() => { child.emit('exit', 0, null) }) - return true - }) - Object.assign(child, { - pid: 657, - connected: true, - disconnect: vi.fn(() => { throw failure }), - kill, - }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 657 }) - return child - }) as unknown as typeof spawn - const jobs = processOperations() - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(kill).toHaveBeenCalledOnce() - }) - - it('closes the parent Job when spawning the runner throws synchronously', () => { - const failure = new Error('runner spawn failed') - const jobs = processOperations() - expect(() => launchWindowsJob(spec(['fake-target']), { - spawn: vi.fn(() => { throw failure }) as unknown as typeof spawn, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - })).toThrow(failure) - expect(jobs.closeJob).toHaveBeenCalledExactlyOnceWith(50n) - }) - - it('force-stops a runner that times out before publishing target startup', async () => { - const child = new EventEmitter() as ChildProcess - const disconnect = vi.fn(() => { Object.assign(child, { connected: false }) }) - const kill = vi.fn(() => { - queueMicrotask(() => { child.emit('exit', 1, null) }) - return true - }) - Object.assign(child, { pid: process.pid, exitCode: null, signalCode: null, connected: true, disconnect, kill }) - const jobs = processOperations() - const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) - try { - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: vi.fn(() => child) as unknown as typeof spawn, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, - }) - await expect(launch.direct).rejects.toThrow('did not report target start') - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(kill).toHaveBeenCalledOnce() - } finally { - now.mockRestore() - } - }) - - it('passes a generated Job name to the runner and rejects an empty invocation', async () => { - const emptyJobs = processOperations() - expect(() => launchWindowsJob(spec(['fake-target']), { - runnerInvocation: [], - operations: emptyJobs.operations, - })) + it('uses production runner defaults and rejects an empty invocation', async () => { + expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] })) .toThrow('Windows runner invocation is empty') - expect(emptyJobs.create).not.toHaveBeenCalled() - const { child, disconnect } = fakeRunner(987) + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + pid: 987, + connected: true, + kill: vi.fn(() => true), + send: vi.fn(), + }) let eventsPath = '' - let jobName = '' const run = vi.fn((_command: string, args: readonly string[]) => { - jobName = args[args.indexOf('--job') + 1] as string eventsPath = args[args.indexOf('--events') + 1] as string appendRunnerEvent(eventsPath, { type: 'started', pid: 987 }) return child - }) as unknown as typeof spawn - const jobs = processOperations() - const launch = launchWindowsJob(spec(['fake-target']), { - spawn: run, - runnerInvocation: ['fake-runner'], - operations: jobs.operations, }) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - expect(disconnect).toHaveBeenCalledOnce() - expect(jobName).toMatch(/^Local\\dsh-subprocess-/u) - expect(jobs.create).toHaveBeenCalledExactlyOnceWith(jobName) + const runSync = vi.fn(() => ({ status: 0, error: undefined })) + vi.resetModules() + vi.doMock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn: run, + spawnSync: runSync, + })) + try { + const defaults = await import('../src/windows-job.ts') + expect(defaults.probeWindowsJob()).toBe(true) + const launch = defaults.launchWindowsJob(spec(['fake-target'])) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(runSync).toHaveBeenCalledOnce() + } finally { + vi.doUnmock('node:child_process') + vi.resetModules() + } }) }) diff --git a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts deleted file mode 100644 index 99df24b0d6..0000000000 --- a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { spawnSync } from 'node:child_process' -import { once } from 'node:events' -import { connect } from 'node:net' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { createWindowsStdioBridge } from '../src/windows-stdio.ts' - -function pipeBase(): string { - return process.platform === 'win32' - ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}` - : join('/tmp', `dsh-windows-stdio-${randomUUID()}`) -} - -function spec(): SubprocessSpawnSpec { - return { - argv: ['target'], - cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } }, - graceMs: 100, - } -} - -function pathAfter(args: readonly string[], key: string): string { - const path = args[args.indexOf(key) + 1] - if (path === undefined) throw new Error(`missing ${key}`) - return path -} - -describe('Windows parent-owned stdio bridge', () => { - it('binds before returning so a synchronously launched peer can connect', async () => { - const bridge = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, - }, pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const result = spawnSync(process.execPath, ['-e', ` - const { connect } = require('node:net') - const socket = connect(${JSON.stringify(stdoutPath)}) - socket.once('connect', () => { - socket.write('blocked-parent', () => { - socket.destroy() - process.exit(0) - }) - }) - socket.once('error', () => { process.exit(1) }) - setTimeout(() => { process.exit(2) }, 2000) - `], { timeout: 5_000 }) - expect(result.status).toBe(0) - - const chunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - await once(bridge.stdout as NodeJS.ReadableStream, 'end') - expect(Buffer.concat(chunks).toString()).toBe('blocked-parent') - bridge.dispose() - }) - - it('moves bytes in both directions and ends output with its target-side peer', async () => { - const bridge = createWindowsStdioBridge(spec(), pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe') - const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe') - expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc']) - await new Promise(resolve => setImmediate(resolve)) - - bridge.stdin?.end('in') - const stdoutPeer = connect(stdoutPath) - const stderrPeer = connect(stderrPath) - const stdinPeer = connect(stdinPath) - await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')]) - - const stdoutChunks: Buffer[] = [] - const stderrChunks: Buffer[] = [] - const stdinChunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) }) - bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) - stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) }) - const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end') - const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end') - const stdinEnded = once(stdinPeer, 'end') - - stdoutPeer.end('out') - stderrPeer.end('err') - await Promise.all([stdoutEnded, stderrEnded, stdinEnded]) - - expect(Buffer.concat(stdoutChunks).toString()).toBe('out') - expect(Buffer.concat(stderrChunks).toString()).toBe('err') - expect(Buffer.concat(stdinChunks).toString()).toBe('in') - bridge.dispose() - }) - - it('uses inherited output directly and disposes unconnected endpoints', () => { - const inherited = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, pipeBase()) - expect(inherited.stdin).toBeNull() - expect(inherited.stdout).toBeNull() - expect(inherited.stderr).toBeNull() - expect(inherited.runnerArgs).toEqual([]) - expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc']) - inherited.dispose() - - const pending = createWindowsStdioBridge(spec(), pipeBase()) - pending.closeInput() - expect(pending.stdin?.destroyed).toBe(true) - pending.dispose() - expect(pending.stdout?.destroyed).toBe(true) - expect(pending.stderr?.destroyed).toBe(true) - }) -}) diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index decbfdc80f..398e87e81c 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 20f2edea5d29e152ae01a968b81cbc79e9bb69e5 -README.zh.md: ea4801de670a90b6b158830a7f8b19ea936c6e5c +README.md: 61aec9024427a6530f4e877c2fd35cdf301af169 +README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 20f2edea5d..61aec90244 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so consumer teardown can await real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). +- Termination and waiting use one provider-managed range. `terminate()` — the only termination verb — starts the provider's documented procedure (idempotent, driven by the spec's abort signal too, and a no-op once the range is empty), while `waitForExit(signal?)` observes that same range so a consumer-owned teardown ladder holds each tier on real quiescence. Staged providers may use `graceMs` between graceful and forced steps; immediate providers do not delay. Each provider documents how it defines, signals, and observes the range, including weaker fallbacks; the [local provider](../subprocess-local/README.md) owns its systemd, Job, process-group, and `taskkill` details. The wait rejects when a selected owner can no longer observe its range; the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches quiescence for every session member the provider can still observe and settles in-flight handle calls; providers document substrate-specific observability limits. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members; readiness, scrollback, and owner policy remain in the PTY consumer. - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and explicit `env` merges after the scrub. The local ordinary and terminal spawns both apply it; SDK-managed transports that own their spawn may import it directly. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index ea4801de67..4740bef706 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方拆卸可以等待真正完全停稳。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 +- 终止与等待使用同一个 provider-managed range。`terminate()`(唯一的终止动词)启动 provider 记录的终止过程(幂等,也由 spec 的 abort 信号驱动,并在范围为空后成为空操作);`waitForExit(signal?)` 观察同一范围,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。分阶段 provider 可以用 `graceMs` 分隔温和与强制步骤,立即终止的 provider 不会等待。每个 provider 记录该范围的定义、信号与观察方式,包括较弱 fallback;[本地 provider](../subprocess-local/README.zh.md)拥有 systemd、Job、进程组与 `taskkill` 的具体说明。所选 owner 不再能观察范围时,该等待会 reject;管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,并结算在途句柄调用;提供方会记录执行基底特有的可观察性限制。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出时,输出流在已排队输出之后结束;仍处于活动状态的传输若发生故障,会使 `done` 拒绝。这些操作保留为一项执行基底原语,因为普通管道无法分配控制终端或清理终端会话成员;就绪状态、scrollback 和所有者策略仍归 PTY 消费方所有。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地的普通 spawn 与终端 spawn 都应用该定义;拥有自身 spawn 的 SDK 管理传输可直接导入它。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index c6a452bbec..8fc9773df7 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -90,9 +90,9 @@ declare module '@deepseek-ai/cordis' { * streams are handed to the caller raw and never buffered here. * - {@link SubprocessHandle.terminate} (and the spec's abort signal) starts the * provider's documented procedure against its managed range. - * {@link SubprocessHandle.waitForExit} observes that same range so consumer - * teardown can await real quiescence; each provider documents its identity, - * signalling, and observability limits. + * {@link SubprocessHandle.waitForExit} observes that same range so a + * consumer-owned teardown ladder can hold each tier on real quiescence; each + * provider documents its identity, signalling, and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 0ef3f5f981..c2b43f919f 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -89,9 +89,9 @@ export interface SubprocessSpawnSpec { */ graceMs: number /** - * Abort signal — starts the provider's termination procedure on the managed - * range when it fires. The caller owns deadlines and cause classification; - * this seam only reacts to the abort. + * Abort signal — starts the terminate escalation on the managed range when + * it fires. The caller owns deadlines and cause classification; this seam + * only reacts to the abort. */ signal?: AbortSignal | undefined /** diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 5c24705011..a8b66f662e 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 5d2b6dc0f669fb14962183e6d761f80ef9ca7e2b -README.zh.md: 9799c781ec1362631801145f9b3ff3cdb096ed35 +README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8 +README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 5d2b6dc0f6..5d14ead5a8 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitives** — the parent creates a named kill-on-close Job and private named-pipe endpoints, the runner opens their target-side handles, and `spawnOrdinaryProcessInJob()` applies explicit stdio, suspended creation, Job assignment, and resume through `CreateProcessW`. Before releasing the runner, the parent opens a separate process handle and polls its zero-time state for the direct result; Job accounting and parent-owned streams then continue independently until their own OS lifetimes end. -- **Explicit settlement ownership** — `waitForProcessExit()` waits for and closes a sandbox process handle; ordinary parent-side process polling and Job accounting, termination, and closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. +- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero. +- **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 9799c781ec..faa2dc829d 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — parent 创建 named kill-on-close Job 与 private named-pipe endpoint,runner 打开它们的 target 端 handle,`spawnOrdinaryProcessInJob()` 再通过 `CreateProcessW` 应用显式 stdio、suspended-create、Job-assignment 与 resume 生命周期。释放 runner 前,parent 会打开另一个 process handle,并轮询其 zero-time state 得到 direct result;Job accounting 与 parent-owned stream 随后各自持续到对应 OS 生命周期结束。 -- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary parent-side process polling 与 Job accounting、termination、closure 保持独立。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 +- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一直存活到 `ActiveProcesses` 归零。 +- **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index d762021788..d3b2eafcb4 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -6,20 +6,10 @@ export const STARTF_USESTDHANDLES = 0x00000100 export const HANDLE_FLAG_INHERIT = 0x1 /** Infinite WaitForSingleObject timeout. */ export const INFINITE = 0xFFFFFFFF -/** CreateProcess flag that prevents user code from running before resume. */ -export const CREATE_SUSPENDED = 0x4 /** WaitForSingleObject returned because a zero-time probe is not signalled. */ export const WAIT_TIMEOUT = 258 -/** OpenProcess right required to read limited process information. */ -export const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 -/** Standard right required to wait on a process handle. */ -export const SYNCHRONIZE = 0x00100000 -/** Read access requested for a private named-pipe client handle. */ -export const GENERIC_READ = 0x80000000 -/** Write access requested for a private named-pipe client handle. */ -export const GENERIC_WRITE = 0x40000000 -/** Open an existing named-pipe endpoint. */ -export const OPEN_EXISTING = 3 +/** CreateProcess flag that prevents user code from running before resume. */ +export const CREATE_SUSPENDED = 0x4 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -38,8 +28,6 @@ export const ERROR_BROKEN_PIPE = 109 export const ERROR_NO_DATA = 232 /** Job limit that terminates every member when the final Job handle closes. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 -/** Access right required to assign a process to an existing Job. */ -export const JOB_OBJECT_ASSIGN_PROCESS = 0x0001 /** QueryInformationJobObject class for basic accounting and active-process count. */ export const JobObjectBasicAccountingInformation = 1 /** SetInformationJobObject class for JOBOBJECT_EXTENDED_LIMIT_INFORMATION. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index d2f3bf1e85..b1adeff700 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -53,11 +53,10 @@ export interface ProcessInfoOutput { dwThreadId: number } -/** Generic Win32 calls consumed by sandbox and ordinary process operations. */ +/** Generic Win32 calls consumed by restricted-token sandbox process operations. */ export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number getLastError(): number - openProcess(desiredAccess: number, inheritHandle: number, processId: number): NativePtr formatMessageW( flags: number, source: null, @@ -68,15 +67,6 @@ export interface Win32ProcessBindings { args: null, ): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number - createFileW( - path: string, - desiredAccess: number, - shareMode: number, - securityAttributes: null, - creationDisposition: number, - flagsAndAttributes: number, - templateFile: null, - ): NativePtr setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, @@ -114,8 +104,7 @@ export interface Win32ProcessBindings { ): number waitForSingleObject(handle: NativePtr, milliseconds: number): number getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number - createJobObjectW(attributes: null, name: string | null): NativePtr - openJobObjectW(desiredAccess: number, inheritHandle: number, name: string): NativePtr + createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number queryInformationJobObject( job: NativePtr, @@ -132,7 +121,7 @@ export interface Win32ProcessBindings { } /** Koffi STARTUPINFOW layout. */ -export const STARTUPINFOW = koffi.struct({ +export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { cb: 'uint32', lpReserved: 'str16', lpDesktop: 'str16', @@ -154,7 +143,7 @@ export const STARTUPINFOW = koffi.struct({ }) /** Koffi PROCESS_INFORMATION layout. */ -export const PROCESS_INFORMATION = koffi.struct({ +export const PROCESS_INFORMATION = koffi.struct('DSH_PROCESS_INFORMATION', { hProcess: PVOID, hThread: PVOID, dwProcessId: 'uint32', @@ -263,14 +252,10 @@ function bindings(): Win32ProcessBindings { cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), - openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', [ 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), - createFileW: bind(kernel32, 'CreateFileW', PVOID, [ - 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, - ]), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', @@ -287,7 +272,6 @@ function bindings(): Win32ProcessBindings { waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), - openJobObjectW: bind(kernel32, 'OpenJobObjectW', PVOID, ['uint32', 'int', 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), queryInformationJobObject: bind(kernel32, 'QueryInformationJobObject', 'int', [ PVOID, 'int', PVOID, 'uint32', PVOID, diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 45a81f2beb..b27a72690c 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -1,4 +1,4 @@ -/** Low-level Win32 process, stdio, and Job Object primitives shared by sandbox and ordinary subprocess paths. */ +/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' export * from './errors.ts' @@ -19,23 +19,17 @@ export type { } from './ffi.ts' export { closeHandleChecked, - createKillOnCloseJob, drainPipe, isJobEmpty, - openNamedPipeForStdio, - openJobForAssignment, - openProcessForWait, pollProcessExit, spawnInheritedJobProcess, - spawnOrdinaryProcessInJob, + spawnOrdinaryJobProcess, spawnPipedProcess, terminateJob, waitForProcessExit, } from './process.ts' export type { - ChildStdioHandles, OrdinaryProcessSpawnOptions, - SpawnedAssignedProcess, SpawnedJobProcess, SpawnedPipedProcess, } from './process.ts' diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7b91efbef8..76260b5f4f 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -63,13 +63,6 @@ export interface OrdinaryProcessSpawnOptions { cwd: string } -/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */ -export interface ChildStdioHandles { - stdin?: NativePtr - stdout?: NativePtr - stderr?: NativePtr -} - /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ @@ -98,14 +91,6 @@ export interface SpawnedJobProcess { job: NativePtr } -/** Suspended child already assigned to a caller-owned Job. */ -export interface SpawnedAssignedProcess { - /** Direct child process id. */ - pid: number - /** Process handle closed by waitForProcessExit. */ - process: NativePtr -} - interface PipePair { read: NativePtr write: NativePtr @@ -314,14 +299,8 @@ export function waitForProcessExit(api: Win32ProcessBindings, process: NativePtr } } -/** - * Create a caller-owned Job whose final handle closure terminates all members. - * @param api - active binding table. - * @param name - optional name used when another process must open the same Job. - * @returns caller-owned Job handle. - */ -export function createKillOnCloseJob(api: Win32ProcessBindings, name: string | null = null): NativePtr { - const job = api.createJobObjectW(null, name) +function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { + const job = api.createJobObjectW(null, null) if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) information.writeUInt32LE( @@ -341,70 +320,24 @@ export function createKillOnCloseJob(api: Win32ProcessBindings, name: string | n return job } -/** - * Open one named Job for assigning a process from another process. - * @param api - active binding table. - * @param name - name supplied by the Job's owner. - * @returns caller-owned Job handle with assignment access. - */ -export function openJobForAssignment(api: Win32ProcessBindings, name: string): NativePtr { - const job = api.openJobObjectW(abi.JOB_OBJECT_ASSIGN_PROCESS, 0, name) - if (isNullPtr(job)) throwLastError(api, 'OpenJobObjectW', name) - return job -} - -/** - * Open one private named-pipe client for target stdio. - * @param api - active binding table. - * @param path - unique parent-owned named-pipe path. - * @returns caller-owned connected pipe handle. - */ -export function openNamedPipeForStdio(api: Win32ProcessBindings, path: string): NativePtr { - const handle = api.createFileW( - path, - abi.GENERIC_READ + abi.GENERIC_WRITE, - 0, - null, - abi.OPEN_EXISTING, - 0, - null, - ) - if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) { - throwLastError(api, 'CreateFileW', path) - } - return handle -} - -/** - * Open a process for non-blocking exit observation. - * @param api - active binding table. - * @param pid - direct process id published by the launcher. - * @returns caller-owned process handle with query and synchronize access. - */ -export function openProcessForWait(api: Win32ProcessBindings, pid: number): NativePtr { - const process = api.openProcess(abi.PROCESS_QUERY_LIMITED_INFORMATION | abi.SYNCHRONIZE, 0, pid) - if (isNullPtr(process)) throwLastError(api, 'OpenProcess', `pid ${String(pid)}`) - return process -} - /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, - job: NativePtr, - stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, -): SpawnedAssignedProcess { +): SpawnedJobProcess { + const job = createKillOnCloseJob(api) const getStdHandle = (selector: number, label: string): NativePtr => { const handle = api.getStdHandle(selector) if (!isNullPtr(handle)) return handle const win32Code = api.getLastError() + api.closeHandle(job) throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) } - const stdIn = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined @@ -434,6 +367,7 @@ function spawnJobProcess( if (created === 0) createFailureCode = api.getLastError() } catch (error) { freeNative(processInfo) + api.closeHandle(job) throw error } finally { freeNative(startupInfo) @@ -444,6 +378,7 @@ function spawnJobProcess( } if (created === 0) { freeNative(processInfo) + api.closeHandle(job) throwWin32( api, createName, @@ -459,6 +394,7 @@ function spawnJobProcess( } if (info.hProcess === null || info.hThread === null) { if (info.hProcess !== null) api.terminateProcess(info.hProcess, 1) + api.closeHandle(job) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) throw new Error(`${createName} succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) @@ -468,17 +404,18 @@ function spawnJobProcess( api.terminateProcess(info.hProcess, 1) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) + api.closeHandle(job) throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) } if (api.resumeThread(info.hThread) === 0xFFFFFFFF) { const win32Code = api.getLastError() - api.terminateProcess(info.hProcess, 1) closeBestEffort(api, info.hThread) closeBestEffort(api, info.hProcess) + api.closeHandle(job) throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) } closeBestEffort(api, info.hThread) - return { pid: info.dwProcessId, process: info.hProcess } + return { pid: info.dwProcessId, process: info.hProcess, job } } /** @@ -495,43 +432,30 @@ export function spawnInheritedJobProcess( api: Win32ProcessBindings, options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { - const job = createKillOnCloseJob(api) const commandLine = buildCommandLine(options.command, options.args) - try { - return { - ...spawnJobProcess(api, options, job, {}, 'CreateProcessAsUserW', (startupInfo, processInfo) => - createRestrictedProcess( - api, - options, - commandLine, - abi.CREATE_SUSPENDED, - startupInfo, - processInfo, - )), - job, - } - } catch (error) { - api.closeHandle(job) - throw error - } + return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => + createRestrictedProcess( + api, + options, + commandLine, + abi.CREATE_SUSPENDED, + startupInfo, + processInfo, + )) } /** - * Spawn an ordinary process suspended, assign a caller-owned Job, then resume it. + * Spawn an ordinary process suspended, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, and argv. - * @param job - caller-owned Job handle that remains open after this call. - * @param stdio - optional explicit handles opened for this target. - * @returns caller-owned process handle after successful resume. + * @returns caller-owned process and Job handles after successful resume. */ -export function spawnOrdinaryProcessInJob( +export function spawnOrdinaryJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, - job: NativePtr, - stdio: ChildStdioHandles = {}, -): SpawnedAssignedProcess { +): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, job, stdio, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, @@ -547,7 +471,7 @@ export function spawnOrdinaryProcessInJob( } /** - * Poll one process handle without blocking the caller event loop. + * Poll one process handle without blocking the runner event loop. * @param api - active binding table. * @param process - caller-owned process handle. * @returns the direct exit code when signalled, or undefined while running. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 6529b840d0..3c8f182e27 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,38 +2,25 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, - createKillOnCloseJob, isJobEmpty, - openNamedPipeForStdio, - openJobForAssignment, - openProcessForWait, pollProcessExit, - spawnOrdinaryProcessInJob, + spawnOrdinaryJobProcess, terminateJob, Win32Error, } from '../src/index.ts' import { CREATE_SUSPENDED, - GENERIC_READ, - GENERIC_WRITE, - JOB_OBJECT_ASSIGN_PROCESS, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, - OPEN_EXISTING, - PROCESS_QUERY_LIMITED_INFORMATION, - SYNCHRONIZE, WAIT_TIMEOUT, } from '../src/abi.ts' -import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' +import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), - openJobObjectW: vi.fn(() => 55n), - openProcess: vi.fn(() => 60n), - createFileW: vi.fn(() => 70n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -69,7 +56,6 @@ function api(overrides: Partial = {}): Win32ProcessBinding describe('ordinary Job process operations', () => { it('creates suspended, assigns the Job, and resumes before returning', () => { const events: string[] = [] - const createJobObjectW = vi.fn(() => 50n as NativePtr) const createProcessW = vi.fn(( _app: unknown, _line: unknown, @@ -87,19 +73,16 @@ describe('ordinary Job process operations', () => { return 1 }) const bindings = api({ - createJobObjectW, createProcessW, assignProcessToJobObject: vi.fn(() => { events.push('assign'); return 1 }), resumeThread: vi.fn(() => { events.push('resume'); return 0 }), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), }) - const job = createKillOnCloseJob(bindings, 'Local\\test-job') - expect(spawnOrdinaryProcessInJob(bindings, { + expect(spawnOrdinaryJobProcess(bindings, { command: 'probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', - }, job)).toEqual({ pid: 1234, process: 60n }) - expect(createJobObjectW).toHaveBeenCalledWith(null, 'Local\\test-job') + })).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(createProcessW).toHaveBeenCalledWith( null, 'probe.exe "literal $VALUE" "a b"', @@ -121,62 +104,14 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnOrdinaryProcessInJob(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }, 50n as NativePtr) + spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) } catch (error) { caught = error } expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) - it('passes explicit target stdio handles without reading caller stdio', () => { - let startup: Record | undefined - const getStdHandle = vi.fn(() => 99n as NativePtr) - const bindings = api({ - getStdHandle, - createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { - startup = koffi.decode(infoPtr, STARTUPINFOW) as Record - koffi.encode(processInfo, PROCESS_INFORMATION, { - hProcess: 60n, - hThread: 61n, - dwProcessId: 1234, - dwThreadId: 5678, - }) - return 1 - }), - }) - expect(spawnOrdinaryProcessInJob(bindings, { - command: 'probe.exe', - args: [], - cwd: 'C:\\work', - }, 50n as NativePtr, { - stdin: 71n as NativePtr, - stdout: 72n as NativePtr, - stderr: 73n as NativePtr, - })).toEqual({ pid: 1234, process: 60n }) - expect(getStdHandle).not.toHaveBeenCalled() - expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) - }) - - it('terminates an assigned suspended process when resume fails', () => { - const terminateProcess = vi.fn(() => 1) - const closeHandle = vi.fn(() => 1) - const bindings = api({ - resumeThread: vi.fn(() => 0xFFFFFFFF), - terminateProcess, - closeHandle, - }) - expect(() => spawnOrdinaryProcessInJob(bindings, { - command: 'probe.exe', - args: [], - cwd: 'C:\\work', - }, 50n as NativePtr)).toThrow(Win32Error) - expect(terminateProcess).toHaveBeenCalledWith(60n, 1) - expect(closeHandle).toHaveBeenCalledWith(61n) - expect(closeHandle).toHaveBeenCalledWith(60n) - expect(closeHandle).not.toHaveBeenCalledWith(50n) - }) - - it('polls a parent-owned process handle and reads Job emptiness without blocking', () => { + it('polls direct exit and Job emptiness without blocking', () => { const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) return 1 @@ -194,12 +129,13 @@ describe('ordinary Job process operations', () => { JOBOBJECT_BASIC_ACCOUNTING_SIZE, null, ) + const exited = api() expect(pollProcessExit(exited, 60n as NativePtr)).toBe(42) expect(isJobEmpty(exited, 50n as NativePtr)).toBe(true) }) - it('reports process wait, exit-code query, and Job accounting failures', () => { + it('reports wait and exit-code query failures', () => { const processWait = api({ waitForSingleObject: vi.fn(() => 0xFFFFFFFF) }) expect(() => pollProcessExit(processWait, 60n as NativePtr)).toThrow(Win32Error) @@ -223,42 +159,4 @@ describe('ordinary Job process operations', () => { const closeFailure = api({ closeHandle: vi.fn(() => 0) }) expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error) }) - - it('opens a named Job for process assignment', () => { - const openJobObjectW = vi.fn(() => 55n as NativePtr) - const bindings = api({ openJobObjectW }) - expect(openJobForAssignment(bindings, 'Local\\test-job')).toBe(55n) - expect(openJobObjectW).toHaveBeenCalledWith(JOB_OBJECT_ASSIGN_PROCESS, 0, 'Local\\test-job') - - const missing = api({ openJobObjectW: vi.fn(() => 0n as NativePtr) }) - expect(() => openJobForAssignment(missing, 'Local\\missing-job')).toThrow(Win32Error) - }) - - it('opens a private named-pipe client for target stdio', () => { - const createFileW = vi.fn(() => 70n as NativePtr) - const bindings = api({ createFileW }) - expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-test')).toBe(70n) - expect(createFileW).toHaveBeenCalledWith( - '\\\\.\\pipe\\dsh-test', - GENERIC_READ + GENERIC_WRITE, - 0, - null, - OPEN_EXISTING, - 0, - null, - ) - - const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) }) - expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing')).toThrow(Win32Error) - }) - - it('opens a direct process for parent-side exit observation', () => { - const openProcess = vi.fn(() => 60n as NativePtr) - const bindings = api({ openProcess }) - expect(openProcessForWait(bindings, 1234)).toBe(60n) - expect(openProcess).toHaveBeenCalledWith(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, 1234) - - const missing = api({ openProcess: vi.fn(() => 0n as NativePtr) }) - expect(() => openProcessForWait(missing, 1234)).toThrow(Win32Error) - }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index bcd8ae5c19..3cbb883ccf 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,12 +21,7 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); - P(GENERIC_READ); - P(GENERIC_WRITE); - P(OPEN_EXISTING); P(WAIT_TIMEOUT); - P(PROCESS_QUERY_LIMITED_INFORMATION); - P(SYNCHRONIZE); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); P(STD_ERROR_HANDLE); @@ -48,12 +43,7 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); - static_assert(GENERIC_READ == 0x80000000, "generic read access"); - static_assert(GENERIC_WRITE == 0x40000000, "generic write access"); - static_assert(OPEN_EXISTING == 3, "open existing disposition"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); - static_assert(PROCESS_QUERY_LIMITED_INFORMATION == 0x1000, "limited process query right"); - static_assert(SYNCHRONIZE == 0x100000, "synchronize right"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); static_assert(JobObjectBasicAccountingInformation == 1, "basic accounting class"); diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d5ebe108c6..5e19c20113 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -387,7 +387,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['subprocess-local', 'subprocess-e2b'], consumers: ['bash-local', 'bash-sandbox', 'terminal-bash', 'lsp-stdio', 'subagent-acp', 'subagent-codex', 'subagent-claude-code'], - note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, managed-range/session lifetime, stdio dispositions, terminal mechanics, and provider-defined termination.', + note: 'The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation.', }, { key: 'shell', diff --git a/vitest.config.ts b/vitest.config.ts index 12dd238907..cce205c03a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,12 +57,11 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32' const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', - // The Win32 adapters (Koffi process inspection/Jobs and named-pipe - // streams) execute only on win32; their decisions are unit-pinned on - // every host through focused tests and injected operations. + // The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill) + // executes only on win32; its decision logic is unit-pinned on every + // host through the injected-internals suites. 'packages/subprocess/subprocess-local/src/windows-inspector.ts', 'packages/subprocess/subprocess-local/src/windows-job.ts', - 'packages/subprocess/subprocess-local/src/windows-stdio.ts', ] : [] From f72ce2299ac08c153b8b04b83e5e605463b917d5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 22:49:56 +0800 Subject: [PATCH 042/110] fix(subprocess): keep native Job ownership in runner --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 2 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 2 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 8 +- ...-08-20-subprocess-native-containment.zh.md | 8 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 4 +- packages/subprocess/README.zh.md | 4 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- .../subprocess/subprocess-local/src/index.ts | 8 +- .../subprocess-local/src/linux-scope.ts | 20 ++- .../subprocess-local/src/managed-owner.ts | 5 +- .../subprocess-local/src/runner-launch.ts | 14 +- .../subprocess-local/src/runner-protocol.ts | 16 +- .../subprocess-local/src/spawn-runner.ts | 159 +++++++++++++----- .../subprocess/subprocess-local/src/spawn.ts | 57 ++++--- .../subprocess-local/src/windows-job.ts | 118 ++++++++++--- .../subprocess-local/src/windows-stdio.ts | 143 ++++++++++++++++ .../tests/fixtures/fake-job-runner.ts | 5 +- .../tests/linux-scope.spec.ts | 39 +++-- .../subprocess-local/tests/local.spec.ts | 35 ++++ .../tests/managed-spawn.spec.ts | 30 +++- .../tests/native-windows.spec.ts | 73 +++++++- .../tests/spawn-runner.spec.ts | 65 +++++-- .../tests/windows-job.spec.ts | 33 +++- .../tests/windows-stdio.spec.ts | 112 ++++++++++++ .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 5 +- .../subprocess/win32-process/README.zh.md | 5 +- packages/subprocess/win32-process/src/abi.ts | 6 + packages/subprocess/win32-process/src/ffi.ts | 12 ++ .../subprocess/win32-process/src/index.ts | 4 +- .../subprocess/win32-process/src/process.ts | 47 +++++- .../tests/ordinary-process.spec.ts | 66 +++++++- .../win32-process/verify/abi-probe.cpp | 6 + 41 files changed, 943 insertions(+), 216 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/windows-stdio.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index b3aae39a5d..221d51c183 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: a3ab8ebcfac7c2ad3429bcea2993fb5281a9d2e0 -2026-08-19-shared-win32-process-primitives.zh.md: e64f7537cb54e9eb1aaeb3dcf3b37cf300721b36 +2026-08-19-shared-win32-process-primitives.md: f9d876acdc385b0dfae8319091655722b24d4981 +2026-08-19-shared-win32-process-primitives.zh.md: 37ba5cae7f2d194f6e641164b72478fefab6428e diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index a3ab8ebcfa..f9d876acdc 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked pipe, Job, wait, polling, termination, and handle operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked anonymous/named-pipe, Job, wait, polling, termination, and handle operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Restricted and ordinary inherited-stdio creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner polls the direct process separately and closes the Job only after it is empty. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful anonymous-pipe creation returns the process plus stdout/stderr read handles to the sandbox. The ordinary runner opens target-side named-pipe handles supplied by its parent and closes those handles after target creation. Restricted and ordinary creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner retains the original direct-process handle and unnamed Job, polls direct exit, and closes the Job only after it is empty. -The package exports only operations used by the two production consumers. Exact `applicationName`, parent-stdio release, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. +The package exports only operations used by the two production consumers. Exact `applicationName`, parent-owned Node streams, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, anonymous-pipe EOF and drain allocation reuse, stream-specific named-pipe opens, explicit ordinary stdio handles, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index e64f7537cb..37ba5cae7f 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 pipe、Job、wait、polling、termination 与 handle 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 anonymous/named-pipe、Job、wait、polling、termination 与 handle 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。restricted 与 ordinary inherited-stdio 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 单独轮询 direct process,并只在 Job 为空后关闭它。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。anonymous pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。ordinary runner 打开 parent 提供的 target-side named-pipe handle,并在 target 创建后关闭这些 handle。restricted 与 ordinary 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 保留原始 direct-process handle 与 unnamed Job,轮询 direct exit,并只在 Job 为空后关闭它。 -该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-stdio release、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 +该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-owned Node stream、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、anonymous-pipe EOF 与 drain allocation 复用、按流划分 access 的 named-pipe open、显式 ordinary stdio handle、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 216e14e98c..4bd104ef6d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 517b7e82f00ea16ec6d9f8731be67963a46035da -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: f9555bba3d7fb2dc9971aef21f8a2c8c17d25398 +2026-08-11-synchronous-subprocess-exit-cleanup.md: d029e3eb368b175264c730cd080b431b46c73b34 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 247af134728eee420d838b92f93105857517bb60 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 517b7e82f0..d029e3eb36 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -20,7 +20,7 @@ The listener uses local-only final operations that are absent from the public `S - A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. -Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: POSIX ordinary ranges receive TERM, the configured grace, then KILL; Windows ordinary ranges terminate immediately; and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. | Host path | Local provider action | Completion evidence | | --- | --- | --- | diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index f9555bba3d..247af13472 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -20,7 +20,7 @@ Status: implemented - Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 -正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:POSIX ordinary range 先接收 TERM,经过配置的宽限期后再接收 KILL;Windows ordinary range 立即终止;每个 ordinary 或 terminal 清理都会等待完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 | 宿主路径 | 本地 provider动作 | 完成证据 | | --- | --- | --- | diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index a3cf978af0..ac616f1941 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 19fc275370e6262a7d0a31042d41f1c1fa0c9477 +2026-08-20-subprocess-native-containment.zh.md: ca96b6e30094a5d4277e9fb9acd2a1e49fec1757 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index d5a405cdda..19fc275370 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -14,13 +14,13 @@ 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. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. 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. ## Verification -Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers a default-inheritance descendant and a descendant that remains after the direct target exits. Shared tests pin direct exit versus range quiescence, literal argv, one-time fallback warnings, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. +Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers default Job inheritance, raw stdin after startup, direct stdout/stderr EOF while a descendant remains, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. ## Alternatives considered @@ -28,10 +28,12 @@ Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with syste **Expose a public backend selector or generic launch framework.** Rejected because callers need one subprocess contract, while systemd and Job creation have different launch mechanics. Only the signal/wait owner is common. +**Move the Windows Job or direct-process observation into the parent.** Rejected because a named Job, cross-process open, release handshake, or second process handle would duplicate runner-owned lifecycle facts without producing a second user result. The parent owns only public stdio endpoints and runner control. + **Support legacy systemd argument expansion.** Rejected because shell-style expansion can change user argv. Hosts without the literal-argument option use the disclosed fallback. **Use private macOS coalition APIs.** Rejected because no supported public owner gives the required membership and settlement contract. ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index abd450fe84..ca96b6e300 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -14,13 +14,13 @@ 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` 会拒绝,而不会虚构 outcome。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 ## Verification -Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认继承 descendant,以及 direct target 退出后仍存活的 descendant。shared tests 固定 direct exit 与 range quiescence 的区别、literal argv、一次性 fallback warning、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 +Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认 Job inheritance、启动后的 raw stdin、descendant 仍存活时 direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 ## Alternatives considered @@ -28,10 +28,12 @@ Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager **暴露公共 backend selector 或通用 launch framework。** 拒绝,因为调用方只需要一个 subprocess contract,而 systemd 与 Job creation 具有不同 launch mechanics;只有 signal/wait owner 是共同部分。 +**把 Windows Job 或 direct-process observation 移到 parent。** 拒绝,因为 named Job、cross-process open、release handshake 或第二个 process handle 会重复 runner 已拥有的生命周期事实,却不会产生第二个用户结果。parent 只拥有公共 stdio endpoint 与 runner control。 + **支持 legacy systemd argument expansion。** 拒绝,因为 shell-style expansion 会改变 user argv;缺少 literal-argument option 的宿主使用已披露的 fallback。 **使用 private macOS coalition API。** 拒绝,因为没有受支持的公开 owner 能提供所需 membership 与 settlement contract。 ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index cb27192356..531689eb25 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.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 packages/subprocess/README.md -README.md: 1b516c36d81a51fc2e0023d69f748cb4b46f36a4 -README.zh.md: cb83d0a9d417114f20e8fbd970f57c5d917f736f +README.md: 4f0591a698e1be12255041a3e8cf091c287a9bf8 +README.zh.md: 14aa61df2e0d4fcbdf3b5df88e085954973e0104 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 1b516c36d8..4f0591a698 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -7,8 +7,8 @@ The shared process substrate for one execution world: executable lookup, fully-s | Package | ctx key | Role | |---|---|---| | [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition: executable lookup, ordinary managed spawns, the terminal-process primitive, handle lifecycles, and shared environment/output vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, and terminate-and-join disposal | -| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | Local Service Provider: native managed ranges with disclosed fallbacks, bounded collection/spill, `node-pty`, foreground/session inspection, signalling, and terminate-and-join disposal | +| [`win32-process`](win32-process/README.md) (`@deepseek-ai/dsh-win32-process`) | — | Windows-only low-level library: the single Koffi owner for sandbox and ordinary process creation, inherited/anonymous/named-pipe stdio, suspended Job assignment, polling, waits, and handle cleanup | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index cb83d0a9d4..14aa61df2e 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -7,8 +7,8 @@ | 包 | ctx 键 | 角色 | |---|---|---| | [`subprocess`](subprocess/README.zh.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | Service Definition:可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期,以及共享的环境/输出词汇 | -| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:detached 进程树、有界收集/spill、`node-pty`、前台/会话检查、进程树信号发送,以及先终止再等待退出的 dispose(资源释放) | -| [`win32-process`](win32-process/README.zh.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名管道 stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | +| [`subprocess-local`](subprocess-local/README.zh.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地 Service Provider:带明确 fallback 的 native managed range、有界收集/spill、`node-pty`、前台/会话检查、信号发送,以及先终止再等待退出的 dispose(资源释放) | +| [`win32-process`](win32-process/README.zh.md)(`@deepseek-ai/dsh-win32-process`) | 无 | 仅限 Windows 的底层库:sandbox 与 ordinary process creation、继承/匿名/named-pipe stdio、suspended Job 分配、polling、wait 与句柄清理的唯一 Koffi owner | 即使消费方重载,进程生命周期仍由服务负责管理;消费方负责定义进程的含义(一条 bash 命令、未来的非 shell 运行器),以及决定塑造该进程的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 18ed126f0e..5ffbb436c3 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: af971f2f9706117d9a7be8fdeb29eea4d33f72e9 -README.zh.md: e829dce28d2e70d75665aa0de25611f445a49e6a +README.md: 3fec4469657ea5a98ed37c8a72f727afce08c7b3 +README.zh.md: f615e8f6e31a81b711bf58d51da2601cccbf443c diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index af971f2f97..3fec446965 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state; Windows starts the target suspended in a kill-on-close Job before resuming it. The Windows runner releases its own standard-handle copies before publishing target start, so raw pipe EOF follows the target and descendants that actually inherit the stream rather than the Job observer's lifetime. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after asynchronous scope observation or Windows Job `ActiveProcesses` confirms the range is empty and rejects when that owner becomes unreadable. `.done` remains the direct command result: a private runner reports target start failure and exit separately from range lifetime, and only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, and closes those pipe handles before publishing startup. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. `.done` remains the direct command result, and only collected pipes retain the existing bounded drain grace. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command also keeps one runner process alive until the OS-owned range is empty. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e829dce28d..f615e8f6e3 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope;Windows 以 suspended 状态创建目标,把它加入 kill-on-close Job 后才恢复。Windows runner 会在发布 target start 前释放自身持有的标准句柄副本,因此 raw pipe EOF 取决于 target 与实际继承该流的 descendant,而不取决于 Job observer 的生命周期。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在异步 scope observation 或 Windows Job 的 `ActiveProcesses` 确认同一范围为空后成功,并在 owner 不再可读时拒绝。`.done` 仍是 direct command result:private runner 分别报告目标启动失败与退出,不把 range 生命周期冒充目标结果;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,并在发布启动事实前关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。`.done` 仍是 direct command result;只有 collected pipe 保留既有有界排空宽限期。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 还会保留一个 runner process,直到 OS-owned range 为空。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5d30fa50ba..62280c77c7 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -94,8 +94,12 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const pending: Promise[] = [] 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 so an + // unreadable owner cannot hide behind a result that never settles. + pending.push(Promise.all([ + handle.done.catch(() => {}), + handle.waitForExit(), + ]).then(() => undefined)) } for (const terminal of this.terminals) { pending.push(terminal.terminate()) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 24f7bb947b..3f4eef5d51 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/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 { @@ -106,7 +106,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { class SystemdScopeOwner implements BoundProcessOwner { private stopped = false private observation: Promise | 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') { @@ -173,10 +171,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 +212,15 @@ 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 } + return { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + pid: result.pid, + direct: result.direct, + closed, + owner, + } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 945701dbe8..b37f66d137 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -1,6 +1,7 @@ /** Minimal managed-range ownership bound to one ordinary subprocess handle. */ import type { ChildProcess } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' /** Platform owner used by termination and whole-range settlement. */ @@ -13,7 +14,9 @@ export interface BoundProcessOwner { /** Platform launch facts consumed by the common stdio and result lifecycle. */ export interface ManagedProcessLaunch { - child: ChildProcess + stdin: Writable | null + stdout: Readable | null + stderr: Readable | null pid: number direct: Promise closed: Promise diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index f09da5adca..c29621ea07 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -41,17 +41,14 @@ export function spawnRunnerInvocation(): string[] { /** * Build wrapper stdio corresponding to the public target dispositions. * @param spec - target stdio request. - * @param ipc - append a Node IPC channel for the Windows runner. * @returns child-process stdio configuration. */ -export function runnerStdio(spec: SubprocessSpawnSpec, ipc = false): StdioOptions { - const stdio: StdioOptions = [ +export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { + return [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] - if (ipc) stdio.push('ipc') - return stdio } /** @@ -114,7 +111,6 @@ async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], closed: Promise, - missingResult?: () => SubprocessOutcome | undefined, ): Promise { let seen = 0 const wrapperState = { closed: false } @@ -131,8 +127,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 +138,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, - missingResult?: () => SubprocessOutcome | undefined, ): { pid: number direct: Promise @@ -165,7 +157,7 @@ export function runnerDirectResult( } return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, closed, missingResult), + direct: waitForDirectResult(files, handshake.events, closed), } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 0eab839b9f..73762bf2dc 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/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. } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 1636ce0fe5..52891b89ce 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,17 +1,17 @@ /** Native managed-range runner for ordinary local subprocesses. */ import { spawn } from 'node:child_process' -import { closeSync } from 'node:fs' import { closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, + openNamedPipeForStdio, pollProcessExit, spawnOrdinaryJobProcess, terminateJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' import { appendRunnerEvent, consumeRunnerRequest, @@ -22,12 +22,23 @@ import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' type RunnerArgs = | { mode: 'probe-node' } | { mode: 'probe-win32' } - | { mode: 'node' | 'win32'; requestPath: string; eventsPath: string } + | { mode: 'node'; requestPath: string; eventsPath: string } + | { + mode: 'win32' + requestPath: string + eventsPath: string + stdinPipe?: string + stdoutPipe?: string + stderrPipe?: string + } function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined let requestPath: string | undefined let eventsPath: string | undefined + let stdinPipe: string | undefined + let stdoutPipe: string | undefined + let stderrPipe: string | undefined for (let index = 0; index < argv.length; index += 2) { const key = argv[index] const value = argv[index + 1] @@ -35,27 +46,41 @@ function parseArgs(argv: string[]): RunnerArgs { if (key === '--mode') mode = value else if (key === '--request') requestPath = value else if (key === '--events') eventsPath = value + else if (key === '--stdin-pipe') stdinPipe = value + else if (key === '--stdout-pipe') stdoutPipe = value + else if (key === '--stderr-pipe') stderrPipe = value else throw new Error(`subprocess runner unknown argument: ${String(key)}`) } if (mode === 'probe-node' || mode === 'probe-win32') return { mode } if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') - return { mode, requestPath, eventsPath } + if (mode === 'node') return { mode, requestPath, eventsPath } + return { + mode, + requestPath, + eventsPath, + ...stdinPipe === undefined ? {} : { stdinPipe }, + ...stdoutPipe === undefined ? {} : { stdoutPipe }, + ...stderrPipe === undefined ? {} : { stderrPipe }, + } } function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { - if (!(error instanceof Win32Error)) return serializeSpawnError(error) - const code = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 - ? 'ENOENT' - : error.win32Code === 5 - ? 'EPERM' - : error.win32Code === 193 - ? 'EFTYPE' - : 'UNKNOWN' + const serialized = serializeSpawnError(error) + const code = error instanceof Win32Error + ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? 'ENOENT' + : error.win32Code === 5 + ? 'EPERM' + : error.win32Code === 193 + ? 'EFTYPE' + : 'UNKNOWN' + : serialized.code + if (code === undefined) return serialized const program = request.argv[0] as string return { - name: 'Error', - message: `spawn ${program} ${code}: ${error.message}`, + ...serialized, + message: `spawn ${program} ${code}: ${serialized.message}`, code, syscall: `spawn ${program}`, path: program, @@ -97,49 +122,91 @@ function replaceEnvironment(env: Record): void { Object.assign(process.env, env) } -/** Release the runner's copies after the Windows target inherits its standard handles. */ -function releaseRunnerStdio(): void { - for (const fd of [0, 1, 2]) { +function closeStdioHandles( + api: ReturnType, + handles: Array<{ handle: NativePtr; label: string }>, + reportFailure: boolean, +): void { + let failure: Error | undefined + for (const owned of handles.splice(0)) { try { - closeSync(fd) + closeHandleChecked(api, owned.handle, owned.label) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EBADF') throw error + handles.push(owned) + failure ??= error instanceof Error ? error : new Error(serializeSpawnError(error).message) } } + if (reportFailure && failure !== undefined) throw failure } -async function runWin32(request: RunnerRequest, eventsPath: string): Promise { +async function runWin32( + request: RunnerRequest, + eventsPath: string, + pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, +): Promise { replaceEnvironment(request.env) const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined let targetStarted = false + let targetCreationAttempted = false + const openedStdio: Array<{ handle: NativePtr; label: string }> = [] try { - let spawned - try { - const [command, ...args] = request.argv - spawned = spawnOrdinaryJobProcess(api, { command: command as string, args, cwd: request.cwd }) - } catch (error) { - appendRunnerEvent(eventsPath, { type: 'spawn-error', error: win32SpawnError(error, request) }) - return + const stdio: ChildStdioHandles = {} + for (const [key, path, access] of [ + ['stdin', pipes.stdinPipe, 'read'], + ['stdout', pipes.stdoutPipe, 'write'], + ['stderr', pipes.stderrPipe, 'write'], + ] as const) { + if (path === undefined) continue + const handle = openNamedPipeForStdio(api, path, access) + stdio[key] = handle + openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) } + targetCreationAttempted = true + // Match Node's cwd-relative executable lookup and spawn-error attribution. + process.chdir(request.cwd) + const [command, ...args] = request.argv + const spawned = spawnOrdinaryJobProcess( + api, + { command: command as string, args, cwd: process.cwd() }, + stdio, + ) processHandle = spawned.process jobHandle = spawned.job targetStarted = true - let terminationRequested = false - const terminate = (): void => { - if (terminationRequested || jobHandle === undefined) return - terminationRequested = true - terminateJob(api, jobHandle, 1) - } - process.on('message', (message: unknown) => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() - }) - process.on('disconnect', terminate) - releaseRunnerStdio() + closeStdioHandles(api, openedStdio, true) appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) await new Promise((resolve, reject) => { + let settled = false + let terminationRequested = false + const settle = (error?: unknown): void => { + if (settled) return + settled = true + clearInterval(timer) + process.off('message', onMessage) + process.off('disconnect', onDisconnect) + if (error === undefined) resolve() + else reject(error instanceof Error ? error : new Error(serializeSpawnError(error).message)) + } + const terminate = (): void => { + if (terminationRequested || jobHandle === undefined) return + terminationRequested = true + try { + terminateJob(api, jobHandle, 1) + } catch (error) { + settle(error) + } + } + const onMessage = (message: unknown): void => { + if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') { + terminate() + } + } + const onDisconnect = (): void => { terminate() } + process.on('message', onMessage) + process.on('disconnect', onDisconnect) const timer = setInterval(() => { try { if (processHandle !== undefined) { @@ -153,22 +220,22 @@ async function runWin32(request: RunnerRequest, eventsPath: string): Promise { if (args.mode === 'node') runNode(request, args.eventsPath) else { try { - await runWin32(request, args.eventsPath) + await runWin32(request, args.eventsPath, args) } finally { if (process.connected) process.disconnect() } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index b579fb022a..d7f23108b4 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,6 +1,6 @@ /** - * Process plumbing for the local subprocess service: detached process-tree - * spawn with per-stream stdio dispositions, tail-keep collection with spill + * Process plumbing for the local subprocess service: ordinary process launch + * with per-stream stdio dispositions, tail-keep collection with spill * files, provider-owned range signalling, and common termination scheduling. * POSIX owners stage TERM before KILL; Windows owners terminate immediately. * This layer reacts to an abort signal; callers own deadlines, teardown @@ -420,9 +420,9 @@ function fallbackOwner( } /** - * Bind platform launch facts to the existing stdio, outcome, abort, and escalation lifecycle. + * Bind platform launch facts to the existing stdio, outcome, abort, and termination lifecycle. * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. - * @param launch - platform child streams, direct outcome, and managed-range owner. + * @param launch - platform streams, direct outcome, and managed-range owner. * @param internals - test-only spill-directory override. * @returns live subprocess handle. */ @@ -433,7 +433,7 @@ export function bindManagedProcess( ): LocalSubprocessHandle { validateSubprocessSpec(spec) const { spillDir } = prepareManagedProcessBinding(internals) - const child = launch.child + const { stdin, stdout, stderr } = launch const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => mode !== 'pipe' && mode !== 'inherit' @@ -447,8 +447,14 @@ export function bindManagedProcess( stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) return collector } - const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') - const stderrCollector = collectStream(errMode, child.stderr, 'stderr') + const stdoutCollector = collectStream(outMode, stdout, 'stdout') + const stderrCollector = collectStream(errMode, stderr, 'stderr') + const stopCollectors = (): void => { + if (stdoutCollector !== undefined) stdout?.destroy() + if (stderrCollector !== undefined) stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() + } let graceTimer: ReturnType | undefined let rangeExitObserved = false @@ -495,9 +501,9 @@ export function bindManagedProcess( // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. - if (typeof stdinMode === 'object' && child.stdin !== null) { - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(stdinMode.data) + if (typeof stdinMode === 'object' && stdin !== null) { + stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) + stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -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))) }) @@ -539,8 +541,8 @@ export function bindManagedProcess( if (directOutcome !== undefined) settle(directOutcome) }) function cleanup(): void { - // graceTimer deliberately NOT cleared: the SIGKILL escalation must be - // able to reach tree survivors after the direct child settles. + // graceTimer deliberately NOT cleared: forced termination must still + // reach range survivors after the spawned command settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) } }) @@ -552,10 +554,11 @@ export function bindManagedProcess( return { pid: launch.pid, - /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ - stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, - stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, - stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + /* v8 ignore start -- pipe-mode streams exist on every conforming launch; + the null-coalesces guard an internal adapter defect only. */ + stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? stderr ?? undefined : undefined, /* v8 ignore stop */ collected: { ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, @@ -600,5 +603,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers, direct, ) - return bindManagedProcess(spec, { child, pid, direct, closed, owner }, binding) + return bindManagedProcess(spec, { + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + pid, + direct, + closed, + owner, + }, binding) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 3de46915da..2796b9813c 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,6 +1,8 @@ /** Windows Job runner launch and managed-range ownership. */ import { spawn, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import type { Readable } from 'node:stream' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { observeChildClose, waitWithAbort } from './managed-owner.ts' @@ -9,9 +11,31 @@ import { cleanupAfterRunner, runnerDirectResult, runnerFiles, - runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' +import { cleanupRunnerFiles } from './runner-protocol.ts' +import { createWindowsStdioBridge } from './windows-stdio.ts' + +function observeCollectedStream( + mode: SubprocessSpawnSpec['stdio']['stdout'], + stream: Readable | null | undefined, +): Promise { + if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined + || stream.readableEnded || stream.destroyed) { + return Promise.resolve() + } + return new Promise((resolve) => { + const settle = (): void => { + stream.off('end', settle) + stream.off('close', settle) + stream.off('error', settle) + resolve() + } + stream.once('end', settle) + stream.once('close', settle) + stream.once('error', settle) + }) +} /** Test seams for the runner process. */ export interface WindowsJobInternals { @@ -39,19 +63,33 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private stopped = false + private runnerClosed = false private readonly observation: Promise constructor(private readonly runner: ReturnType) { - this.observation = new Promise((resolve) => { - runner.once('close', () => { - this.stopped = true - resolve() + this.observation = new Promise((resolve, reject) => { + runner.once('close', (exitCode, signal) => { + this.runnerClosed = true + if (exitCode === 0 && signal === null) { + this.stopped = true + resolve() + return + } + const status = signal !== null + ? `signal ${signal}` + : exitCode === null + ? 'without an exit status' + : `exit code ${String(exitCode)}` + reject(new Error( + `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, + )) }) }) + void this.observation.catch(() => {}) } signal(_signal: NodeJS.Signals): void { - if (this.stopped) return + if (this.stopped || this.runnerClosed) return try { if (this.runner.connected) { this.runner.send({ type: 'terminate' }, (error) => { @@ -74,7 +112,7 @@ class WindowsJobOwner implements BoundProcessOwner { * Launch one direct command through the Job-owning runner. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. * @param internals - injected process runner used by tests. - * @returns wrapper streams, target outcome, and the bound Job owner. + * @returns parent-owned streams, target outcome, and the bound Job owner. */ export function launchWindowsJob( spec: SubprocessSpawnSpec, @@ -85,21 +123,55 @@ export function launchWindowsJob( const [command, ...prefix] = invocation if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') const files = runnerFiles(spec) - const child = run(command, [ - ...prefix, - '--mode', - 'win32', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ], { - env: childEnv(), - stdio: runnerStdio(spec, true), - }) - const closed = observeChildClose(child) + let stdio: ReturnType + try { + stdio = createWindowsStdioBridge( + spec, + `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${randomUUID()}`, + ) + } catch (error) { + cleanupRunnerFiles(files) + throw error + } + let child: ReturnType + try { + child = run(command, [ + ...prefix, + '--mode', + 'win32', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ...stdio.runnerArgs, + ], { + env: childEnv(), + stdio: stdio.runnerStdio, + }) + } catch (error) { + stdio.dispose() + cleanupRunnerFiles(files) + throw error + } + const runnerClosed = observeChildClose(child) + const closed = Promise.all([ + observeCollectedStream(spec.stdio.stdout, stdio.stdout), + observeCollectedStream(spec.stdio.stderr, stdio.stderr), + ]).then(() => undefined) const owner = new WindowsJobOwner(child) - const result = runnerDirectResult(child, files, closed) - cleanupAfterRunner(files, result.direct, closed) - return { child, pid: result.pid, direct: result.direct, closed, owner } + const result = runnerDirectResult(child, files, runnerClosed) + void result.direct.then( + () => { stdio.closeInput() }, + () => { stdio.dispose() }, + ) + cleanupAfterRunner(files, result.direct, runnerClosed) + return { + stdin: stdio.stdin, + stdout: stdio.stdout, + stderr: stdio.stderr, + pid: result.pid, + direct: result.direct, + closed, + owner, + } } diff --git a/packages/subprocess/subprocess-local/src/windows-stdio.ts b/packages/subprocess/subprocess-local/src/windows-stdio.ts new file mode 100644 index 0000000000..79037ee86b --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-stdio.ts @@ -0,0 +1,143 @@ +/** Parent-owned named-pipe streams for one Windows native launch. */ + +import type { StdioOptions } from 'node:child_process' +import { createServer } from 'node:net' +import type { Server, Socket } from 'node:net' +import { PassThrough } from 'node:stream' +import type { Readable, Writable } from 'node:stream' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' + +interface PipeEndpoint { + readonly path: string + readonly stream: PassThrough + dispose(): void +} + +/** Streams and runner arguments for one Windows launch. */ +export interface WindowsStdioBridge { + readonly stdin: Writable | null + readonly stdout: Readable | null + readonly stderr: Readable | null + readonly runnerArgs: string[] + readonly runnerStdio: StdioOptions + closeInput(): void + dispose(): void +} + +function closeServer(server: Server): void { + try { + server.close() + } catch { + // A listen failure or an already-accepted connection can close first. + } +} + +function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint { + const stream = new PassThrough() + let socket: Socket | undefined + let disposed = false + const server = createServer({ allowHalfOpen: true }) + // Direct-result failure remains authoritative for setup errors. Keep stream + // errors observable without allowing an early server failure to go unhandled. + /* v8 ignore next -- exercised only when the OS listener or socket reports an asynchronous fault. */ + stream.on('error', () => {}) + /* v8 ignore next -- platform-specific listen failures are reported asynchronously. */ + server.once('error', (error) => { stream.destroy(error) }) + server.once('connection', (connection) => { + /* v8 ignore start -- dispose racing an already-queued OS accept is not deterministic in unit tests. */ + if (disposed) { + connection.destroy() + return + } + /* v8 ignore stop */ + socket = connection + closeServer(server) + /* v8 ignore next -- exercised only by an asynchronous OS socket fault. */ + connection.once('error', (error) => { stream.destroy(error) }) + stream.once('close', () => { connection.destroy() }) + if (direction === 'output') { + connection.once('end', () => { connection.end() }) + connection.pipe(stream) + } else { + connection.resume() + stream.pipe(connection) + connection.once('close', () => { stream.destroy() }) + } + }) + try { + server.listen(path) + /* v8 ignore start -- the production path always supplies a validated short pipe name. */ + } catch (error) { + stream.destroy() + closeServer(server) + throw error + } + /* v8 ignore stop */ + return { + path, + stream, + dispose() { + disposed = true + closeServer(server) + socket?.destroy() + stream.destroy() + }, + } +} + +/** + * Create private parent-owned streams whose peer handles are opened by the Windows runner. + * @param spec - target stdio dispositions. + * @param basePath - unique named-pipe base chosen by the launch owner. + * @returns public streams, runner arguments, and cleanup for pre-start failure. + */ +export function createWindowsStdioBridge( + spec: SubprocessSpawnSpec, + basePath: string, +): WindowsStdioBridge { + const endpoints: PipeEndpoint[] = [] + let stdin: PipeEndpoint | undefined + let stdout: PipeEndpoint | undefined + let stderr: PipeEndpoint | undefined + try { + if (spec.stdio.stdin !== 'ignore') { + stdin = createEndpoint(`${basePath}-stdin`, 'input') + endpoints.push(stdin) + } + if (spec.stdio.stdout !== 'inherit') { + stdout = createEndpoint(`${basePath}-stdout`, 'output') + endpoints.push(stdout) + } + if (spec.stdio.stderr !== 'inherit') { + stderr = createEndpoint(`${basePath}-stderr`, 'output') + endpoints.push(stderr) + } + /* v8 ignore start -- only a synchronous Node listener-construction failure reaches this rollback. */ + } catch (error) { + for (const endpoint of endpoints) endpoint.dispose() + throw error + } + /* v8 ignore stop */ + return { + stdin: stdin?.stream ?? null, + stdout: stdout?.stream ?? null, + stderr: stderr?.stream ?? null, + runnerArgs: [ + ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path], + ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path], + ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path], + ], + runnerStdio: [ + 'ignore', + spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore', + spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore', + 'ipc', + ], + closeInput() { + stdin?.dispose() + }, + dispose() { + for (const endpoint of endpoints) endpoint.dispose() + }, + } +} diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index c3971b5077..2dd5451f03 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -7,10 +7,11 @@ const request = consumeRunnerRequest(requestPath) appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) const configuredExit = Number(request.argv[1]) +// Events carry target results; zero means the runner completed its own observation. if (Number.isSafeInteger(configuredExit)) { setTimeout(() => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exitCode = configuredExit + process.exitCode = 0 }, 10) } else { const hold = setInterval(() => {}, 1_000) @@ -21,7 +22,7 @@ if (Number.isSafeInteger(configuredExit)) { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) clearInterval(hold) if (process.connected) process.disconnect() - process.exitCode = 1 + process.exitCode = 0 } process.on('message', (message: unknown) => { if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ffbc047518..8755d651c6 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/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 = [] 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 inventing a direct result', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[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) }) @@ -255,7 +264,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }), runnerInvocation: spawnRunnerInvocation(), }) - expect(launch.child.pid).toBeUndefined() + expect(launch.pid).toBe(-1) await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBe(true) await launch.closed diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e7e24ca07b..37bcae6866 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/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 + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: new Promise(() => {}), + terminate, + terminateForHostExit, + waitForExit: async () => { throw rangeFailure }, + }) + + await expect(Promise.race([ + fiber.dispose().then(() => 'disposed'), + new Promise(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() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 4ea1581fa3..3e01cf8ca8 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -62,7 +62,9 @@ describe('managed process binding', () => { }, } const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: 4242, direct: direct.promise, closed: observeChildClose(wrapper), @@ -86,7 +88,9 @@ describe('managed process binding', () => { const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), closed: observeChildClose(wrapper), @@ -102,7 +106,9 @@ describe('managed process binding', () => { }) const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), @@ -126,7 +132,9 @@ describe('managed process binding', () => { ...spec(1_000), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, }, { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: new Promise(() => {}), @@ -150,7 +158,9 @@ describe('managed process binding', () => { }) const failure = new Error('range observation failed') const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: new Promise(() => {}), closed: new Promise(() => {}), @@ -173,7 +183,9 @@ describe('managed process binding', () => { const direct = Promise.resolve().then(() => { throw rejection }) const signal = vi.fn() const handle = bindManagedProcess(spec(), { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct, closed: new Promise(() => {}), @@ -182,6 +194,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') } @@ -200,7 +214,9 @@ describe('managed process binding', () => { }) const controller = new AbortController() const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { - child: wrapper, + stdin: wrapper.stdin, + stdout: wrapper.stdout, + stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, closed: Promise.resolve(), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index c89b46581a..c22c42608d 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/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' @@ -55,14 +55,16 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } -function directSpawnFailure(argv: string[]): Promise { +type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } + +function directSpawnFailure(argv: string[], cwd = scratch): Promise { return new Promise((resolve, reject) => { try { - const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) + const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' }) child.once('error', resolve) child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) } catch (error) { - resolve(error as NodeJS.ErrnoException) + resolve(error as SpawnFailure) } }) } @@ -70,6 +72,30 @@ function directSpawnFailure(argv: string[]): Promise { const windowsNative = process.platform === 'win32' && probeWindowsJob() describe.skipIf(!windowsNative)('Windows Job native containment', () => { + it('keeps raw stdin writable after the launch handshake', async () => { + const output = join(scratch, `stdin-${Date.now()}.txt`) + const script = ` + const { writeFileSync } = require('node:fs') + let input = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { input += chunk }) + process.stdin.on('end', () => { writeFileSync(${JSON.stringify(output)}, input) }) + ` + const request = { + ...spec([process.execPath, '-e', script]), + stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const, + } + const handle = bindManagedProcess(request, launchWindowsJob(request)) + if (handle.stdin === undefined) throw new Error('expected piped stdin') + await new Promise((resolve, reject) => { + handle.stdin?.once('error', reject) + handle.stdin?.end('after-handshake', resolve) + }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).resolves.toBe(true) + expect(readFileSync(output, 'utf8')).toBe('after-handshake') + }) + it('terminates the direct target and its default-inheritance descendant', async () => { const pidFile = join(scratch, `job-child-${Date.now()}.pid`) const script = ` @@ -102,24 +128,33 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) writeFileSync(${JSON.stringify(factsFile)}, JSON.stringify({ cwd: process.cwd(), value: process.env.TARGET_VALUE, arg: process.argv[1] })) child.unref() - process.exit(42) + process.stdout.end() + process.stderr.end() + process.exitCode = 42 ` const request = { ...spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' } as const, + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } as const, } const handle = bindManagedProcess(request, launchWindowsJob(request)) if (handle.stdout === undefined) throw new Error('expected piped stdout') + if (handle.stderr === undefined) throw new Error('expected piped stderr') + handle.stdout.resume() + handle.stderr.resume() const stdoutEnded = new Promise((resolve, reject) => { handle.stdout?.once('end', resolve) handle.stdout?.once('error', reject) }) + const stderrEnded = new Promise((resolve, reject) => { + handle.stderr?.once('end', resolve) + handle.stderr?.once('error', reject) + }) const descendant = await waitForPid(pidFile) try { await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(Promise.race([ - stdoutEnded.then(() => true), - new Promise(resolve => setTimeout(() => { resolve(false) }, 1_000)), + Promise.all([stdoutEnded, stderrEnded]).then(() => true), + new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ cwd: scratch, @@ -136,9 +171,30 @@ 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' }) + await expect(missingHandle.waitForExit()).resolves.toBe(true) + + const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) + const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] + const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) + const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } + const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd)) + await expect(invalidCwdHandle.done).rejects.toMatchObject({ + code: expectedCwd.code, + syscall: expectedCwd.syscall, + path: expectedCwd.path, + spawnargs: expectedCwd.spawnargs, + }) + await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true) const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) writeFileSync(invalidExecutable, 'not a Windows executable\r\n') @@ -146,5 +202,6 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) + await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index c54d831c43..07bc9ca2b4 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/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, mkdirSync, 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' @@ -84,11 +85,11 @@ describe('spawn runner transport', () => { expect(result.status).toBe(0) }) - it('maps every target stdio disposition and optional IPC channel', () => { + it('maps every target stdio disposition', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, - }), true)).toEqual(['pipe', 'inherit', 'inherit', 'ipc']) + }))).toEqual(['pipe', 'inherit', 'inherit']) }) it('materializes and consumes the exact runner request once', () => { @@ -127,6 +128,35 @@ 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('contains an unexpected owned-path cleanup failure', () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + rmSync(files.requestPath, { force: true }) + mkdirSync(files.requestPath) + try { + expect(() => { cleanupRunnerFiles(files) }).not.toThrow() + expect(existsSync(files.directory)).toBe(true) + } finally { + rmSync(files.directory, { recursive: true, force: true }) + } + }) + it('reads only complete known event records and propagates file errors', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { @@ -246,7 +276,7 @@ describe('spawn runner transport', () => { } }) - it('maps runner failures and wrapper-close fallback outcomes', async () => { + it('maps runner failures and missing direct results', async () => { const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(runnerFailure.eventsPath, { @@ -260,6 +290,20 @@ describe('spawn runner transport', () => { cleanupRunnerFiles(runnerFailure) } + const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) + appendRunnerEvent(afterStartFailure.eventsPath, { + type: 'runner-error', + error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, + }) + const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) + expect(result.pid).toBe(456) + await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) + } finally { + cleanupRunnerFiles(afterStartFailure) + } + const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) @@ -270,19 +314,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 () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 0d69a52331..85c9095879 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -14,7 +14,7 @@ function spec(argv: string[]): SubprocessSpawnSpec { return { argv, cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 100, } } @@ -60,6 +60,33 @@ describe('Windows Job runner adapter', () => { launch.owner.signal('SIGKILL') }) + it.each([ + { exitCode: 127, signal: null, status: 'exit code 127' }, + { exitCode: null, signal: 'SIGTERM' as NodeJS.Signals, status: 'signal SIGTERM' }, + { exitCode: null, signal: null, status: 'without an exit status' }, + ])('rejects range settlement when the runner exits with $status', async ({ exitCode, signal, status }) => { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + Object.assign(child, { pid: 432, connected: false, kill }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + const directFailure = launch.direct.catch((error: unknown) => error) + + child.emit('close', exitCode, signal) + + await expect(launch.owner.waitForExit()).rejects.toThrow( + `Windows Job runner exited with ${status} before proving its managed range empty`, + ) + await expect(directFailure).resolves.toBeInstanceOf(Error) + launch.owner.signal('SIGKILL') + expect(kill).not.toHaveBeenCalled() + }) + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { const child = new EventEmitter() as ChildProcess @@ -90,9 +117,9 @@ describe('Windows Job runner adapter', () => { if (mode === 'disconnected') expect(send).not.toHaveBeenCalled() appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) + child.emit('close', null, 'SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') const sends = send.mock.calls.length const kills = kill.mock.calls.length launch.owner.signal('SIGKILL') diff --git a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts new file mode 100644 index 0000000000..c8b3cf4e07 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts @@ -0,0 +1,112 @@ +import { spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { connect } from 'node:net' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { createWindowsStdioBridge } from '../src/windows-stdio.ts' + +function pipeBase(): string { + return process.platform === 'win32' + ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}` + : join('/tmp', `dsh-windows-stdio-${randomUUID()}`) +} + +function spec(): SubprocessSpawnSpec { + return { + argv: ['target'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } }, + graceMs: 100, + } +} + +function pathAfter(args: readonly string[], key: string): string { + const path = args[args.indexOf(key) + 1] + if (path === undefined) throw new Error(`missing ${key}`) + return path +} + +describe('Windows parent-owned stdio bridge', () => { + it('binds before returning so a synchronously launched peer can connect', async () => { + const bridge = createWindowsStdioBridge({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, + }, pipeBase()) + const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') + const result = spawnSync(process.execPath, ['-e', ` + const { connect } = require('node:net') + const socket = connect(${JSON.stringify(stdoutPath)}) + socket.once('connect', () => { + socket.write('blocked-parent', () => { + socket.destroy() + process.exit(0) + }) + }) + socket.once('error', () => { process.exit(1) }) + setTimeout(() => { process.exit(2) }, 2000) + `], { timeout: 5_000 }) + expect(result.status).toBe(0) + + const chunks: Buffer[] = [] + bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) }) + await once(bridge.stdout as NodeJS.ReadableStream, 'end') + expect(Buffer.concat(chunks).toString()).toBe('blocked-parent') + bridge.dispose() + }) + + it('moves bytes in both directions and ends output with its target-side peer', async () => { + const bridge = createWindowsStdioBridge(spec(), pipeBase()) + const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') + const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe') + const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe') + expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc']) + await new Promise(resolve => setImmediate(resolve)) + + bridge.stdin?.end('in') + const stdoutPeer = connect(stdoutPath) + const stderrPeer = connect(stderrPath) + const stdinPeer = connect(stdinPath) + await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')]) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + const stdinChunks: Buffer[] = [] + bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) }) + bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) + stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) }) + const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end') + const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end') + const stdinEnded = once(stdinPeer, 'end') + + stdoutPeer.end('out') + stderrPeer.end('err') + await Promise.all([stdoutEnded, stderrEnded, stdinEnded]) + + expect(Buffer.concat(stdoutChunks).toString()).toBe('out') + expect(Buffer.concat(stderrChunks).toString()).toBe('err') + expect(Buffer.concat(stdinChunks).toString()).toBe('in') + bridge.dispose() + }) + + it('uses inherited output directly and disposes unconnected endpoints', () => { + const inherited = createWindowsStdioBridge({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }, pipeBase()) + expect(inherited.stdin).toBeNull() + expect(inherited.stdout).toBeNull() + expect(inherited.stderr).toBeNull() + expect(inherited.runnerArgs).toEqual([]) + expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc']) + inherited.dispose() + + const pending = createWindowsStdioBridge(spec(), pipeBase()) + pending.closeInput() + expect(pending.stdin?.destroyed).toBe(true) + pending.dispose() + expect(pending.stdout?.destroyed).toBe(true) + expect(pending.stderr?.destroyed).toBe(true) + }) +}) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index a8b66f662e..648514158c 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 5d14ead5a8d9b6b5d00ee298f274a3d4a1a9aae8 -README.zh.md: faa2dc829db4e4772384bb8a58ca56cebb12dd5c +README.md: 3859a504d2ec011d01df0a43141f96f2dc89d1c9 +README.zh.md: 877acb4f4f30a7aa387dc95a2df2efee726f36e6 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 5d14ead5a8..3859a504d2 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,14 +10,15 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the same suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW`. A zero-time process wait publishes the direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps the runner alive until `ActiveProcesses` reaches zero. +- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnOrdinaryJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle. +- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. ## Header verification -The process, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): +The process, named-pipe, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index faa2dc829d..877acb4f4f 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,7 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用相同的 suspended-create、Job-assignment 与 resume 生命周期。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让 runner 一直存活到 `ActiveProcesses` 归零。 +- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnOrdinaryJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。 +- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -19,7 +20,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 ## 头部验证 -process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: +process、named-pipe、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index d3b2eafcb4..3a7448b497 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -10,6 +10,12 @@ export const INFINITE = 0xFFFFFFFF export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** Read access requested for a private named-pipe client handle. */ +export const GENERIC_READ = 0x80000000 +/** Write access requested for a private named-pipe client handle. */ +export const GENERIC_WRITE = 0x40000000 +/** Open an existing named-pipe endpoint. */ +export const OPEN_EXISTING = 3 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b1adeff700..62375975ac 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -67,6 +67,15 @@ export interface Win32ProcessBindings { args: null, ): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + createFileW( + path: string, + desiredAccess: number, + shareMode: number, + securityAttributes: null, + creationDisposition: number, + flagsAndAttributes: number, + templateFile: null, + ): NativePtr setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, @@ -256,6 +265,9 @@ function bindings(): Win32ProcessBindings { 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + createFileW: bind(kernel32, 'CreateFileW', PVOID, [ + 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, + ]), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index b27a72690c..ba5dfdcc9d 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -1,4 +1,4 @@ -/** Low-level Win32 process, stdio, and Job Object primitives used by the Windows ACL sandbox. */ +/** Shared low-level Win32 process, stdio, and Job Object primitives. */ export { ERROR_INSUFFICIENT_BUFFER } from './abi.ts' export * from './errors.ts' @@ -21,6 +21,7 @@ export { closeHandleChecked, drainPipe, isJobEmpty, + openNamedPipeForStdio, pollProcessExit, spawnInheritedJobProcess, spawnOrdinaryJobProcess, @@ -29,6 +30,7 @@ export { waitForProcessExit, } from './process.ts' export type { + ChildStdioHandles, OrdinaryProcessSpawnOptions, SpawnedJobProcess, SpawnedPipedProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 76260b5f4f..b56804e0df 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -63,6 +63,13 @@ export interface OrdinaryProcessSpawnOptions { cwd: string } +/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */ +export interface ChildStdioHandles { + stdin?: NativePtr + stdout?: NativePtr + stderr?: NativePtr +} + /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ @@ -320,10 +327,38 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { return job } +/** + * Open one private named-pipe client for target stdio. + * @param api - active binding table. + * @param path - unique parent-owned named-pipe path. + * @param access - target-side read access for stdin or write access for output. + * @returns caller-owned connected pipe handle. + */ +export function openNamedPipeForStdio( + api: Win32ProcessBindings, + path: string, + access: 'read' | 'write', +): NativePtr { + const handle = api.createFileW( + path, + access === 'read' ? abi.GENERIC_READ : abi.GENERIC_WRITE, + 0, + null, + abi.OPEN_EXISTING, + 0, + null, + ) + if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) { + throwLastError(api, 'CreateFileW', path) + } + return handle +} + /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, + stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, ): SpawnedJobProcess { @@ -335,9 +370,9 @@ function spawnJobProcess( api.closeHandle(job) throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) } - const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const stdIn = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined @@ -433,7 +468,7 @@ export function spawnInheritedJobProcess( options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, {}, 'CreateProcessAsUserW', (startupInfo, processInfo) => createRestrictedProcess( api, options, @@ -448,14 +483,16 @@ export function spawnInheritedJobProcess( * Spawn an ordinary process suspended, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, and argv. + * @param stdio - optional explicit handles opened for this target. * @returns caller-owned process and Job handles after successful resume. */ export function spawnOrdinaryJobProcess( api: Win32ProcessBindings, options: OrdinaryProcessSpawnOptions, + stdio: ChildStdioHandles = {}, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, stdio, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 3c8f182e27..60f51bbef6 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, isJobEmpty, + openNamedPipeForStdio, pollProcessExit, spawnOrdinaryJobProcess, terminateJob, @@ -10,17 +11,21 @@ import { } from '../src/index.ts' import { CREATE_SUSPENDED, + GENERIC_READ, + GENERIC_WRITE, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, + OPEN_EXISTING, WAIT_TIMEOUT, } from '../src/abi.ts' -import { PROCESS_INFORMATION } from '../src/ffi.ts' +import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), + createFileW: vi.fn(() => 70n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -111,6 +116,65 @@ describe('ordinary Job process operations', () => { expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) + it('passes explicit target stdio handles without reading caller stdio', () => { + let startup: Record | undefined + const getStdHandle = vi.fn(() => 99n as NativePtr) + const bindings = api({ + getStdHandle, + createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { + startup = koffi.decode(infoPtr, STARTUPINFOW) as Record + koffi.encode(processInfo, PROCESS_INFORMATION, { + hProcess: 60n, + hThread: 61n, + dwProcessId: 1234, + dwThreadId: 5678, + }) + return 1 + }), + }) + expect(spawnOrdinaryJobProcess(bindings, { + command: 'probe.exe', + args: [], + cwd: 'C:\\work', + }, { + stdin: 71n as NativePtr, + stdout: 72n as NativePtr, + stderr: 73n as NativePtr, + })).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(getStdHandle).not.toHaveBeenCalled() + expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) + }) + + it('opens private named-pipe clients with stream-specific access', () => { + const createFileW = vi.fn(() => 70n as NativePtr) + const bindings = api({ createFileW }) + expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-stdin', 'read')).toBe(70n) + expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-stdout', 'write')).toBe(70n) + expect(createFileW).toHaveBeenNthCalledWith( + 1, + '\\\\.\\pipe\\dsh-stdin', + GENERIC_READ, + 0, + null, + OPEN_EXISTING, + 0, + null, + ) + expect(createFileW).toHaveBeenNthCalledWith( + 2, + '\\\\.\\pipe\\dsh-stdout', + GENERIC_WRITE, + 0, + null, + OPEN_EXISTING, + 0, + null, + ) + + const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) }) + expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing', 'read')).toThrow(Win32Error) + }) + it('polls direct exit and Job emptiness without blocking', () => { const queryInformationJobObject = vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(1, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 3cbb883ccf..60ad3c662d 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,6 +21,9 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_EXISTING); P(WAIT_TIMEOUT); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); @@ -43,6 +46,9 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); + static_assert(GENERIC_READ == 0x80000000, "generic read access"); + static_assert(GENERIC_WRITE == 0x40000000, "generic write access"); + static_assert(OPEN_EXISTING == 3, "open existing disposition"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); From fa8b83816e886d6d1d5f9bd29704e7a8ec03a6c5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 23:03:59 +0800 Subject: [PATCH 043/110] fix(subprocess): defer native handshake allocation --- .../subprocess-local/src/runner-launch.ts | 3 ++- .../subprocess-local/tests/spawn-runner.spec.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index c29621ea07..60472c357e 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -16,7 +16,7 @@ import { import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' import { childEnv } from './spawn.ts' -const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) +let handshakeWait: Int32Array | undefined const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' @@ -102,6 +102,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') + handshakeWait ??= new Int32Array(new SharedArrayBuffer(4)) Atomics.wait(handshakeWait, 0, 0, 5) } throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 07bc9ca2b4..5841a44a54 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -63,6 +63,20 @@ describe('spawn runner transport', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) }) + it('does not require SharedArrayBuffer until a native handshake runs', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer') + Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined }) + vi.resetModules() + try { + const isolated = await import('../src/runner-launch.ts') + expect(isolated.runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer') + else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor) + vi.resetModules() + } + }) + it('re-enters a packaged executable through its private runner dispatch', () => { const packagedProcess = process as NodeJS.Process & { pkg?: unknown } const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') From 3acb84744235512881efed577ec3eb697885365e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 21 Aug 2026 23:32:27 +0800 Subject: [PATCH 044/110] fix(subprocess): preserve clean spawn failure settlement --- .../subprocess-local/src/runner-launch.ts | 13 +++++++---- .../subprocess-local/src/windows-job.ts | 9 +++++--- .../tests/spawn-runner.spec.ts | 8 +++++++ .../tests/windows-job.spec.ts | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 60472c357e..d23cbe4477 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -67,6 +67,7 @@ export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles { interface RunnerHandshake { pid: number events: RunnerEvent[] + failureReported: boolean } /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ @@ -98,8 +99,10 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner while (Date.now() < deadline) { const events = readRunnerEvents(files.eventsPath) const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') - if (terminal?.type === 'started') return { pid: terminal.pid, events } - if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') return { pid: -1, events } + if (terminal?.type === 'started') return { pid: terminal.pid, events, failureReported: false } + if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { + return { pid: -1, events, failureReported: true } + } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') handshakeWait ??= new Int32Array(new SharedArrayBuffer(4)) @@ -139,7 +142,7 @@ 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. - * @returns target pid and direct result promise. + * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure. */ export function runnerDirectResult( child: ChildProcess, @@ -148,17 +151,19 @@ export function runnerDirectResult( ): { pid: number direct: Promise + failureReported: boolean } { let handshake: RunnerHandshake try { handshake = waitForRunnerHandshake(child, files) } catch (error) { cleanupRunnerFiles(files) - return { pid: -1, direct: Promise.resolve().then(() => { throw error }) } + return { pid: -1, direct: Promise.resolve().then(() => { throw error }), failureReported: false } } return { pid: handshake.pid, direct: waitForDirectResult(files, handshake.events, closed), + failureReported: handshake.failureReported, } } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 2796b9813c..e680e96a5b 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -66,7 +66,10 @@ class WindowsJobOwner implements BoundProcessOwner { private runnerClosed = false private readonly observation: Promise - constructor(private readonly runner: ReturnType) { + constructor( + private readonly runner: ReturnType, + private readonly startupFailureReported: boolean, + ) { this.observation = new Promise((resolve, reject) => { runner.once('close', (exitCode, signal) => { this.runnerClosed = true @@ -89,7 +92,7 @@ class WindowsJobOwner implements BoundProcessOwner { } signal(_signal: NodeJS.Signals): void { - if (this.stopped || this.runnerClosed) return + if (this.stopped || this.runnerClosed || this.startupFailureReported) return try { if (this.runner.connected) { this.runner.send({ type: 'terminate' }, (error) => { @@ -158,8 +161,8 @@ export function launchWindowsJob( observeCollectedStream(spec.stdio.stdout, stdio.stdout), observeCollectedStream(spec.stdio.stderr, stdio.stderr), ]).then(() => undefined) - const owner = new WindowsJobOwner(child) const result = runnerDirectResult(child, files, runnerClosed) + const owner = new WindowsJobOwner(child, result.failureReported) void result.direct.then( () => { stdio.closeInput() }, () => { stdio.dispose() }, diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 5841a44a54..913d8ecbe1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -299,6 +299,7 @@ describe('spawn runner transport', () => { }) const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise(() => {})) expect(result.pid).toBe(-1) + expect(result.failureReported).toBe(true) await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' }) } finally { cleanupRunnerFiles(runnerFailure) @@ -313,6 +314,7 @@ describe('spawn runner transport', () => { }) const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) expect(result.pid).toBe(456) + expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) } finally { cleanupRunnerFiles(afterStartFailure) @@ -323,6 +325,7 @@ describe('spawn runner transport', () => { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve()) expect(result.pid).toBe(456) + expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toThrow('exited without a direct-command result') } finally { cleanupRunnerFiles(missing) @@ -351,6 +354,7 @@ describe('spawn runner transport', () => { const closed = Promise.withResolvers() const isolated = await import('../src/runner-launch.ts') const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) + expect(result.failureReported).toBe(false) expect(readCount).toBe(1) closed.resolve(undefined) await Promise.resolve() @@ -374,6 +378,7 @@ describe('spawn runner transport', () => { const closed = observeChildClose(child) const result = runnerDirectResult(child, files, closed) expect(result.pid).toBe(-1) + expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toThrow('runner failed to start') await expect(closed).resolves.toBeUndefined() } finally { @@ -385,12 +390,14 @@ describe('spawn runner transport', () => { const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise(() => {})) expect(missingResult.pid).toBe(-1) + expect(missingResult.failureReported).toBe(false) await expect(missingResult.direct).rejects.toThrow('runner failed to start') expect(existsSync(missingChild.directory)).toBe(false) const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise(() => {})) expect(exitedResult.pid).toBe(-1) + expect(exitedResult.failureReported).toBe(false) await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start') expect(existsSync(exitedChild.directory)).toBe(false) @@ -399,6 +406,7 @@ describe('spawn runner transport', () => { try { const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise(() => {})) expect(timedOutResult.pid).toBe(-1) + expect(timedOutResult.failureReported).toBe(false) await expect(timedOutResult.direct).rejects.toThrow('did not report target start') expect(existsSync(timedOut.directory)).toBe(false) } finally { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 85c9095879..b8d24b8876 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -87,6 +87,29 @@ describe('Windows Job runner adapter', () => { expect(kill).not.toHaveBeenCalled() }) + it('lets a runner-reported startup failure close without a termination race', async () => { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn() + Object.assign(child, { pid: 432, connected: true, kill, send }) + const run = vi.fn((_command: string, args: readonly string[]) => { + const eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { + type: 'spawn-error', + error: { name: 'Error', message: 'spawn missing ENOENT', code: 'ENOENT' }, + }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['missing-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + + launch.owner.signal('SIGTERM') + await expect(launch.direct).rejects.toMatchObject({ code: 'ENOENT' }) + expect(send).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + child.emit('close', 0, null) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + }) + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { const child = new EventEmitter() as ChildProcess From df8e3d7c373bad8afc2dcaaf62c674db657f60fd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 04:58:31 +0800 Subject: [PATCH 045/110] fix(subprocess): settle pre-start Windows runner failures --- .../subprocess-local/src/windows-job.ts | 25 +++--- .../tests/windows-job.spec.ts | 90 +++++++++++++++++++ 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index e680e96a5b..6031c91748 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -18,22 +18,21 @@ import { createWindowsStdioBridge } from './windows-stdio.ts' function observeCollectedStream( mode: SubprocessSpawnSpec['stdio']['stdout'], - stream: Readable | null | undefined, + stream: Readable | null, ): Promise { - if (mode === 'pipe' || mode === 'inherit' || stream === null || stream === undefined - || stream.readableEnded || stream.destroyed) { - return Promise.resolve() - } + if (mode === 'pipe' || mode === 'inherit') return Promise.resolve() + // The bridge creates collect streams synchronously before the runner starts. + const collected = stream as Readable return new Promise((resolve) => { const settle = (): void => { - stream.off('end', settle) - stream.off('close', settle) - stream.off('error', settle) + collected.off('end', settle) + collected.off('close', settle) + collected.off('error', settle) resolve() } - stream.once('end', settle) - stream.once('close', settle) - stream.once('error', settle) + collected.once('end', settle) + collected.once('close', settle) + collected.once('error', settle) }) } @@ -73,7 +72,7 @@ class WindowsJobOwner implements BoundProcessOwner { this.observation = new Promise((resolve, reject) => { runner.once('close', (exitCode, signal) => { this.runnerClosed = true - if (exitCode === 0 && signal === null) { + if (this.startupFailureReported || this.runner.pid === undefined || (exitCode === 0 && signal === null)) { this.stopped = true resolve() return @@ -92,7 +91,7 @@ class WindowsJobOwner implements BoundProcessOwner { } signal(_signal: NodeJS.Signals): void { - if (this.stopped || this.runnerClosed || this.startupFailureReported) return + if (this.stopped || this.runnerClosed || this.startupFailureReported || this.runner.pid === undefined) return try { if (this.runner.connected) { this.runner.send({ type: 'terminate' }, (error) => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index b8d24b8876..8d9005da18 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -1,11 +1,13 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { appendRunnerEvent } from '../src/runner-protocol.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' +import type { WindowsStdioBridge } from '../src/windows-stdio.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) const invocation = [process.execPath, '--import', 'tsx/esm', fixture] @@ -110,6 +112,20 @@ describe('Windows Job runner adapter', () => { await expect(launch.owner.waitForExit()).resolves.toBe(true) }) + it('treats a wrapper that never started as an empty managed range', async () => { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + Object.assign(child, { pid: undefined, connected: false, kill }) + const run = vi.fn(() => child) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['missing-runner'] }) + + await expect(launch.direct).rejects.toThrow('runner failed to start') + launch.owner.signal('SIGTERM') + expect(kill).not.toHaveBeenCalled() + child.emit('close', -2, null) + await expect(launch.owner.waitForExit()).resolves.toBe(true) + }) + it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { const child = new EventEmitter() as ChildProcess @@ -212,4 +228,78 @@ describe('Windows Job runner adapter', () => { vi.resetModules() } }) + + it('cleans synchronous setup failures and waits for collected streams', async () => { + const bridgeFailure = new Error('bridge failed') + const spawnFailure = new Error('spawn threw') + const bridges: Array; closeInput: ReturnType }> = [] + let collectedStreams: { stdout: PassThrough; stderr: PassThrough } | undefined + vi.resetModules() + vi.doMock('../src/windows-stdio.ts', async importOriginal => ({ + ...await importOriginal(), + createWindowsStdioBridge: vi.fn((request: SubprocessSpawnSpec): WindowsStdioBridge => { + if (request.argv[0] === 'bridge-failure') throw bridgeFailure + const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null + const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null + if (stdout !== null && stderr !== null) collectedStreams = { stdout, stderr } + const bridge = { + stdin: null, + stdout, + stderr, + runnerArgs: [], + runnerStdio: ['ignore', 'ignore', 'ignore', 'ipc'], + closeInput: vi.fn(), + dispose: vi.fn(() => { + stdout?.destroy() + stderr?.destroy() + }), + } satisfies WindowsStdioBridge + bridges.push(bridge) + return bridge + }), + })) + try { + const isolated = await import('../src/windows-job.ts') + expect(() => isolated.launchWindowsJob(spec(['bridge-failure']), { runnerInvocation: ['fake-runner'] })) + .toThrow(bridgeFailure) + + expect(() => isolated.launchWindowsJob(spec(['spawn-failure']), { + spawn: vi.fn(() => { throw spawnFailure }) as unknown as typeof spawn, + runnerInvocation: ['fake-runner'], + })).toThrow(spawnFailure) + expect(bridges.at(-1)?.dispose).toHaveBeenCalledOnce() + + const child = new EventEmitter() as ChildProcess + Object.assign(child, { pid: 432, connected: true, kill: vi.fn(), send: vi.fn() }) + const run = vi.fn((_command: string, args: readonly string[]) => { + const eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + setImmediate(() => { child.emit('close', 0, null) }) + return child + }) as unknown as typeof spawn + const launch = isolated.launchWindowsJob({ + ...spec(['collect']), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 1024 }, + stderr: { maxBytes: 1024 }, + }, + }, { spawn: run, runnerInvocation: ['fake-runner'] }) + let streamsSettled = false + void launch.closed.then(() => { streamsSettled = true }) + await launch.direct + await new Promise(resolve => setImmediate(resolve)) + expect(streamsSettled).toBe(false) + collectedStreams?.stdout.emit('end') + await Promise.resolve() + expect(streamsSettled).toBe(false) + collectedStreams?.stderr.emit('error', new Error('stream closed')) + await expect(launch.closed).resolves.toBeUndefined() + expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce() + } finally { + vi.doUnmock('../src/windows-stdio.ts') + vi.resetModules() + } + }) }) From b6dc88be519a6a769453d2730188c86ee9706a1a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 04:59:36 +0800 Subject: [PATCH 046/110] fix(subprocess): preserve forced Linux outcomes --- .../subprocess-local/src/linux-scope.ts | 37 ++++++++++++++++--- .../subprocess-local/src/managed-owner.ts | 5 +++ .../subprocess-local/src/runner-launch.ts | 24 ++++++------ .../tests/linux-scope.spec.ts | 10 ++++- .../tests/spawn-runner.spec.ts | 8 ++-- 5 files changed, 60 insertions(+), 24 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 3f4eef5d51..287f4a23a7 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -4,9 +4,9 @@ import { randomBytes } from 'node:crypto' import { execFile, spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { DirectResultUnavailableError, observeChildClose, waitWithAbort } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { cleanupAfterRunner, @@ -114,6 +114,7 @@ class SystemdScopeOwner implements BoundProcessOwner { private readonly runSync: typeof spawnSync, private readonly query: (command: string, args: readonly string[]) => Promise, private readonly runner: ChildProcess, + private readonly onForceKillAttempt: () => void, ) {} signal(signal: NodeJS.Signals): void { @@ -125,6 +126,7 @@ class SystemdScopeOwner implements BoundProcessOwner { `--signal=${signal}`, this.unit, ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) + if (signal === 'SIGKILL' && result.error === undefined) this.onForceKillAttempt() if (result.error === undefined && result.status === 0) { return } @@ -211,15 +213,38 @@ export function launchLinuxScope( stdio: runnerStdio(spec), }) const closed = observeChildClose(child) - const owner = new SystemdScopeOwner(`${unitBase}.scope`, systemctl, runSync, query, child) - const result = runnerDirectResult(child, files, closed) - cleanupAfterRunner(files, result.direct, closed) + let forceKillAttempted = false + let scopeSettled = false + const owner = new SystemdScopeOwner( + `${unitBase}.scope`, + systemctl, + runSync, + query, + child, + () => { forceKillAttempted = true }, + ) + const resultFinalized = closed.then(async () => { + try { + await owner.waitForExit() + scopeSettled = true + } catch (_rangeObservationFailed) { + // waitForExit retains the authoritative owner failure for its caller. + } + }) + const result = runnerDirectResult(child, files, resultFinalized) + const direct = result.direct.catch((error: unknown): SubprocessOutcome => { + if (forceKillAttempted && scopeSettled && error instanceof DirectResultUnavailableError) { + return { exitCode: null, signal: 'SIGKILL' } + } + throw error + }) + cleanupAfterRunner(files, direct, closed) return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, pid: result.pid, - direct: result.direct, + direct, closed, owner, } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index b37f66d137..87a51ff541 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -4,6 +4,11 @@ import type { ChildProcess } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +/** Direct target started, but its runner closed before publishing an exit event. */ +export class DirectResultUnavailableError extends Error { + override name = 'DirectResultUnavailableError' +} + /** Platform owner used by termination and whole-range settlement. */ export interface BoundProcessOwner { /** Signal the established managed range; a confirmed-stopped owner stays inert. */ diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index d23cbe4477..276ed9df14 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -14,6 +14,7 @@ import { readRunnerEventsAsync, } from './runner-protocol.ts' import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' +import { DirectResultUnavailableError } from './managed-owner.ts' import { childEnv } from './spawn.ts' let handshakeWait: Int32Array | undefined @@ -114,24 +115,23 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], - closed: Promise, + resultFinalized: Promise, ): Promise { let seen = 0 - const wrapperState = { closed: false } - void closed.then(() => { wrapperState.closed = true }) + const resultState = { finalized: false } + void resultFinalized.then(() => { resultState.finalized = true }) for (;;) { - // A read started before close may return a stale snapshot after close has - // become visible. Only a read started after close can prove no terminal - // event was written before the runner exited. - const closedBeforeRead = wrapperState.closed + // A read started before finalization may return a stale snapshot. Only a + // read started afterward can prove no terminal event remains forthcoming. + const finalizedBeforeRead = resultState.finalized const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) } seen = Math.max(seen, events.length, initial.length) - if (closedBeforeRead) { - throw new Error('native subprocess runner exited without a direct-command result') + if (finalizedBeforeRead) { + throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') } await sleepMs(RUNNER_EVENT_POLL_MS) } @@ -141,13 +141,13 @@ async function waitForDirectResult( * Bind runner events into one direct result while preserving the target pid. * @param child - native wrapper process. * @param files - private request and result paths. - * @param closed - wrapper close observation attached before the start handshake. + * @param resultFinalized - platform proof that no later runner event can arrive. * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure. */ export function runnerDirectResult( child: ChildProcess, files: RunnerFiles, - closed: Promise, + resultFinalized: Promise, ): { pid: number direct: Promise @@ -162,7 +162,7 @@ export function runnerDirectResult( } return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, closed), + direct: waitForDirectResult(files, handshake.events, resultFinalized), failureReported: handshake.failureReported, } } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 8755d651c6..c7951d98c0 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -113,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 without inventing a direct result', async () => { + it('uses a successful scope KILL when the runner cannot publish the direct result', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') @@ -127,6 +127,12 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () return { status: 1, stdout: '', stderr: 'Unit could not be found', error: undefined } } if (wrapper?.pid !== undefined) process.kill(-wrapper.pid, 'SIGKILL') + return { + status: 1, + stdout: '', + stderr: 'Failed to send signal SIGKILL to auxiliary processes: Invalid argument', + error: undefined, + } } if (command === 'systemctl' && args[1] === 'show') { const active = wrapper?.exitCode === null && wrapper.signalCode === null @@ -142,7 +148,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) launch.owner.signal('SIGTERM') launch.owner.signal('SIGKILL') - await expect(launch.direct).rejects.toThrow('exited without a direct-command result') + await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) await expect(launch.owner.waitForExit()).resolves.toBe(true) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 913d8ecbe1..f8374c2818 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -333,7 +333,7 @@ describe('spawn runner transport', () => { }) - it('requires an event snapshot started after wrapper close before reporting a missing result', async () => { + it('requires an event snapshot started after result finalization before reporting a missing result', async () => { const staleRead = Promise.withResolvers>>() let readCount = 0 vi.resetModules() @@ -351,12 +351,12 @@ describe('spawn runner transport', () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - const closed = Promise.withResolvers() + const finalized = Promise.withResolvers() const isolated = await import('../src/runner-launch.ts') - const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) + const result = isolated.runnerDirectResult(fakeChild(123), files, finalized.promise) expect(result.failureReported).toBe(false) expect(readCount).toBe(1) - closed.resolve(undefined) + finalized.resolve(undefined) await Promise.resolve() appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) staleRead.resolve([{ type: 'started', pid: 456 }]) From 0c80f9e7a1229c35bd58e4beca0783973ec8fd7a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 05:05:31 +0800 Subject: [PATCH 047/110] test(subprocess): align native evidence with plan --- ...20-subprocess-native-containment.i18n.yaml | 4 ++-- ...026-08-20-subprocess-native-containment.md | 4 ++-- ...-08-20-subprocess-native-containment.zh.md | 4 ++-- .../tests/native-containment.spec.ts | 21 ---------------- .../tests/native-windows.spec.ts | 24 +------------------ vitest.config.ts | 10 -------- 6 files changed, 7 insertions(+), 60 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index ac616f1941..bffcedcaac 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 19fc275370e6262a7d0a31042d41f1c1fa0c9477 -2026-08-20-subprocess-native-containment.zh.md: ca96b6e30094a5d4277e9fb9acd2a1e49fec1757 +2026-08-20-subprocess-native-containment.md: 334a5a89056eb4fbc2a22874c81530dc0e6c8a8c +2026-08-20-subprocess-native-containment.zh.md: fec48b641f286a026fc7e78b03e3a45a24d74c61 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 19fc275370..334a5a8905 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -14,13 +14,13 @@ The local subprocess provider treated a POSIX process group or a Windows direct- The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target; if scope KILL prevents a final target event, `.done` rejects rather than inventing an outcome. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. +Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. 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. ## Verification -Linux native evidence ran against an Ubuntu 24.04 x86_64 user manager with systemd 255.4 and covers a real `setsid` descendant, a double-fork daemon whose direct parent exits first, and Node-shaped spawn failures without replay. Windows native evidence covers default Job inheritance, raw stdin after startup, direct stdout/stderr EOF while a descendant remains, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. +Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `setsid` and reparenting scenario plus Node-shaped spawn failures without replay. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index ca96b6e300..fec48b641f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -14,13 +14,13 @@ Status: implemented common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标;scope KILL 阻止最终 target event 时,`.done` 会拒绝,而不会虚构 outcome。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 +Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 ## Verification -Linux native 证据已在 Ubuntu 24.04 x86_64、systemd 255.4 的 user manager 上运行,覆盖真实 `setsid` descendant、direct parent 先退出的 double-fork daemon,以及不重放的 Node-shaped spawn failure。Windows native 证据覆盖默认 Job inheritance、启动后的 raw stdin、descendant 仍存活时 direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 +Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真实 `setsid` 与 reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 ## Alternatives considered diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 7ead41fb22..5b023db576 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -70,27 +70,6 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { await waitGone(descendant) }) - it('keeps direct outcome separate from a double-fork descendant, then reaps the range', async () => { - const pidFile = join(scratch, `double-fork-${Date.now()}.pid`) - const script = [ - 'import os, signal, time', - 'if os.fork() > 0: os._exit(0)', - 'os.setsid()', - 'if os.fork() > 0: os._exit(0)', - `open(${JSON.stringify(pidFile)}, 'w').write(str(os.getpid()))`, - 'signal.signal(signal.SIGTERM, signal.SIG_IGN)', - 'while True: time.sleep(60)', - ].join('\n') - const request = spec(['python3', '-c', script], 80) - const handle = bindManagedProcess(request, launchLinuxScope(request)) - const descendant = await waitForPid(pidFile) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) - handle.terminate() - await expect(handle.waitForExit()).resolves.toBe(true) - await waitGone(descendant) - }) - it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => { const missing = spec([`missing-native-target-${Date.now()}`]) const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing)) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index c22c42608d..1c4025389f 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -96,29 +96,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { expect(readFileSync(output, 'utf8')).toBe('after-handshake') }) - it('terminates the direct target and its default-inheritance descendant', async () => { - const pidFile = join(scratch, `job-child-${Date.now()}.pid`) - const script = ` - const { spawn } = require('node:child_process') - const { writeFileSync } = require('node:fs') - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: true }) - writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) - setInterval(() => {}, 1000) - ` - const request = spec([process.execPath, '-e', script]) - const handle = bindManagedProcess(request, launchWindowsJob(request)) - const descendant = await waitForPid(pidFile) - try { - handle.terminate() - await handle.done - await expect(handle.waitForExit()).resolves.toBe(true) - await waitGone(descendant) - } finally { - cleanup(descendant) - } - }) - - it('reports direct exit before the inherited descendant leaves the Job', async () => { + 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`) const script = ` diff --git a/vitest.config.ts b/vitest.config.ts index 1eb7f5cc49..9c8c0730dd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -72,15 +72,6 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' // executes only on win32; its decision logic is unit-pinned on every // host through the injected-internals suites. 'packages/subprocess/subprocess-local/src/windows-inspector.ts', - 'packages/subprocess/subprocess-local/src/windows-job.ts', - ] - : [] - -const linuxOnlyCoverageExclusions = process.platform !== 'linux' - ? [ - // Native scope ownership executes only on Linux; its command and result - // decisions are unit-pinned on every host through injected runners. - 'packages/subprocess/subprocess-local/src/linux-scope.ts', ] : [] @@ -311,7 +302,6 @@ export default defineConfig({ ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, - ...linuxOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 6e1b8eda773e0edf0a4b40ed363553e8c4f2f910 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 05:30:09 +0800 Subject: [PATCH 048/110] fix(subprocess): preserve runner result boundary --- .../subprocess-local/src/linux-scope.ts | 20 +++++------------- .../subprocess-local/src/runner-launch.ts | 21 ++++++++++--------- .../tests/linux-scope.spec.ts | 2 +- .../tests/spawn-runner.spec.ts | 8 +++---- 4 files changed, 21 insertions(+), 30 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 287f4a23a7..cd83e155f0 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -214,7 +214,6 @@ export function launchLinuxScope( }) const closed = observeChildClose(child) let forceKillAttempted = false - let scopeSettled = false const owner = new SystemdScopeOwner( `${unitBase}.scope`, systemctl, @@ -223,20 +222,11 @@ export function launchLinuxScope( child, () => { forceKillAttempted = true }, ) - const resultFinalized = closed.then(async () => { - try { - await owner.waitForExit() - scopeSettled = true - } catch (_rangeObservationFailed) { - // waitForExit retains the authoritative owner failure for its caller. - } - }) - const result = runnerDirectResult(child, files, resultFinalized) - const direct = result.direct.catch((error: unknown): SubprocessOutcome => { - if (forceKillAttempted && scopeSettled && error instanceof DirectResultUnavailableError) { - return { exitCode: null, signal: 'SIGKILL' } - } - throw error + const result = runnerDirectResult(child, files, closed) + const direct = result.direct.catch(async (error: unknown): Promise => { + if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error + await owner.waitForExit() + return { exitCode: null, signal: 'SIGKILL' } }) cleanupAfterRunner(files, direct, closed) return { diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 276ed9df14..8d696dc6f8 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -115,22 +115,23 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], - resultFinalized: Promise, + closed: Promise, ): Promise { let seen = 0 - const resultState = { finalized: false } - void resultFinalized.then(() => { resultState.finalized = true }) + const wrapperState = { closed: false } + void closed.then(() => { wrapperState.closed = true }) for (;;) { - // A read started before finalization may return a stale snapshot. Only a - // read started afterward can prove no terminal event remains forthcoming. - const finalizedBeforeRead = resultState.finalized + // A read started before close may return a stale snapshot after close has + // become visible. Only a read started after close can prove no terminal + // event was written before the runner exited. + const closedBeforeRead = wrapperState.closed const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) } seen = Math.max(seen, events.length, initial.length) - if (finalizedBeforeRead) { + if (closedBeforeRead) { throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') } await sleepMs(RUNNER_EVENT_POLL_MS) @@ -141,13 +142,13 @@ async function waitForDirectResult( * Bind runner events into one direct result while preserving the target pid. * @param child - native wrapper process. * @param files - private request and result paths. - * @param resultFinalized - platform proof that no later runner event can arrive. + * @param closed - wrapper close observation attached before the start handshake. * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure. */ export function runnerDirectResult( child: ChildProcess, files: RunnerFiles, - resultFinalized: Promise, + closed: Promise, ): { pid: number direct: Promise @@ -162,7 +163,7 @@ export function runnerDirectResult( } return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, resultFinalized), + direct: waitForDirectResult(files, handshake.events, closed), failureReported: handshake.failureReported, } } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index c7951d98c0..d71f5190bf 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -113,7 +113,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(systemdArgs).not.toContain('literal $VALUE') }) - it('uses a successful scope KILL when the runner cannot publish the direct result', async () => { + it('uses a scope KILL after the owner proves the range empty', async () => { let wrapper: ReturnType | undefined const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index f8374c2818..913d8ecbe1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -333,7 +333,7 @@ describe('spawn runner transport', () => { }) - it('requires an event snapshot started after result finalization before reporting a missing result', async () => { + it('requires an event snapshot started after wrapper close before reporting a missing result', async () => { const staleRead = Promise.withResolvers>>() let readCount = 0 vi.resetModules() @@ -351,12 +351,12 @@ describe('spawn runner transport', () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - const finalized = Promise.withResolvers() + const closed = Promise.withResolvers() const isolated = await import('../src/runner-launch.ts') - const result = isolated.runnerDirectResult(fakeChild(123), files, finalized.promise) + const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) expect(result.failureReported).toBe(false) expect(readCount).toBe(1) - finalized.resolve(undefined) + closed.resolve(undefined) await Promise.resolve() appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) staleRead.resolve([{ type: 'started', pid: 456 }]) From 610ed618e97141ca3534c1c99690abb6a1fac695 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 06:04:54 +0800 Subject: [PATCH 049/110] fix(subprocess): release Windows target cwd --- .../subprocess-local/src/spawn-runner.ts | 27 ++++++++++++------- .../tests/native-windows.spec.ts | 11 +++++--- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 52891b89ce..bc0fef4f2d 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -165,18 +165,25 @@ async function runWin32( } targetCreationAttempted = true // Match Node's cwd-relative executable lookup and spawn-error attribution. + const runnerCwd = process.cwd() process.chdir(request.cwd) - const [command, ...args] = request.argv - const spawned = spawnOrdinaryJobProcess( - api, - { command: command as string, args, cwd: process.cwd() }, - stdio, - ) - processHandle = spawned.process - jobHandle = spawned.job - targetStarted = true + let targetPid: number + try { + const [command, ...args] = request.argv + const spawned = spawnOrdinaryJobProcess( + api, + { command: command as string, args, cwd: process.cwd() }, + stdio, + ) + processHandle = spawned.process + jobHandle = spawned.job + targetPid = spawned.pid + targetStarted = true + } finally { + process.chdir(runnerCwd) + } closeStdioHandles(api, openedStdio, true) - appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) + appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid }) await new Promise((resolve, reject) => { let settled = false diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 1c4025389f..d42328df58 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -99,10 +99,13 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { 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`) + const targetCwd = join(scratch, `target-cwd-${Date.now()}`) + mkdirSync(targetCwd) const script = ` const { spawn } = require('node:child_process') const { writeFileSync } = require('node:fs') - const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', detached: true }) + const { dirname } = require('node:path') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { cwd: dirname(process.execPath), stdio: 'ignore', detached: true }) writeFileSync(${JSON.stringify(pidFile)}, String(child.pid)) writeFileSync(${JSON.stringify(factsFile)}, JSON.stringify({ cwd: process.cwd(), value: process.env.TARGET_VALUE, arg: process.argv[1] })) child.unref() @@ -112,6 +115,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { ` const request = { ...spec([process.execPath, '-e', script, 'literal $HOME ${UNCHANGED}'], 100, { TARGET_VALUE: 'explicit' }), + cwd: targetCwd, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } as const, } const handle = bindManagedProcess(request, launchWindowsJob(request)) @@ -135,10 +139,11 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { new Promise(resolve => setTimeout(() => { resolve(false) }, 5_000)), ])).resolves.toBe(true) expect(readFileSync(factsFile, 'utf8')).toBe(JSON.stringify({ - cwd: scratch, + cwd: targetCwd, value: 'explicit', arg: 'literal $HOME ${UNCHANGED}', })) + rmSync(targetCwd) await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) From 923f919b49a6cdc3923c98f62225de011eb5b451 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 06:22:13 +0800 Subject: [PATCH 050/110] test(subprocess): remove Windows cwd recursively --- .../subprocess/subprocess-local/tests/native-windows.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index d42328df58..4788256d07 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -143,7 +143,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { value: 'explicit', arg: 'literal $HOME ${UNCHANGED}', })) - rmSync(targetCwd) + rmSync(targetCwd, { recursive: true }) await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) From 7228c821b7a881d6cadead1033294733354ffb72 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 06:40:12 +0800 Subject: [PATCH 051/110] refactor(subprocess): centralize stream settlement --- .../subprocess-local/src/linux-scope.ts | 1 - .../subprocess-local/src/managed-owner.ts | 1 - .../subprocess/subprocess-local/src/spawn.ts | 33 ++++++++++------- .../subprocess-local/src/windows-job.ts | 26 ------------- .../tests/linux-scope.spec.ts | 1 - .../tests/managed-spawn.spec.ts | 37 +++++++------------ .../subprocess-local/tests/spawn.spec.ts | 11 +++--- .../tests/windows-job.spec.ts | 20 +++++----- 8 files changed, 51 insertions(+), 79 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index cd83e155f0..eb356b484c 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -235,7 +235,6 @@ export function launchLinuxScope( stderr: child.stderr, pid: result.pid, direct, - closed, owner, } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 87a51ff541..ac05b7893b 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -24,7 +24,6 @@ export interface ManagedProcessLaunch { stderr: Readable | null pid: number direct: Promise - closed: Promise owner: BoundProcessOwner } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index d7f23108b4..12048920a6 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -26,7 +26,7 @@ import type { SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { waitWithAbort } from './managed-owner.ts' import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts' /** @@ -431,7 +431,6 @@ export function bindManagedProcess( launch: ManagedProcessLaunch, internals: Pick = {}, ): LocalSubprocessHandle { - validateSubprocessSpec(spec) const { spillDir } = prepareManagedProcessBinding(internals) const { stdin, stdout, stderr } = launch @@ -449,6 +448,24 @@ export function bindManagedProcess( } const stdoutCollector = collectStream(outMode, stdout, 'stdout') const stderrCollector = collectStream(errMode, stderr, 'stderr') + const observeCollectedStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise => { + if (!isCollect(mode) || stream === null || stream.readableEnded || stream.destroyed) return Promise.resolve() + return new Promise((resolve) => { + const settle = (): void => { + stream.off('end', settle) + stream.off('close', settle) + stream.off('error', settle) + resolve() + } + stream.once('end', settle) + stream.once('close', settle) + stream.once('error', settle) + }) + } + const collectedStreamsClosed = Promise.all([ + observeCollectedStream(outMode, stdout), + observeCollectedStream(errMode, stderr), + ]) const stopCollectors = (): void => { if (stdoutCollector !== undefined) stdout?.destroy() if (stderrCollector !== undefined) stderr?.destroy() @@ -508,8 +525,6 @@ export function bindManagedProcess( const done = new Promise((resolve, reject) => { let pipeDrainTimer: ReturnType | undefined - let directOutcome: SubprocessOutcome | undefined - let wrapperClosed = false const settle = (outcome: SubprocessOutcome): void => { if (settled) return settled = true @@ -520,13 +535,12 @@ export function bindManagedProcess( resolve(outcome) } launch.direct.then((outcome) => { - directOutcome = outcome if (stdoutCollector === undefined && stderrCollector === undefined) { settle(outcome) return } pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) - if (wrapperClosed) settle(outcome) + void collectedStreamsClosed.then(() => { settle(outcome) }) }, (error: unknown) => { /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */ if (settled) return @@ -536,10 +550,6 @@ export function bindManagedProcess( cleanup() reject(error instanceof Error ? error : new Error(String(error))) }) - void launch.closed.then(() => { - wrapperClosed = true - if (directOutcome !== undefined) settle(directOutcome) - }) function cleanup(): void { // graceTimer deliberately NOT cleared: forced termination must still // reach range survivors after the spawned command settles. @@ -578,7 +588,6 @@ export function bindManagedProcess( * @returns live subprocess handle. */ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { - validateSubprocessSpec(spec) const binding = prepareManagedProcessBinding(internals) const platform = internals.platform ?? process.platform const [program, ...args] = spec.argv @@ -592,7 +601,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter ], detached: platform !== 'win32', }) - const closed = observeChildClose(child) const direct = directChildResult(child) const pid = child.pid ?? -1 const owner = fallbackOwner( @@ -609,7 +617,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter stderr: child.stderr, pid, direct, - closed, owner, }, binding) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 6031c91748..00808703f1 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -2,7 +2,6 @@ import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' -import type { Readable } from 'node:stream' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { observeChildClose, waitWithAbort } from './managed-owner.ts' @@ -16,26 +15,6 @@ import { import { cleanupRunnerFiles } from './runner-protocol.ts' import { createWindowsStdioBridge } from './windows-stdio.ts' -function observeCollectedStream( - mode: SubprocessSpawnSpec['stdio']['stdout'], - stream: Readable | null, -): Promise { - if (mode === 'pipe' || mode === 'inherit') return Promise.resolve() - // The bridge creates collect streams synchronously before the runner starts. - const collected = stream as Readable - return new Promise((resolve) => { - const settle = (): void => { - collected.off('end', settle) - collected.off('close', settle) - collected.off('error', settle) - resolve() - } - collected.once('end', settle) - collected.once('close', settle) - collected.once('error', settle) - }) -} - /** Test seams for the runner process. */ export interface WindowsJobInternals { spawn?: typeof spawn @@ -156,10 +135,6 @@ export function launchWindowsJob( throw error } const runnerClosed = observeChildClose(child) - const closed = Promise.all([ - observeCollectedStream(spec.stdio.stdout, stdio.stdout), - observeCollectedStream(spec.stdio.stderr, stdio.stderr), - ]).then(() => undefined) const result = runnerDirectResult(child, files, runnerClosed) const owner = new WindowsJobOwner(child, result.failureReported) void result.direct.then( @@ -173,7 +148,6 @@ export function launchWindowsJob( stderr: stdio.stderr, pid: result.pid, direct: result.direct, - closed, owner, } } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index d71f5190bf..3f93ae28cc 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -273,7 +273,6 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(launch.pid).toBe(-1) await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBe(true) - await launch.closed }) it('does not fabricate a direct outcome after a non-forced scope signal', async () => { diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 3e01cf8ca8..17f226910a 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process' +import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner } from '../src/managed-owner.ts' -import { observeChildClose, waitWithAbort } from '../src/managed-owner.ts' +import { waitWithAbort } from '../src/managed-owner.ts' import { bindManagedProcess } from '../src/spawn.ts' function spec(graceMs = 30): SubprocessSpawnSpec { @@ -67,7 +68,6 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: 4242, direct: direct.promise, - closed: observeChildClose(wrapper), owner, }) direct.resolve({ exitCode: 42, signal: null }) @@ -93,34 +93,29 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), - closed: observeChildClose(wrapper), owner: { signal, waitForExit: async () => true }, }) handle.terminateForHostExit() expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') }) - it('settles when the wrapper closes before the direct outcome arrives', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) + it('settles when collected streams close before the direct outcome arrives', async () => { + const stdout = new PassThrough() + const stderr = new PassThrough() const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: wrapper.pid as number, + stdin: null, + stdout, + stderr, + pid: 4242, direct: direct.promise, - closed: Promise.resolve(), owner: { signal: vi.fn(), waitForExit: async () => true }, }) - try { - await new Promise(resolve => setImmediate(resolve)) - direct.resolve({ exitCode: 23, signal: null }) - await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null }) - } finally { - wrapper.kill('SIGKILL') - } + stdout.end() + stderr.end() + await new Promise(resolve => setImmediate(resolve)) + direct.resolve({ exitCode: 23, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null }) }) it('publishes direct outcome immediately when no collected stream needs draining', async () => { @@ -137,7 +132,6 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, - closed: new Promise(() => {}), owner: { signal: vi.fn(), waitForExit: async () => true }, }) try { @@ -163,7 +157,6 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct: new Promise(() => {}), - closed: new Promise(() => {}), owner: { signal: vi.fn(), waitForExit: async () => { throw failure } }, }) try { @@ -188,7 +181,6 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct, - closed: new Promise(() => {}), owner: { signal, waitForExit: async () => true }, }) try { @@ -219,7 +211,6 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, - closed: Promise.resolve(), owner: { signal, waitForExit: async () => { await stopped.promise; return true }, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 0c54234051..512acb56e5 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -8,6 +8,7 @@ import { OutputCollector, spawnSubprocess, taskkillProcessTree, + validateSubprocessSpec, } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -165,7 +166,7 @@ describe('spawnSubprocess', () => { it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( 'rejects an invalid grace before spawning: %s', (graceMs) => { - expect(() => spawnSubprocess(spec('true', { graceMs }))) + expect(() => validateSubprocessSpec(spec('true', { graceMs }))) .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) }, ) @@ -310,7 +311,7 @@ describe('spawnSubprocess', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal }))) + expect(() => validateSubprocessSpec(spec('echo hi', { signal: controller.signal }))) .toThrow(/aborted before spawn: too late/) }) @@ -1029,11 +1030,11 @@ describe('coverage seams 2', () => { describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + expect(() => validateSubprocessSpec({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) }) it('rejects an empty program name before spawning', () => { - expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + expect(() => validateSubprocessSpec({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { @@ -1052,7 +1053,7 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => spawnSubprocess(spec('echo hi', { signal: bare }))) + expect(() => validateSubprocessSpec(spec('echo hi', { signal: bare }))) .toThrow(/aborted before spawn: aborted/) }) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 8d9005da18..145207eb0f 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { appendRunnerEvent } from '../src/runner-protocol.ts' +import { bindManagedProcess } from '../src/spawn.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' import type { WindowsStdioBridge } from '../src/windows-stdio.ts' @@ -229,7 +230,7 @@ describe('Windows Job runner adapter', () => { } }) - it('cleans synchronous setup failures and waits for collected streams', async () => { + it('cleans synchronous setup failures and leaves collected-stream settlement to common binding', async () => { const bridgeFailure = new Error('bridge failed') const spawnFailure = new Error('spawn threw') const bridges: Array; closeInput: ReturnType }> = [] @@ -278,24 +279,25 @@ describe('Windows Job runner adapter', () => { setImmediate(() => { child.emit('close', 0, null) }) return child }) as unknown as typeof spawn - const launch = isolated.launchWindowsJob({ + const request = { ...spec(['collect']), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 }, }, - }, { spawn: run, runnerInvocation: ['fake-runner'] }) - let streamsSettled = false - void launch.closed.then(() => { streamsSettled = true }) - await launch.direct + } satisfies SubprocessSpawnSpec + const launch = isolated.launchWindowsJob(request, { spawn: run, runnerInvocation: ['fake-runner'] }) + const handle = bindManagedProcess(request, launch) + let doneSettled = false + void handle.done.then(() => { doneSettled = true }) await new Promise(resolve => setImmediate(resolve)) - expect(streamsSettled).toBe(false) + expect(doneSettled).toBe(false) collectedStreams?.stdout.emit('end') await Promise.resolve() - expect(streamsSettled).toBe(false) + expect(doneSettled).toBe(false) collectedStreams?.stderr.emit('error', new Error('stream closed')) - await expect(launch.closed).resolves.toBeUndefined() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce() } finally { vi.doUnmock('../src/windows-stdio.ts') From bde6a9a04155c22e76cde426853a708ef4b151a0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 06:51:45 +0800 Subject: [PATCH 052/110] test(subprocess): keep validation assertions explicit --- .../subprocess/subprocess-local/tests/spawn.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 512acb56e5..2eb2f42b36 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -166,7 +166,7 @@ describe('spawnSubprocess', () => { it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1])( 'rejects an invalid grace before spawning: %s', (graceMs) => { - expect(() => validateSubprocessSpec(spec('true', { graceMs }))) + expect(() => { validateSubprocessSpec(spec('true', { graceMs })) }) .toThrow(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) }, ) @@ -311,7 +311,7 @@ describe('spawnSubprocess', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => validateSubprocessSpec(spec('echo hi', { signal: controller.signal }))) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) .toThrow(/aborted before spawn: too late/) }) @@ -1030,11 +1030,11 @@ describe('coverage seams 2', () => { describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => validateSubprocessSpec({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [] }) }).toThrow(/non-empty program name/) }) it('rejects an empty program name before spawning', () => { - expect(() => validateSubprocessSpec({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + expect(() => { validateSubprocessSpec({ ...spec('true'), argv: [''] }) }).toThrow(/non-empty program name/) }) it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { @@ -1053,7 +1053,7 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => validateSubprocessSpec(spec('echo hi', { signal: bare }))) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) }) .toThrow(/aborted before spawn: aborted/) }) From 71e27e4667c97c901c6ca5d145e4a35a1d212be3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 07:35:51 +0800 Subject: [PATCH 053/110] fix(subprocess): close native lifecycle gaps --- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 2 +- ...-08-20-subprocess-native-containment.zh.md | 2 +- .../subprocess-local/src/linux-scope.ts | 20 ++++---- .../subprocess-local/src/managed-owner.ts | 32 +++++++------ .../subprocess-local/src/runner-launch.ts | 27 ++++++----- .../subprocess-local/src/spawn-runner.ts | 31 ++++++++---- .../subprocess/subprocess-local/src/spawn.ts | 23 +++++---- .../subprocess-local/src/windows-job.ts | 20 ++++---- .../tests/linux-scope.spec.ts | 21 ++++---- .../tests/managed-spawn.spec.ts | 42 ++++++++-------- .../tests/native-windows.spec.ts | 7 +++ .../tests/spawn-runner.spec.ts | 48 ++++++++++--------- .../tests/windows-job.spec.ts | 33 ++++--------- 14 files changed, 163 insertions(+), 149 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index bffcedcaac..f50a549f88 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md -2026-08-20-subprocess-native-containment.md: 334a5a89056eb4fbc2a22874c81530dc0e6c8a8c -2026-08-20-subprocess-native-containment.zh.md: fec48b641f286a026fc7e78b03e3a45a24d74c61 +2026-08-20-subprocess-native-containment.md: f1094f42cdec16e8b5d1717975fe108800c65cb8 +2026-08-20-subprocess-native-containment.zh.md: 63b9a4a9691b9d7a964bf4992cea9ec228e499ca diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 334a5a8905..f1094f42cd 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -14,7 +14,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct- The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, closes its pipe handles before publishing startup, and retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. +Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index fec48b641f..63b9a4a969 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -14,7 +14,7 @@ Status: implemented common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在发布启动事实前关闭自身 pipe handle,并保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 +Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index eb356b484c..df6455c5b3 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -6,10 +6,11 @@ import type { ChildProcess } from 'node:child_process' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { DirectResultUnavailableError, observeChildClose, waitWithAbort } from './managed-owner.ts' +import { DirectResultUnavailableError, observeChildLifecycle } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { cleanupAfterRunner, + type RunnerInvocation, runnerDirectResult, runnerFiles, runnerStdio, @@ -23,7 +24,7 @@ export interface LinuxScopeInternals { systemctlQuery?: (command: string, args: readonly string[]) => Promise systemdRun?: string systemctl?: string - runnerInvocation?: string[] + runnerInvocation?: RunnerInvocation } interface SystemctlResult { @@ -72,7 +73,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { const runSync = internals.spawnSync ?? spawnSync const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [runnerCommand, ...runnerPrefix] = invocation - if (runnerCommand === undefined) return false const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' const timeout = 5_000 @@ -117,7 +117,7 @@ class SystemdScopeOwner implements BoundProcessOwner { private readonly onForceKillAttempt: () => void, ) {} - signal(signal: NodeJS.Signals): void { + signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return const result = this.runSync(this.systemctl, [ '--user', @@ -165,13 +165,13 @@ class SystemdScopeOwner implements BoundProcessOwner { return true } - async waitForExit(signal?: AbortSignal): Promise { - if (this.stopped) return true + async waitForExit(): Promise { + if (this.stopped) return this.observation ??= (async () => { while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS) this.stopped = true })() - return waitWithAbort(this.observation, signal) + await this.observation } } @@ -212,7 +212,7 @@ export function launchLinuxScope( env: childEnv(), stdio: runnerStdio(spec), }) - const closed = observeChildClose(child) + const lifecycle = observeChildLifecycle(child) let forceKillAttempted = false const owner = new SystemdScopeOwner( `${unitBase}.scope`, @@ -222,13 +222,13 @@ export function launchLinuxScope( child, () => { forceKillAttempted = true }, ) - const result = runnerDirectResult(child, files, closed) + const result = runnerDirectResult(child, files, lifecycle.exited) const direct = result.direct.catch(async (error: unknown): Promise => { if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error await owner.waitForExit() return { exitCode: null, signal: 'SIGKILL' } }) - cleanupAfterRunner(files, direct, closed) + cleanupAfterRunner(files, direct, lifecycle.closed) return { stdin: child.stdin, stdout: child.stdout, diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index ac05b7893b..436653b96f 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -12,9 +12,9 @@ export class DirectResultUnavailableError extends Error { /** Platform owner used by termination and whole-range settlement. */ export interface BoundProcessOwner { /** Signal the established managed range; a confirmed-stopped owner stays inert. */ - signal(signal: NodeJS.Signals): void + signal(signal: 'SIGTERM' | 'SIGKILL'): void /** Wait for the same managed range to become empty; reject when its owner cannot be observed. */ - waitForExit(signal?: AbortSignal): Promise + waitForExit(): Promise } /** Platform launch facts consumed by the common stdio and result lifecycle. */ @@ -27,19 +27,23 @@ export interface ManagedProcessLaunch { owner: BoundProcessOwner } -/** - * Observe wrapper close from the moment it is spawned and contain its error - * event while the runner-result path converts launch failures into rejection. - * @param child - direct child or native wrapper. - * @returns promise settled by the ChildProcess close event. - */ -export function observeChildClose(child: ChildProcess): Promise { - return new Promise((resolve) => { - child.once('error', () => { - // runnerDirectResult reports the wrapper failure through the handle. - }) - child.once('close', () => { resolve() }) +/** Observe runner exit separately from inherited stdio closure. */ +export function observeChildLifecycle(child: ChildProcess): { + exited: Promise + closed: Promise +} { + const exited = Promise.withResolvers() + const closed = Promise.withResolvers() + child.once('error', () => { + // runnerDirectResult reports the wrapper failure through the handle. + exited.resolve() }) + child.once('exit', () => { exited.resolve() }) + child.once('close', () => { + exited.resolve() + closed.resolve() + }) + return { exited: exited.promise, closed: closed.promise } } /** diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 8d696dc6f8..4058b8b82f 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -17,7 +17,7 @@ import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol. import { DirectResultUnavailableError } from './managed-owner.ts' import { childEnv } from './spawn.ts' -let handshakeWait: Int32Array | undefined +const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' @@ -26,7 +26,9 @@ const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' * Resolve the runner entry from the current module's source or built plane. * @returns Node executable and runner argv prefix. */ -export function spawnRunnerInvocation(): string[] { +export type RunnerInvocation = [string, ...string[]] + +export function spawnRunnerInvocation(): RunnerInvocation { if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] /* v8 ignore start -- source-plane coverage cannot execute the bundled module; the required built-runner smoke executes its published entry. */ @@ -106,7 +108,6 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') - handshakeWait ??= new Int32Array(new SharedArrayBuffer(4)) Atomics.wait(handshakeWait, 0, 0, 5) } throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) @@ -115,23 +116,23 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner async function waitForDirectResult( files: RunnerFiles, initial: RunnerEvent[], - closed: Promise, + exited: Promise, ): Promise { let seen = 0 - const wrapperState = { closed: false } - void closed.then(() => { wrapperState.closed = true }) + const wrapperState = { exited: false } + void exited.then(() => { wrapperState.exited = true }) for (;;) { - // A read started before close may return a stale snapshot after close has - // become visible. Only a read started after close can prove no terminal + // A read started before exit may return a stale snapshot after exit has + // become visible. Only a read started after exit can prove no terminal // event was written before the runner exited. - const closedBeforeRead = wrapperState.closed + const exitedBeforeRead = wrapperState.exited const events = await readRunnerEventsAsync(files.eventsPath) for (const event of events.slice(seen)) { if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) } seen = Math.max(seen, events.length, initial.length) - if (closedBeforeRead) { + if (exitedBeforeRead) { throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') } await sleepMs(RUNNER_EVENT_POLL_MS) @@ -142,13 +143,13 @@ async function waitForDirectResult( * Bind runner events into one direct result while preserving the target pid. * @param child - native wrapper process. * @param files - private request and result paths. - * @param closed - wrapper close observation attached before the start handshake. + * @param exited - wrapper exit/error observation attached before the start handshake. * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure. */ export function runnerDirectResult( child: ChildProcess, files: RunnerFiles, - closed: Promise, + exited: Promise, ): { pid: number direct: Promise @@ -163,7 +164,7 @@ export function runnerDirectResult( } return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, closed), + direct: waitForDirectResult(files, handshake.events, exited), failureReported: handshake.failureReported, } } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index bc0fef4f2d..95a3b7e0c4 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -9,6 +9,7 @@ import { pollProcessExit, spawnOrdinaryJobProcess, terminateJob, + waitForProcessExit, Win32Error, } from '@deepseek-ai/dsh-win32-process' import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' @@ -71,7 +72,7 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 ? 'ENOENT' : error.win32Code === 5 - ? 'EPERM' + ? 'EACCES' : error.win32Code === 193 ? 'EFTYPE' : 'UNKNOWN' @@ -148,8 +149,6 @@ async function runWin32( const api = loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined - let targetStarted = false - let targetCreationAttempted = false const openedStdio: Array<{ handle: NativePtr; label: string }> = [] try { const stdio: ChildStdioHandles = {} @@ -163,7 +162,6 @@ async function runWin32( stdio[key] = handle openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) } - targetCreationAttempted = true // Match Node's cwd-relative executable lookup and spawn-error attribution. const runnerCwd = process.cwd() process.chdir(request.cwd) @@ -178,12 +176,11 @@ async function runWin32( processHandle = spawned.process jobHandle = spawned.job targetPid = spawned.pid - targetStarted = true } finally { process.chdir(runnerCwd) } - closeStdioHandles(api, openedStdio, true) appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid }) + closeStdioHandles(api, openedStdio, true) await new Promise((resolve, reject) => { let settled = false @@ -235,7 +232,8 @@ async function runWin32( }, 10) }) } catch (error) { - const targetSpawnFailed = targetCreationAttempted && !targetStarted + const targetSpawnFailed = (error instanceof Win32Error && error.api === 'CreateProcessW') + || (error instanceof Error && (error as NodeJS.ErrnoException).syscall === 'chdir') appendRunnerEvent(eventsPath, { type: targetSpawnFailed ? 'spawn-error' : 'runner-error', error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error), @@ -252,11 +250,28 @@ async function runWin32( } } +function probeWin32Job(): void { + const command = process.env.ComSpec ?? process.env.COMSPEC + if (command === undefined) throw new Error('subprocess runner cannot probe a Windows Job without ComSpec') + const api = loadWin32ProcessBindings() + const spawned = spawnOrdinaryJobProcess(api, { + command, + args: ['/d', '/s', '/c', 'exit 0'], + cwd: process.cwd(), + }) + try { + const exitCode = waitForProcessExit(api, spawned.process) + if (exitCode !== 0) throw new Error(`subprocess Windows Job probe exited with code ${String(exitCode)}`) + } finally { + closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe') + } +} + async function main(): Promise { const args = parseArgs(process.argv.slice(2)) if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { - loadWin32ProcessBindings() + probeWin32Job() return } const request = consumeRunnerRequest(args.requestPath) diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 12048920a6..160e1d0bb0 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -406,15 +406,15 @@ function fallbackOwner( } signalTree(platform, pid, signal, child, taskkill) }, - waitForExit: async (signal) => { + waitForExit: async () => { /* v8 ignore next -- bindManagedProcess memoizes this owner wait; the guard only protects direct internal re-entry after signal() observed absence. */ - if (stopped) return true + if (stopped) return observation ??= (async () => { while (alive()) await sleepTick() stopped = true })() - return waitWithAbort(observation, signal) + await observation }, } } @@ -448,8 +448,8 @@ export function bindManagedProcess( } const stdoutCollector = collectStream(outMode, stdout, 'stdout') const stderrCollector = collectStream(errMode, stderr, 'stderr') - const observeCollectedStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise => { - if (!isCollect(mode) || stream === null || stream.readableEnded || stream.destroyed) return Promise.resolve() + const observeOutputStream = (mode: SubprocessOutputMode, stream: Readable | null): Promise | undefined => { + if (mode === 'inherit' || stream === null || stream.readableEnded || stream.destroyed) return undefined return new Promise((resolve) => { const settle = (): void => { stream.off('end', settle) @@ -462,10 +462,9 @@ export function bindManagedProcess( stream.once('error', settle) }) } - const collectedStreamsClosed = Promise.all([ - observeCollectedStream(outMode, stdout), - observeCollectedStream(errMode, stderr), - ]) + const stdoutClosed = observeOutputStream(outMode, stdout) + const stderrClosed = observeOutputStream(errMode, stderr) + const outputStreamsClosed = Promise.all([stdoutClosed, stderrClosed]) const stopCollectors = (): void => { if (stdoutCollector !== undefined) stdout?.destroy() if (stderrCollector !== undefined) stderr?.destroy() @@ -494,7 +493,7 @@ export function bindManagedProcess( return rangeExitObservation } - const kill = (sig: NodeJS.Signals): void => { + const kill = (sig: 'SIGTERM' | 'SIGKILL'): void => { if (rangeExitObserved) return launch.owner.signal(sig) } @@ -535,12 +534,12 @@ export function bindManagedProcess( resolve(outcome) } launch.direct.then((outcome) => { - if (stdoutCollector === undefined && stderrCollector === undefined) { + if (stdoutClosed === undefined && stderrClosed === undefined) { settle(outcome) return } pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) - void collectedStreamsClosed.then(() => { settle(outcome) }) + void outputStreamsClosed.then(() => { settle(outcome) }) }, (error: unknown) => { /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */ if (settled) return diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 00808703f1..14b34422f3 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -4,10 +4,11 @@ import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { observeChildClose, waitWithAbort } from './managed-owner.ts' +import { observeChildLifecycle } from './managed-owner.ts' import { childEnv } from './spawn.ts' import { cleanupAfterRunner, + type RunnerInvocation, runnerDirectResult, runnerFiles, spawnRunnerInvocation, @@ -19,7 +20,7 @@ import { createWindowsStdioBridge } from './windows-stdio.ts' export interface WindowsJobInternals { spawn?: typeof spawn spawnSync?: typeof spawnSync - runnerInvocation?: string[] + runnerInvocation?: RunnerInvocation } /** @@ -30,7 +31,6 @@ export interface WindowsJobInternals { export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation - if (command === undefined) return false const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], { env: childEnv(), stdio: 'ignore', @@ -69,7 +69,7 @@ class WindowsJobOwner implements BoundProcessOwner { void this.observation.catch(() => {}) } - signal(_signal: NodeJS.Signals): void { + signal(_signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped || this.runnerClosed || this.startupFailureReported || this.runner.pid === undefined) return try { if (this.runner.connected) { @@ -84,8 +84,9 @@ class WindowsJobOwner implements BoundProcessOwner { } } - waitForExit(signal?: AbortSignal): Promise { - return this.stopped ? Promise.resolve(true) : waitWithAbort(this.observation, signal) + async waitForExit(): Promise { + if (this.stopped) return + await this.observation } } @@ -102,7 +103,6 @@ export function launchWindowsJob( const run = internals.spawn ?? spawn const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation - if (command === undefined) throw new Error('subprocess-local: Windows runner invocation is empty') const files = runnerFiles(spec) let stdio: ReturnType try { @@ -134,14 +134,14 @@ export function launchWindowsJob( cleanupRunnerFiles(files) throw error } - const runnerClosed = observeChildClose(child) - const result = runnerDirectResult(child, files, runnerClosed) + const lifecycle = observeChildLifecycle(child) + const result = runnerDirectResult(child, files, lifecycle.exited) const owner = new WindowsJobOwner(child, result.failureReported) void result.direct.then( () => { stdio.closeInput() }, () => { stdio.dispose() }, ) - cleanupAfterRunner(files, result.direct, runnerClosed) + cleanupAfterRunner(files, result.direct, lifecycle.closed) return { stdin: stdio.stdin, stdout: stdio.stdout, diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 3f93ae28cc..d74cadad70 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -38,7 +38,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () environments.push(options?.env) return { status: 0, error: undefined } }) as unknown as typeof spawnSync - const runnerInvocation = ['node-runtime', 'runner-entry.js'] + const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js'] try { expect(probeLinuxScope({ spawnSync: runSync, @@ -72,9 +72,6 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, })).toBe(false) - const emptyInvocation = vi.fn() as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: emptyInvocation, runnerInvocation: [] })).toBe(false) - expect(emptyInvocation).not.toHaveBeenCalled() }) it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { @@ -102,8 +99,8 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() const callsBeforeStaleSignal = runSyncMock.mock.calls.length launch.owner.signal('SIGKILL') expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) @@ -149,7 +146,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () launch.owner.signal('SIGTERM') launch.owner.signal('SIGKILL') await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it('rejects wait when the selected native owner becomes unreadable', async () => { @@ -232,7 +229,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) }, ) @@ -255,7 +252,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () systemctlQuery: asyncQuery(runSync), runnerInvocation: spawnRunnerInvocation(), }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) expect(runSyncMock.mock.calls.length).toBeGreaterThan(1) }) @@ -272,7 +269,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) expect(launch.pid).toBe(-1) await expect(launch.direct).rejects.toThrow('runner failed to start') - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it('does not fabricate a direct outcome after a non-forced scope signal', async () => { @@ -300,7 +297,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) launch.owner.signal('SIGTERM') await expect(launch.direct).rejects.toThrow('exited without a direct-command result') - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it.each([ @@ -396,7 +393,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () expect(defaults.probeLinuxScope()).toBe(true) const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 17f226910a..913958ad95 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -50,16 +50,9 @@ describe('managed process binding', () => { stopped.resolve(undefined) } }, - async waitForExit(signal) { - if (ownerStopped) return true - if (signal?.aborted) return false - if (signal === undefined) { - await stopped.promise - return true - } - const aborted = Promise.withResolvers() - signal.addEventListener('abort', () => { aborted.resolve(false) }, { once: true }) - return Promise.race([stopped.promise.then(() => true), aborted.promise]) + async waitForExit() { + if (ownerStopped) return + await stopped.promise }, } const handle = bindManagedProcess(spec(), { @@ -93,28 +86,37 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: 4242, direct: Promise.resolve({ exitCode: 0, signal: null }), - owner: { signal, waitForExit: async () => true }, + owner: { signal, waitForExit: async () => {} }, }) handle.terminateForHostExit() expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') }) - it('settles when collected streams close before the direct outcome arrives', async () => { + it('waits for raw and collected output streams after the direct outcome', async () => { const stdout = new PassThrough() const stderr = new PassThrough() const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() - const handle = bindManagedProcess(spec(), { + const request = { + ...spec(), + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const, + } + const handle = bindManagedProcess(request, { stdin: null, stdout, stderr, pid: 4242, direct: direct.promise, - owner: { signal: vi.fn(), waitForExit: async () => true }, + owner: { signal: vi.fn(), waitForExit: async () => {} }, }) - stdout.end() - stderr.end() - await new Promise(resolve => setImmediate(resolve)) + let doneSettled = false + void handle.done.then(() => { doneSettled = true }) direct.resolve({ exitCode: 23, signal: null }) + await Promise.resolve() + expect(doneSettled).toBe(false) + stdout.end() + await Promise.resolve() + expect(doneSettled).toBe(false) + stderr.end() await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null }) }) @@ -132,7 +134,7 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct: direct.promise, - owner: { signal: vi.fn(), waitForExit: async () => true }, + owner: { signal: vi.fn(), waitForExit: async () => {} }, }) try { direct.resolve({ exitCode: 23, signal: null }) @@ -181,7 +183,7 @@ describe('managed process binding', () => { stderr: wrapper.stderr, pid: wrapper.pid as number, direct, - owner: { signal, waitForExit: async () => true }, + owner: { signal, waitForExit: async () => {} }, }) try { await expect(handle.done).rejects.toThrow('runner failed') @@ -213,7 +215,7 @@ describe('managed process binding', () => { direct: direct.promise, owner: { signal, - waitForExit: async () => { await stopped.promise; return true }, + waitForExit: async () => { await stopped.promise }, }, }) direct.resolve({ exitCode: 0, signal: null }) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 4788256d07..87b52a1de7 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -166,6 +166,13 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) await expect(missingHandle.waitForExit()).resolves.toBe(true) + const accessDenied = spec([scratch]) + const expectedAccessDenied = await directSpawnFailure([scratch]) + const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied)) + await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) + await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: expectedAccessDenied.code }) + await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true) + const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 913d8ecbe1..9c5563cd2b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,5 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -13,7 +14,7 @@ import { runnerStdio, spawnRunnerInvocation, } from '../src/runner-launch.ts' -import { observeChildClose } from '../src/managed-owner.ts' +import { observeChildLifecycle } from '../src/managed-owner.ts' import { appendRunnerEvent, cleanupRunnerFiles, @@ -63,20 +64,6 @@ describe('spawn runner transport', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) }) - it('does not require SharedArrayBuffer until a native handshake runs', async () => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer') - Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined }) - vi.resetModules() - try { - const isolated = await import('../src/runner-launch.ts') - expect(isolated.runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) - } finally { - if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer') - else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor) - vi.resetModules() - } - }) - it('re-enters a packaged executable through its private runner dispatch', () => { const packagedProcess = process as NodeJS.Process & { pkg?: unknown } const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') @@ -333,7 +320,7 @@ describe('spawn runner transport', () => { }) - it('requires an event snapshot started after wrapper close before reporting a missing result', async () => { + it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => { const staleRead = Promise.withResolvers>>() let readCount = 0 vi.resetModules() @@ -351,12 +338,12 @@ describe('spawn runner transport', () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - const closed = Promise.withResolvers() + const exited = Promise.withResolvers() const isolated = await import('../src/runner-launch.ts') - const result = isolated.runnerDirectResult(fakeChild(123), files, closed.promise) + const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise) expect(result.failureReported).toBe(false) expect(readCount).toBe(1) - closed.resolve(undefined) + exited.resolve(undefined) await Promise.resolve() appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) staleRead.resolve([{ type: 'started', pid: 456 }]) @@ -369,18 +356,35 @@ describe('spawn runner transport', () => { } }) + it('reports a missing direct result at runner exit without waiting for pipe close', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) + const child = new EventEmitter() as ChildProcess + Object.assign(child, { pid: 123, exitCode: null, signalCode: null }) + const lifecycle = observeChildLifecycle(child) + const result = runnerDirectResult(child, files, lifecycle.exited) + child.emit('exit', 1, null) + await expect(result.direct).rejects.toThrow('exited without a direct-command result') + child.emit('close', 1, null) + await lifecycle.closed + } finally { + cleanupRunnerFiles(files) + } + }) + it('contains wrapper spawn errors while publishing the runner startup rejection', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], { stdio: 'ignore', }) - const closed = observeChildClose(child) - const result = runnerDirectResult(child, files, closed) + const lifecycle = observeChildLifecycle(child) + const result = runnerDirectResult(child, files, lifecycle.exited) expect(result.pid).toBe(-1) expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toThrow('runner failed to start') - await expect(closed).resolves.toBeUndefined() + await expect(lifecycle.closed).resolves.toBeUndefined() } finally { cleanupRunnerFiles(files) } diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 145207eb0f..7434efb1fb 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -6,12 +6,11 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { appendRunnerEvent } from '../src/runner-protocol.ts' -import { bindManagedProcess } from '../src/spawn.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' import type { WindowsStdioBridge } from '../src/windows-stdio.ts' const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) -const invocation = [process.execPath, '--import', 'tsx/esm', fixture] +const invocation: [string, ...string[]] = [process.execPath, '--import', 'tsx/esm', fixture] function spec(argv: string[]): SubprocessSpawnSpec { return { @@ -31,7 +30,6 @@ describe('Windows Job runner adapter', () => { [...invocation.slice(1), '--mode', 'probe-win32'], expect.objectContaining({ stdio: 'ignore' }), ) - expect(probeWindowsJob({ runnerInvocation: [] })).toBe(false) expect(probeWindowsJob({ spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, runnerInvocation: invocation, @@ -49,7 +47,7 @@ describe('Windows Job runner adapter', () => { }) expect(launch.pid).toBeGreaterThan(0) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it('signals the Job runner and waits for its managed range to stop', async () => { @@ -59,7 +57,7 @@ describe('Windows Job runner adapter', () => { }) launch.owner.signal('SIGTERM') await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() launch.owner.signal('SIGKILL') }) @@ -110,7 +108,7 @@ describe('Windows Job runner adapter', () => { expect(send).not.toHaveBeenCalled() expect(kill).not.toHaveBeenCalled() child.emit('close', 0, null) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it('treats a wrapper that never started as an empty managed range', async () => { @@ -124,7 +122,7 @@ describe('Windows Job runner adapter', () => { launch.owner.signal('SIGTERM') expect(kill).not.toHaveBeenCalled() child.emit('close', -2, null) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { @@ -190,10 +188,7 @@ describe('Windows Job runner adapter', () => { await launch.owner.waitForExit() }) - it('uses production runner defaults and rejects an empty invocation', async () => { - expect(() => launchWindowsJob(spec(['fake-target']), { runnerInvocation: [] })) - .toThrow('Windows runner invocation is empty') - + it('uses production runner defaults', async () => { const child = new EventEmitter() as ChildProcess Object.assign(child, { pid: 987, @@ -221,7 +216,7 @@ describe('Windows Job runner adapter', () => { appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) child.emit('close', 0, null) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBe(true) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() expect(run).toHaveBeenCalledOnce() expect(runSync).toHaveBeenCalledOnce() } finally { @@ -234,7 +229,6 @@ describe('Windows Job runner adapter', () => { const bridgeFailure = new Error('bridge failed') const spawnFailure = new Error('spawn threw') const bridges: Array; closeInput: ReturnType }> = [] - let collectedStreams: { stdout: PassThrough; stderr: PassThrough } | undefined vi.resetModules() vi.doMock('../src/windows-stdio.ts', async importOriginal => ({ ...await importOriginal(), @@ -242,7 +236,6 @@ describe('Windows Job runner adapter', () => { if (request.argv[0] === 'bridge-failure') throw bridgeFailure const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null - if (stdout !== null && stderr !== null) collectedStreams = { stdout, stderr } const bridge = { stdin: null, stdout, @@ -288,16 +281,8 @@ describe('Windows Job runner adapter', () => { }, } satisfies SubprocessSpawnSpec const launch = isolated.launchWindowsJob(request, { spawn: run, runnerInvocation: ['fake-runner'] }) - const handle = bindManagedProcess(request, launch) - let doneSettled = false - void handle.done.then(() => { doneSettled = true }) - await new Promise(resolve => setImmediate(resolve)) - expect(doneSettled).toBe(false) - collectedStreams?.stdout.emit('end') - await Promise.resolve() - expect(doneSettled).toBe(false) - collectedStreams?.stderr.emit('error', new Error('stream closed')) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce() } finally { vi.doUnmock('../src/windows-stdio.ts') From 63df2b31a70e181c1fe61046c9f035c6ecb6b0e4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 07:58:00 +0800 Subject: [PATCH 054/110] docs(subprocess): document runner lifecycle helpers --- packages/subprocess/subprocess-local/src/managed-owner.ts | 6 +++++- packages/subprocess/subprocess-local/src/runner-launch.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 436653b96f..59f899388c 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -27,7 +27,11 @@ export interface ManagedProcessLaunch { owner: BoundProcessOwner } -/** Observe runner exit separately from inherited stdio closure. */ +/** + * Observe runner exit separately from inherited stdio closure. + * @param child - native wrapper process. + * @returns promises for wrapper exit/error and full stdio closure. + */ export function observeChildLifecycle(child: ChildProcess): { exited: Promise closed: Promise diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 4058b8b82f..c247b7b433 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -22,12 +22,13 @@ const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' +/** Non-empty command tuple used to launch the private native runner. */ +export type RunnerInvocation = [string, ...string[]] + /** * Resolve the runner entry from the current module's source or built plane. * @returns Node executable and runner argv prefix. */ -export type RunnerInvocation = [string, ...string[]] - export function spawnRunnerInvocation(): RunnerInvocation { if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] /* v8 ignore start -- source-plane coverage cannot execute the bundled module; From b3fcca95985f450b52faa7c7671d6b2313560f66 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 08:00:30 +0800 Subject: [PATCH 055/110] refactor(subprocess): remove synthetic abort race --- .../subprocess-local/src/managed-owner.ts | 1 - .../tests/managed-spawn.spec.ts | 17 ----------------- 2 files changed, 18 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 59f899388c..895bba86ab 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -65,7 +65,6 @@ export async function waitWithAbort(pending: Promise, signal?: AbortSignal const aborted = Promise.withResolvers() const onAbort = (): void => { aborted.resolve(false) } signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) onAbort() try { return await Promise.race([pending.then(() => true), aborted.promise]) } finally { diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 913958ad95..54a1464ed6 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -3,7 +3,6 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner } from '../src/managed-owner.ts' -import { waitWithAbort } from '../src/managed-owner.ts' import { bindManagedProcess } from '../src/spawn.ts' function spec(graceMs = 30): SubprocessSpawnSpec { @@ -16,22 +15,6 @@ function spec(graceMs = 30): SubprocessSpawnSpec { } describe('managed process binding', () => { - it('closes the abort race after installing the wait listener', async () => { - let reads = 0 - const removeEventListener = vi.fn() - const signal = { - get aborted() { - reads += 1 - return reads > 1 - }, - addEventListener: vi.fn(), - removeEventListener, - } as unknown as AbortSignal - - await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) - expect(removeEventListener).toHaveBeenCalledOnce() - }) - it('keeps direct outcome separate from managed-range quiescence', async () => { const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'pipe'], From eb17028720b98ddd4212a2c7b57600903200e0ff Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 08:23:34 +0800 Subject: [PATCH 056/110] fix(subprocess): preserve post-start runner failures --- packages/subprocess/subprocess-local/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess-local/README.md | 2 +- packages/subprocess/subprocess-local/README.zh.md | 2 +- packages/subprocess/subprocess-local/src/spawn-runner.ts | 8 ++++---- .../subprocess-local/tests/managed-spawn.spec.ts | 8 ++++++-- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 5ffbb436c3..c2535a33e3 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 3fec4469657ea5a98ed37c8a72f727afce08c7b3 -README.zh.md: f615e8f6e31a81b711bf58d51da2601cccbf443c +README.md: 30dc28a0be9db966ef8ffc1286ffa07858c8f81d +README.zh.md: 1a5a6c17efad9ea2a1e0fb2980b23e61679a68f5 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 3fec446965..30dc28a0be 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, and closes those pipe handles before publishing startup. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. `.done` remains the direct command result, and only collected pipes retain the existing bounded drain grace. +- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index f615e8f6e3..1a5a6c17ef 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,并在发布启动事实前关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。`.done` 仍是 direct command result;只有 collected pipe 保留既有有界排空宽限期。 +- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 95a3b7e0c4..3722277a2f 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -165,7 +165,6 @@ async function runWin32( // Match Node's cwd-relative executable lookup and spawn-error attribution. const runnerCwd = process.cwd() process.chdir(request.cwd) - let targetPid: number try { const [command, ...args] = request.argv const spawned = spawnOrdinaryJobProcess( @@ -175,11 +174,10 @@ async function runWin32( ) processHandle = spawned.process jobHandle = spawned.job - targetPid = spawned.pid + appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) } finally { process.chdir(runnerCwd) } - appendRunnerEvent(eventsPath, { type: 'started', pid: targetPid }) closeStdioHandles(api, openedStdio, true) await new Promise((resolve, reject) => { @@ -233,7 +231,9 @@ async function runWin32( }) } catch (error) { const targetSpawnFailed = (error instanceof Win32Error && error.api === 'CreateProcessW') - || (error instanceof Error && (error as NodeJS.ErrnoException).syscall === 'chdir') + || (processHandle === undefined + && error instanceof Error + && (error as NodeJS.ErrnoException).syscall === 'chdir') appendRunnerEvent(eventsPath, { type: targetSpawnFailed ? 'spawn-error' : 'runner-error', error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error), diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 54a1464ed6..f089fcac5e 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -80,7 +80,7 @@ describe('managed process binding', () => { const stderr = new PassThrough() const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const request = { - ...spec(), + ...spec(1_000), stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const, } const handle = bindManagedProcess(request, { @@ -91,6 +91,7 @@ describe('managed process binding', () => { direct: direct.promise, owner: { signal: vi.fn(), waitForExit: async () => {} }, }) + stdout.resume() let doneSettled = false void handle.done.then(() => { doneSettled = true }) direct.resolve({ exitCode: 23, signal: null }) @@ -100,7 +101,10 @@ describe('managed process binding', () => { await Promise.resolve() expect(doneSettled).toBe(false) stderr.end() - await expect(handle.done).resolves.toEqual({ exitCode: 23, signal: null }) + await expect(Promise.race([ + handle.done, + new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)), + ])).resolves.toEqual({ exitCode: 23, signal: null }) }) it('publishes direct outcome immediately when no collected stream needs draining', async () => { From da042b8e1014619d3ed20e476c79f06025ce7785 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 08:46:33 +0800 Subject: [PATCH 057/110] test(subprocess): cover native settlement edges --- .../subprocess-local/src/managed-owner.ts | 7 +++- .../tests/managed-spawn.spec.ts | 26 ++++++++---- .../tests/native-windows.spec.ts | 41 +++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 895bba86ab..197fa81752 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -57,7 +57,12 @@ export function observeChildLifecycle(child: ChildProcess): { * @returns true on completion, false when the bound aborts first. */ export async function waitWithAbort(pending: Promise, signal?: AbortSignal): Promise { - if (signal?.aborted) return false + if (signal?.aborted) { + void pending.catch(() => { + // This caller declined the wait; a later caller still observes the cached rejection. + }) + return false + } if (signal === undefined) { await pending return true diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index f089fcac5e..deab3ea5c5 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -3,6 +3,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner } from '../src/managed-owner.ts' +import { waitWithAbort } from '../src/managed-owner.ts' import { bindManagedProcess } from '../src/spawn.ts' function spec(graceMs = 30): SubprocessSpawnSpec { @@ -15,6 +16,16 @@ function spec(graceMs = 30): SubprocessSpawnSpec { } describe('managed process binding', () => { + it('contains owner failure after an already-aborted wait returns false', async () => { + const controller = new AbortController() + const ownerFailure = Promise.withResolvers() + controller.abort() + + await expect(waitWithAbort(ownerFailure.promise, controller.signal)).resolves.toBe(false) + ownerFailure.reject(new Error('owner unavailable')) + await new Promise(resolve => setImmediate(resolve)) + }) + it('keeps direct outcome separate from managed-range quiescence', async () => { const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'pipe'], @@ -75,32 +86,31 @@ describe('managed process binding', () => { expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') }) - it('waits for raw and collected output streams after the direct outcome', async () => { + it.each([ + ['raw', 'pipe'], + ['collected', { maxBytes: 1024 }], + ] as const)('waits for %s output EOF after the direct outcome', async (_label, stdoutMode) => { const stdout = new PassThrough() - const stderr = new PassThrough() const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() const request = { ...spec(1_000), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 1024 } } as const, + stdio: { stdin: 'ignore', stdout: stdoutMode, stderr: 'inherit' } as const, } const handle = bindManagedProcess(request, { stdin: null, stdout, - stderr, + stderr: null, pid: 4242, direct: direct.promise, owner: { signal: vi.fn(), waitForExit: async () => {} }, }) - stdout.resume() + if (stdoutMode === 'pipe') stdout.resume() let doneSettled = false void handle.done.then(() => { doneSettled = true }) direct.resolve({ exitCode: 23, signal: null }) await Promise.resolve() expect(doneSettled).toBe(false) stdout.end() - await Promise.resolve() - expect(doneSettled).toBe(false) - stderr.end() await expect(Promise.race([ handle.done, new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)), diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 87b52a1de7..a8bd8bd814 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process' import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { bindManagedProcess } from '../src/spawn.ts' @@ -153,6 +154,46 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { } }) + it('publishes target identity before reporting cwd restoration failure', async () => { + const preload = join(scratch, `fail-runner-cwd-restore-${Date.now()}.mjs`) + writeFileSync(preload, ` + const originalChdir = process.chdir.bind(process) + let calls = 0 + process.chdir = (path) => { + calls += 1 + if (calls === 2) { + const error = new Error('injected runner cwd restoration failure') + error.code = 'ENOENT' + error.syscall = 'chdir' + throw error + } + originalChdir(path) + } + `) + const previousNodeOptions = process.env.NODE_OPTIONS + process.env.NODE_OPTIONS = [previousNodeOptions, `--import=${pathToFileURL(preload).href}`] + .filter((value): value is string => value !== undefined && value.length > 0) + .join(' ') + try { + const command = process.env.ComSpec ?? process.env.COMSPEC + if (command === undefined) throw new Error('expected ComSpec for the Windows runner test') + const request = spec([command, '/d', '/s', '/c', 'exit 0']) + const launch = launchWindowsJob(request) + expect(launch.pid).toBeGreaterThan(0) + const failure = await launch.direct.catch((error: unknown) => error) + expect(failure).toMatchObject({ + message: 'injected runner cwd restoration failure', + code: 'ENOENT', + syscall: 'chdir', + }) + expect(failure).not.toHaveProperty('path') + await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') + } finally { + if (previousNodeOptions === undefined) Reflect.deleteProperty(process.env, 'NODE_OPTIONS') + else process.env.NODE_OPTIONS = previousNodeOptions + } + }) + it('preserves missing-target and invalid-executable rejection errors', async () => { const relativeExecutable = `relative-node-${String(Date.now())}.exe` copyFileSync(process.execPath, join(scratch, relativeExecutable)) From df00bb9c448e85ceb93c07e2aea266b5d0acf5ab Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 08:58:18 +0800 Subject: [PATCH 058/110] test(subprocess): make stream EOF checks causal --- .../subprocess/subprocess-local/tests/managed-spawn.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index deab3ea5c5..7b428dab11 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -108,7 +108,7 @@ describe('managed process binding', () => { let doneSettled = false void handle.done.then(() => { doneSettled = true }) direct.resolve({ exitCode: 23, signal: null }) - await Promise.resolve() + await new Promise(resolve => setImmediate(resolve)) expect(doneSettled).toBe(false) stdout.end() await expect(Promise.race([ From ae77bc0b2f67b5a06b74267754cda060e20e5b2b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 22 Aug 2026 09:18:31 +0800 Subject: [PATCH 059/110] fix(subprocess): defer native wait allocation --- .../subprocess-local/src/runner-launch.ts | 2 +- .../subprocess-local/tests/spawn-runner.spec.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index c247b7b433..0609639175 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -17,7 +17,6 @@ import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol. import { DirectResultUnavailableError } from './managed-owner.ts' import { childEnv } from './spawn.ts' -const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' @@ -99,6 +98,7 @@ function runnerExited(child: ChildProcess, pid: number): boolean { /** Wait synchronously only until the runner reports target start or spawn failure. */ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): RunnerHandshake { + const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) const deadline = Date.now() + RUNNER_HANDSHAKE_TIMEOUT_MS while (Date.now() < deadline) { const events = readRunnerEvents(files.eventsPath) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 9c5563cd2b..ba326cad6a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -64,6 +64,20 @@ describe('spawn runner transport', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) }) + it('does not require SharedArrayBuffer until a native handshake runs', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer') + Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined }) + vi.resetModules() + try { + const isolated = await import('../src/runner-launch.ts') + expect(isolated.runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) + } finally { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer') + else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor) + vi.resetModules() + } + }) + it('re-enters a packaged executable through its private runner dispatch', () => { const packagedProcess = process as NodeJS.Process & { pkg?: unknown } const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') From 8e9bac4a27e70dc51153331cbb8b60d46fc3bb38 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 20:06:00 +0800 Subject: [PATCH 060/110] fix(subprocess): align native containment contract --- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 14 +- ...-08-20-subprocess-native-containment.zh.md | 14 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 4 +- docs/subsystems/subprocess.zh.md | 4 +- .../snapshots/cordis-inspect-jsdoc/input.json | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 28 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 4 +- packages/e2b/subprocess-e2b/README.zh.md | 4 +- packages/e2b/subprocess-e2b/src/process.ts | 16 +- .../subprocess-e2b/tests/subprocess.spec.ts | 4 +- .../src/client/api-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- packages/lsp/lsp-stdio/src/connection.ts | 4 +- .../lsp/lsp-stdio/tests/connection.spec.ts | 30 + .../shell/bash-sandbox/tests/sandbox.spec.ts | 2 +- .../shell/pwsh-local/tests/executor.spec.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 38 +- .../subagent-acp/tests/subagent-acp.spec.ts | 94 ++- .../subagent/subagent-claude-code/src/run.ts | 54 +- .../tests/subagent-claude-code.spec.ts | 54 +- packages/subagent/subagent-codex/src/run.ts | 45 +- .../tests/subagent-codex.spec.ts | 17 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 12 +- .../subprocess/subprocess-local/README.zh.md | 12 +- .../subprocess/subprocess-local/package.json | 2 +- .../subprocess/subprocess-local/src/bin.ts | 12 + .../subprocess/subprocess-local/src/index.ts | 55 +- .../subprocess-local/src/linux-scope.ts | 116 ++- .../subprocess-local/src/managed-owner.ts | 3 +- .../subprocess-local/src/runner-launch.ts | 42 +- .../subprocess-local/src/spawn-runner.ts | 191 +++-- .../subprocess/subprocess-local/src/spawn.ts | 26 +- .../subprocess-local/src/terminal.ts | 45 +- .../tests/linux-scope.spec.ts | 131 +++- .../subprocess-local/tests/local.spec.ts | 124 ++- .../tests/managed-spawn.spec.ts | 23 +- .../tests/native-containment.spec.ts | 149 +++- .../tests/spawn-runner.spec.ts | 738 +++++++++++++++++- .../subprocess-local/tests/spawn.spec.ts | 30 +- .../subprocess-local/tests/terminal.spec.ts | 108 +++ .../subprocess-local/tsdown.config.ts | 2 +- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 4 +- .../subprocess/tests/service.spec.ts | 17 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- .../subprocess/win32-process/src/index.ts | 4 +- .../subprocess/win32-process/src/process.ts | 10 +- .../tests/ordinary-process.spec.ts | 8 +- .../typert/generator/src/cordis-catalog.ts | 2 +- .../generator/tests/cordis-catalog.spec.ts | 4 +- vitest.config.ts | 8 - 59 files changed, 1973 insertions(+), 381 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/bin.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index f50a549f88..bf489f9217 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: f1094f42cdec16e8b5d1717975fe108800c65cb8 -2026-08-20-subprocess-native-containment.zh.md: 63b9a4a9691b9d7a964bf4992cea9ec228e499ca +2026-08-20-subprocess-native-containment.md: 7772284635435ecf11dbc0416021c8b701535275 +2026-08-20-subprocess-native-containment.zh.md: 538677ca4bd53192188d1a412f6cf649c32deac8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index f1094f42cd..7772284635 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -1,4 +1,4 @@ -# Agent Note: Ordinary subprocesses use native managed ranges where supported +# Agent Note: Local subprocesses use native managed ranges where supported Status: implemented @@ -10,17 +10,21 @@ The local subprocess provider treated a POSIX process group or a Windows direct- ## Decision -`LocalSubprocessRuntime` selects ordinary native containment once, before its first user command. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. +`LocalSubprocessRuntime` selects containment before every eligible ordinary or terminal user command; capability results are not cached, while the weaker-path warning is emitted at most once per provider. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows ordinary launch uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each native launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. The common spawn lifecycle still owns stdio dispositions, bounded collection, direct outcome, abort handling, termination scheduling, and host-exit registration. Linux scope and POSIX process-group owners deliver TERM and then KILL after the configured grace; Windows Job and `taskkill` owners force-terminate on the first request. `.done` comes from the target process. A private `0600` single-spawn request/event transport lets the Linux or Windows runner report Node-shaped target spawn failures and the target exit independently of the scope or Job lifetime. `waitForExit()` succeeds only after the same owner used by `terminate()` confirms that the OS range is empty; once confirmed, the owner permanently ignores later signals. -Linux user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. +Linux ordinary user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. + +Linux terminal launch passes `systemd-run --user --scope --quiet --collect --expand-environment=no -- ` directly to `node-pty`; `systemd-run --scope` replaces itself with the target, so node-pty continues to observe the target PID, session leader, process group, controlling terminal, foreground input wait, and prompt readiness. The terminal handle binds the same scope owner for normal termination and host-exit KILL, so a descendant that reparents or creates a new session remains in the managed range without a second PTY runner or a continuous process-table monitor. + +On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback. ## Verification -Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `setsid` and reparenting scenario plus Node-shaped spawn failures without replay. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. +Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs separate ordinary and node-pty `setsid`/reparenting scenarios plus Node-shaped spawn failures without replay. The PTY scenario pins the node-pty PID, process group, session leader, controlling terminal, `/dev/tty` input, foreground `inputWaiting`, and termination of the escaped descendant. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. ## Alternatives considered @@ -36,4 +40,4 @@ Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs one real `s ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. The first ordinary spawn probes capability once per provider instance with a 5-second bound per probe command. The local native path then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native range retains one runner process until settlement. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. Every eligible ordinary or terminal spawn probes capability before target execution with a 5-second bound per probe command. Native ordinary launch then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native ordinary range retains one runner process until settlement. Linux PTY launch adds no runner. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 63b9a4a969..538677ca4b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Ordinary subprocesses use native managed ranges where supported +# Agent Note: Local subprocesses use native managed ranges where supported Status: implemented @@ -10,17 +10,21 @@ Status: implemented ## Decision -`LocalSubprocessRuntime` 在首个用户命令之前只选择一次 ordinary native containment。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 +`LocalSubprocessRuntime` 会在每次符合条件的 ordinary 或 terminal 用户命令前选择 containment;capability 结果不会缓存,较弱路径的告警则由每个 provider 至多发出一次。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows ordinary launch 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 native launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 common spawn lifecycle 继续拥有 stdio disposition、有界收集、direct outcome、abort 处理、termination scheduling 与 host-exit 注册。Linux scope 与 POSIX 进程组 owner 先投递 TERM,并在配置的 grace 后投递 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`.done` 来自 target process。private `0600` single-spawn request/event transport 让 Linux 或 Windows runner 分别报告 Node-shaped target spawn failure 与 target exit,不依赖 scope 或 Job 生命周期。`waitForExit()` 只在 `terminate()` 使用的同一 owner 确认 OS range 为空后成功;首次确认后,该 owner 永久忽略后续 signal。 -Linux user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 +Linux ordinary user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。 + +Linux terminal launch 会把 `systemd-run --user --scope --quiet --collect --expand-environment=no -- <原始 argv>` 直接交给 `node-pty`;`systemd-run --scope` 会以 target 替换自身,因此 node-pty 继续观察 target PID、session leader、process group、控制终端、前台 input wait 与 prompt readiness。terminal handle 会为正常终止与 host-exit KILL 绑定同一个 scope owner,因此已 reparent 或新建 session 的 descendant 仍留在 managed range 内,无需第二个 PTY runner 或持续进程表 monitor。 + +Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 ## Verification -Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真实 `setsid` 与 reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 +Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境分别运行 ordinary 与 node-pty `setsid`/reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。PTY 场景固定 node-pty PID、process group、session leader、控制终端、`/dev/tty` 输入、前台 `inputWaiting`,以及 escaped descendant 的终止。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 ## Alternatives considered @@ -36,4 +40,4 @@ Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境运行一个真 ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。首条 ordinary spawn 会为每个 provider instance 探测一次能力,每条 probe command 的上限为 5 秒。本地 native 路径随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native range 还会保留一个 runner process 直到 settlement。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。每次符合条件的 ordinary 或 terminal spawn 都会在 target 执行前探测能力,每条 probe command 的上限为 5 秒。native ordinary launch 随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native ordinary range 还会保留一个 runner process 直到 settlement。Linux PTY launch 不增加 runner。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 8b51742982..dbed038726 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 701d70629587c202c50047fb88158f39179920d1 -subprocess.zh.md: 643998ae0210e0fa2cdf8d5140809df07beaa27b +subprocess.md: 2ee79c8d9605ebf8c375e7bfad84063f5e1b17b9 +subprocess.zh.md: 01a837fa7613a714c3ce1d5f4ec4c29d3a632a26 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 701d706295..2ee79c8d96 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -144,8 +144,8 @@ A spawn returns a live handle synchronously; the provider may publish its proces * observe. */ interface SubprocessHandle { - /** Provider-published process identifier; -1 while unavailable or after startup fails. */ - readonly pid: number + /** Provider-published target process identifier, or undefined until it is available. */ + readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 643998ae02..01a837fa76 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -144,8 +144,8 @@ spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识 * observe. */ interface SubprocessHandle { - /** Provider-published process identifier; -1 while unavailable or after startup fails. */ - readonly pid: number + /** Provider-published target process identifier, or undefined until it is available. */ + readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json index f7d3da5029..511fc507ea 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK." } + { "op": "prompt", "text": "Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK." } ] } diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index ff0ff42208..bb6530735b 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"3a6e7222-9340-429e-bec7-c30fcd063c70"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"a5f7957c-8553-4a87-88e0-e10e8f18eb88"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"3a6e7222-9340-429e-bec7-c30fcd063c70"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"a5f7957c-8553-4a87-88e0-e10e8f18eb88"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f9a387d6-bd6f-4613-9c11-5768017feb5c"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -15,7 +15,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7931aaf0-d192-407a-a751-397bc43fb399"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}],"isError":false}],"role":"user","id":"cf2f25e5-8b65-40f2-9301-1635e7497242"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n readonly options: AgentOptions;\\n readonly session: Session;\\n readonly inbox: Inbox;\\n readonly status: AgentStatus;\\n readonly ctx: Context;\\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\\n whenIdle(): Promise;\\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\\n followup(message: UserMessage): void;\\n steer(message: UserMessage): void;\\n inject(message: UserMessage): void;\\n}\"\n },\n {\n \"name\": \"AgentCancelCause\",\n \"declaration\": \"export type AgentCancelCause = {\\n readonly kind: 'user';\\n} | {\\n readonly kind: 'parent';\\n} | {\\n readonly kind: 'hook';\\n readonly reason: string;\\n} | {\\n readonly kind: 'disposed';\\n};\"\n },\n {\n \"name\": \"AgentOptions\",\n \"declaration\": \"export interface AgentOptions {\\n provider?: string;\\n model?: string;\\n maxTokens?: number;\\n}\"\n },\n {\n \"name\": \"AgentStatus\",\n \"declaration\": \"export type AgentStatus = 'idle' | 'running';\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"CancelOptions\",\n \"declaration\": \"export interface CancelOptions {\\n keepInbox?: boolean | undefined;\\n}\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"Inbox\",\n \"declaration\": \"export class Inbox {\\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\\n get nextTurn(): readonly UserMessage[];\\n get nextStep(): readonly UserMessage[];\\n get hasPending(): boolean;\\n clear(): void;\\n claim(target: InboxTarget, turn: number): UserMessage[];\\n append(target: InboxTarget, message: UserMessage): void;\\n prepend(target: InboxTarget, message: UserMessage): void;\\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\\n remove(messageId: MessageId): boolean;\\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\\n}\"\n },\n {\n \"name\": \"InboxNotifications\",\n \"declaration\": \"export interface InboxNotifications {\\n inserted(message: UserMessage): void;\\n discarded(message: UserMessage): void;\\n claimed(message: UserMessage, turn: number): void;\\n}\"\n },\n {\n \"name\": \"InboxTarget\",\n \"declaration\": \"export type InboxTarget = 'next-turn' | 'next-step';\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: CallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: CallId;\\n readonly rootCallId?: CallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: CallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'code' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"cb6ab295-ace9-4d7e-9a9b-f42ad46825b9"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -25,14 +25,24 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Event\",\"method\":\"listEvents\",\"input\":{\"event\":\"tools/pre-execute\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfd8a7c9-1809-41eb-b7b3-1e244f580a26"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Event\",\"method\":\"listEvents\",\"input\":{\"event\":\"tools/pre-execute\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-tools-event"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-event","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Event\",\n \"method\": \"listEvents\",\n \"data\": {\n \"mode\": \"event\",\n \"event\": {\n \"name\": \"tools/pre-execute\",\n \"description\": \"Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\",\n \"mode\": \"waterfall\",\n \"signature\": \"'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the pending call (name, parsed arguments, caller agent).\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}],"isError":false}],"role":"user","id":"8cc5c21e-1c4a-4ff4-a862-e701e8c1ac7f"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-tools-event"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-event","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Event\",\n \"method\": \"listEvents\",\n \"data\": {\n \"mode\": \"event\",\n \"event\": {\n \"name\": \"tools/pre-execute\",\n \"description\": \"Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\",\n \"mode\": \"waterfall\",\n \"signature\": \"'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the pending call (name, parsed arguments, caller agent).\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n readonly options: AgentOptions;\\n readonly session: Session;\\n readonly inbox: Inbox;\\n readonly status: AgentStatus;\\n readonly ctx: Context;\\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\\n whenIdle(): Promise;\\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\\n followup(message: UserMessage): void;\\n steer(message: UserMessage): void;\\n inject(message: UserMessage): void;\\n}\"\n },\n {\n \"name\": \"AgentCancelCause\",\n \"declaration\": \"export type AgentCancelCause = {\\n readonly kind: 'user';\\n} | {\\n readonly kind: 'parent';\\n} | {\\n readonly kind: 'hook';\\n readonly reason: string;\\n} | {\\n readonly kind: 'disposed';\\n};\"\n },\n {\n \"name\": \"AgentOptions\",\n \"declaration\": \"export interface AgentOptions {\\n provider?: string;\\n model?: string;\\n maxTokens?: number;\\n}\"\n },\n {\n \"name\": \"AgentStatus\",\n \"declaration\": \"export type AgentStatus = 'idle' | 'running';\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"CancelOptions\",\n \"declaration\": \"export interface CancelOptions {\\n keepInbox?: boolean | undefined;\\n}\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"Inbox\",\n \"declaration\": \"export class Inbox {\\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\\n get nextTurn(): readonly UserMessage[];\\n get nextStep(): readonly UserMessage[];\\n get hasPending(): boolean;\\n clear(): void;\\n claim(target: InboxTarget, turn: number): UserMessage[];\\n append(target: InboxTarget, message: UserMessage): void;\\n prepend(target: InboxTarget, message: UserMessage): void;\\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\\n remove(messageId: MessageId): boolean;\\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\\n}\"\n },\n {\n \"name\": \"InboxNotifications\",\n \"declaration\": \"export interface InboxNotifications {\\n inserted(message: UserMessage): void;\\n discarded(message: UserMessage): void;\\n claimed(message: UserMessage, turn: number): void;\\n}\"\n },\n {\n \"name\": \"InboxTarget\",\n \"declaration\": \"export type InboxTarget = 'next-turn' | 'next-step';\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"PreToolDecision\",\n \"declaration\": \"export type PreToolDecision = {\\n kind: 'allow';\\n} | {\\n kind: 'deny';\\n reason: string;\\n} | {\\n kind: 'ask';\\n reason?: string;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScheduledToolDispatch\",\n \"declaration\": \"export type ScheduledToolDispatch = {\\n kind: 'post-result';\\n result: ToolExecutionResult;\\n} | {\\n kind: 'final-result';\\n result: ToolExecutionResult;\\n};\"\n },\n {\n \"name\": \"ScheduledToolPreparation\",\n \"declaration\": \"export type ScheduledToolPreparation = {\\n kind: 'dispatch';\\n exec: ToolRunContext;\\n} | {\\n kind: 'post-result';\\n exec: ToolRunContext;\\n result: ToolExecutionResult;\\n} | {\\n kind: 'final-result';\\n exec: ToolRunContext;\\n result: ToolExecutionResult;\\n};\"\n },\n {\n \"name\": \"Scoped\",\n \"declaration\": \"export type Scoped = object & {\\n readonly [ScopedBrand]: T;\\n};\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: CallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: CallId;\\n readonly rootCallId?: CallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: CallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'code' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolRuntime\",\n \"declaration\": \"export class ToolRuntime extends Service {\\n static inject;\\n static Config: z;\\n readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler;\\n constructor(ctx: Context, config: Config = {});\\n presentAs(mode: ToolPresentationMode): () => void;\\n register(definition: ToolDefinition): () => void;\\n restrict(filter: ToolRestriction): () => void;\\n guard(guard: ToolGuard): () => void;\\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\\n schemas(scope?: ScopeKey): ToolSchema[];\\n executionMode(exec: ToolExecutionInput): ToolExecutionMode;\\n async execute(exec: ToolExecutionInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolRuntimeScheduler\",\n \"declaration\": \"export interface ToolRuntimeScheduler {\\n prepare(exec: ToolExecutionInput): Promise;\\n dispatch(exec: ToolRunContext): Promise;\\n finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise;\\n finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"8c412368-5ad9-4837-b4c8-50ec9508acce"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-subprocess-api","name":"cordis_inspect_query","argumentsDelta":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a4aa43b5-240e-423a-bc03-0abed8d890e4"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"06d010e2-bbdf-47d8-8f7c-f20e97f4647e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":3,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\"\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly pid: number | undefined;\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"cf61f8cb-a6d5-46df-9d7a-bd74791387ed"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} +{"type":"step/start","data":{"turn":1,"step":4}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a4aa43b5-240e-423a-bc03-0abed8d890e4"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":4}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 44dd96fb79..a4afb52b1a 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.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 packages/e2b/subprocess-e2b/README.md -README.md: 6e0ef633577377af6045c8b81d6f1e141bc46b54 -README.zh.md: 460c6c60fefce67e6813805c68405a24c64be9cb +README.md: baa86b345f65c55515cb2e880d1fe707432184f9 +README.zh.md: 3476bca21da304517763b279cbc0f3a49a1d3724 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 6e0ef63357..baa86b345f 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -12,7 +12,7 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr ## Behavior -- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. +- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `undefined` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. - **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides, and rejects relative paths containing separators like every subprocess provider. - **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes. - **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting. @@ -33,7 +33,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream. -- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged. +- **Synchronous-PID consumers are unsupported** — `pid` remains `undefined` during remote startup; consumers that require a PID immediately cannot use this provider unchanged. - **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 460c6c60fe..3476bca21d 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -12,7 +12,7 @@ ## 行为 -- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 +- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `undefined`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 - **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称,并与所有 subprocess 提供方一致地拒绝含分隔符的相对路径。 - **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放沙箱。 - **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 @@ -33,7 +33,7 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具: ## 已知限制与延后工作 - **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。 -- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP(Agent Client Protocol)子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。 +- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `undefined`;要求立即获得 PID 的消费方无法原样使用本提供方。 - **私有状态随沙箱生命周期存在**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。 - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index e3dd047e38..d12405c54b 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -174,7 +174,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private readonly stderrReader: E2BOutputReader | undefined private readonly paths: RemotePaths private controlEnvs: Record = {} - private remotePid = -1 + private remotePid: number | undefined private outputTransportError: Error | undefined private outputDrainExpired = false private stateDirectoryCreated = false @@ -225,8 +225,8 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (spec.signal?.aborted === true) this.terminate() } - /** Remote process id after start; `-1` while E2B startup is pending or after it fails. */ - get pid(): number { + /** Remote process id after publication; undefined while startup is pending or unavailable. */ + get pid(): number | undefined { return this.remotePid } @@ -260,7 +260,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { this.markQuiescent() return true } - if (this.remotePid <= 0) { + if (this.remotePid === undefined) { const attempt = this.terminationAttempt if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) { return false @@ -293,7 +293,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } throw error } - const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid + const processGroupId = this.remotePid ?? handle.pid while (await this.groupAlive(sandbox, processGroupId, signal)) { this.throwTerminationFailure() if (!await waitTick(this.pollMs, signal)) return false @@ -559,7 +559,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async rollbackPublishedFailure(error: unknown): Promise { - if (this.remotePid <= 0 || this.quiescenceProven) return error + if (this.remotePid === undefined || this.quiescenceProven) return error this.terminate() try { await this.waitForExit() @@ -599,13 +599,13 @@ export class E2BSubprocessHandle implements SubprocessHandle { this.markQuiescent() return } - if (!isValidProcessId(handle.pid) && this.remotePid <= 0) { + if (!isValidProcessId(handle.pid) && this.remotePid === undefined) { await handle.kill() this.markQuiescent() return } const sandbox = await this.runtime.getSandbox() - const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid + const processGroupId = this.remotePid ?? handle.pid await this.terminateGroup(sandbox, handle, processGroupId) } diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 933e7e0190..a1de4780f3 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -402,7 +402,7 @@ describe('E2BSubprocessHandle', () => { KEEP: undefined, }, }), '/workspace/.dsh-e2b/processes/one') - expect(handle.pid).toBe(-1) + expect(handle.pid).toBeUndefined() handle.stdin!.write('hello') handle.stdin!.end() fake.releaseStart() @@ -1166,7 +1166,7 @@ describe('E2BSubprocessHandle', () => { fake.backgroundError = new Error('start failed') const handle = testHandle(runtime(fake), spec(), '/runtime/fail') await expect(handle.done).rejects.toThrow('start failed') - expect(handle.pid).toBe(-1) + expect(handle.pid).toBeUndefined() expect(fake.removed).toContain('/runtime/fail/environment') expect(fake.removed).toContain('/runtime/fail') await expect(handle.waitForExit()).resolves.toBe(true) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 216652dc05..e9c8a10c78 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -888,7 +888,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] { const next: string[] = [] for (const entry of TYPE_API) { if (included.has(entry.name)) continue - const pattern = new RegExp(`\b${entry.name}\b`) + const pattern = new RegExp(`\\b${entry.name}\\b`) if (!frontier.some(text => pattern.test(text))) continue included.add(entry.name) next.push(entry.declaration) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2fabbfa95b..e24cc19920 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4539,7 +4539,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number | undefined;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', }, { name: 'SubprocessOutcome', @@ -5076,7 +5076,7 @@ function referencedTypeClosure(seeds: readonly string[]): TypeApiEntry[] { const next: string[] = [] for (const entry of TYPE_API) { if (included.has(entry.name)) continue - const pattern = new RegExp(`\b${entry.name}\b`) + const pattern = new RegExp(`\\b${entry.name}\\b`) if (!frontier.some(text => pattern.test(text))) continue included.add(entry.name) next.push(entry.declaration) diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index 5cfcf10b0c..c3fa006f40 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -131,8 +131,8 @@ export class LspConnection { this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } - /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ - get pid(): number { + /** The child's published pid, or undefined while the provider has none available. */ + get pid(): number | undefined { return this.handle.pid } diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 3785e677e7..91e4bff909 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' +import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -51,6 +53,34 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) + it('projects an unavailable subprocess pid as undefined', async () => { + const direct = Promise.withResolvers() + const handle: SubprocessHandle = { + pid: undefined, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: undefined, + collected: {}, + done: direct.promise, + terminate: () => {}, + waitForExit: async () => true, + } + const conn = new LspConnection({ + command: 'language-server', + args: [], + cwd: process.cwd(), + env: {}, + maxMessageBytes: 1_000, + maxStderrBytes: 1_000, + killGraceMs: 100, + configuration: null, + }, () => handle, () => Promise.resolve(null)) + + expect(conn.pid).toBeUndefined() + direct.resolve({ exitCode: 0, signal: null }) + await conn.closed + }) + it('forwards explicit DSH_* env entries to the child', async () => { // A configured DSH_* fact must reach the child: the seam scrubs only the // ambient namespace, and the explicit entry merges after that scrub. The diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 750fb870c3..4fe1a4795f 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -564,7 +564,7 @@ describe('background sandbox facts', () => { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), } vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ - pid: -1, + pid: undefined, stdin: undefined, stdout: undefined, stderr: undefined, diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index e26211b268..98cf05d52e 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -163,7 +163,7 @@ describe('spawn construction (pure, every platform)', () => { override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { this.specs.push(spec) return { - pid: -1, + pid: undefined, stdin: undefined, stdout: undefined, stderr: undefined, diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5ba7bc1718..fbea1b84c2 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -112,18 +112,25 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { - // 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 - } + const failures: Error[] = [] child.stdin?.end() - if (await treeExitsWithin(child, eofGraceMs)) return + let exited = false + try { + exited = await treeExitsWithin(child, eofGraceMs) + } catch (error: unknown) { + failures.push(toError(error)) + } + if (exited) return // terminate() owns the bounded SIGTERM→SIGKILL timer. Its unbounded wait is // the process owner's exit proof, not a second derived grace that can overflow. child.terminate() - await child.waitForExit() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(toError(error)) + } + if (failures.length === 1) throw failures[0] as Error + if (failures.length > 1) throw new AggregateError(failures, 'ACP subprocess teardown failed') } /** @@ -311,9 +318,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe ]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) - await disposeProcess() - if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') - throw toError(error) + const startupFailure = flags.cancelled + ? new Error('subagent request was aborted before the ACP child started') + : toError(error) + try { + await disposeProcess() + } catch (cleanupError: unknown) { + throw new AggregateError( + [startupFailure, toError(cleanupError)], + 'ACP startup failed and subprocess rollback did not reach quiescence', + ) + } + throw startupFailure } // The startup transaction validates the returned id before it can fulfill. // This assertion carries that cross-closure invariant into TypeScript. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 26ec4cd0b3..04430ed028 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { PassThrough } from 'node:stream' +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 +9,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 +197,95 @@ 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 terminates and performs the final wait when the EOF wait rejects', async () => { + const initialFailure = new Error('initial range observation failed') + const waitForExit = vi.fn() + .mockRejectedValueOnce(initialFailure) + .mockResolvedValueOnce(true) + const terminate = vi.fn() + const child: SubprocessHandle = { + pid: undefined, + stdin: new PassThrough(), + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate, + waitForExit, + } + + await expect(disposeAcpChild(child, 1_000)).rejects.toBe(initialFailure) + expect(terminate).toHaveBeenCalledOnce() + expect(waitForExit).toHaveBeenCalledTimes(2) + }) + + it('preserves both wait failures in observation order', async () => { + const initialFailure = new Error('initial range observation failed') + const finalFailure = new Error('final range observation failed') + const waitForExit = vi.fn() + .mockRejectedValueOnce(initialFailure) + .mockRejectedValueOnce(finalFailure) + const child: SubprocessHandle = { + pid: undefined, + stdin: new PassThrough(), + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + terminate: vi.fn(), + waitForExit, + } + + let failure: unknown + try { + await disposeAcpChild(child, 1_000) + } catch (error: unknown) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([initialFailure, finalFailure]) + }) + + it('keeps a startup failure before its rollback failure', async () => { + const startupFailure = new Error('target startup failed') + const cleanupFailure = new Error('range cleanup failed') + const direct = Promise.withResolvers() + const stdin = new PassThrough() + const stdout = new PassThrough() + const child: SubprocessHandle = { + pid: undefined, + stdin, + stdout, + stderr: undefined, + collected: {}, + done: direct.promise, + terminate: vi.fn(), + waitForExit: vi.fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(cleanupFailure), + } + const starting = startAcpRun(request(), { + command: 'fake-acp', + args: [], + cwd: process.cwd(), + permission: 'reject', + env: {}, + disposeEofGraceMs: 1_000, + disposeGraceMs: 1_000, + spawn: () => child, + }) + direct.reject(startupFailure) + + let failure: unknown + try { + await starting + } catch (error: unknown) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([startupFailure, cleanupFailure]) + }) }) describe('cwd resolution', () => { diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 3b0a19073d..48a117e44f 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -282,7 +282,7 @@ export async function disposeClaudeCodeChild( } catch (error: unknown) { failures.push(thrown(error)) } - const outcome = await child.done + const outcome = await child.done.catch(() => undefined) const firstFailure = failures[0] if (firstFailure !== undefined) { @@ -400,6 +400,7 @@ export async function startClaudeCodeRun( } let child: SubprocessHandle | undefined + let childFailure: Error | undefined let query: Query | undefined let managedProcess: ManagedClaudeCodeProcess | undefined let diagnostic: string | undefined @@ -418,6 +419,7 @@ export async function startClaudeCodeRun( ): void => { child = captured managedProcess = process + void captured.done.catch((error: unknown) => { childFailure = thrown(error) }) } try { query = officialQuery({ @@ -429,11 +431,16 @@ export async function startClaudeCodeRun( capturePermissionDiagnostic, ), }) - if (child === undefined || child.pid <= 0) { + if (child === undefined) { throw new Error( 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', ) } + // A provider may publish no PID and reject `done` through several already- + // queued promise reactions. PID absence is not failure; give that complete + // synchronous rejection chain one event-loop turn before publication. + await new Promise((resolve) => { setImmediate(resolve) }) + if (childFailure !== undefined) throw childFailure if (controller.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } @@ -448,46 +455,11 @@ export async function startClaudeCodeRun( category: 'unknown', outcome: startupOutcome, } as const - const startupFailure = (cause: unknown = error): ClaudeCodeFailure => new ClaudeCodeFailure( + const startupFailure = (cause: unknown = childFailure ?? error): ClaudeCodeFailure => new ClaudeCodeFailure( startupFacts, thrown(cause), ) requestCancel() - if (child !== undefined && child.pid <= 0) { - let closeError: Error | undefined - try { - query?.close() - } catch (disposeError: unknown) { - closeError = thrown(disposeError) - } - - let spawnError = thrown(error) - try { - await child.done - } catch (childError: unknown) { - spawnError = thrown(childError) - } - - if (closeError !== undefined) { - const failure = startupFailure(spawnError) - const cleanupFailure = new ClaudeCodeFailure({ - stage: 'teardown', - category: 'unknown', - }, closeError) - const aggregate = new AggregateError( - [failure, cleanupFailure], - `${failure.message}; ${cleanupFailure.message}`, - ) - reportFailure(aggregate) - throw aggregate - } - if (cancelledBeforeCleanup || isAborted(request.signal)) { - throw new Error('subagent-claude-code: request was aborted before SDK startup') - } - const failure = startupFailure(spawnError) - reportFailure(failure) - throw failure - } if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) @@ -501,6 +473,12 @@ export async function startClaudeCodeRun( reportFailure(aggregate) throw aggregate } + if (cancelledBeforeCleanup || isAborted(request.signal)) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + const failure = startupFailure() + reportFailure(failure) + throw failure } else if (query !== undefined) { try { query.close() diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 3298e07037..518825e4df 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -100,7 +100,7 @@ function errorCause(value: unknown): Error | undefined { } interface FakeChildOptions { - readonly pid?: number + readonly pid?: number | undefined readonly exitOnTerminate?: boolean readonly waitForExitError?: Error readonly doneError?: Error @@ -169,7 +169,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) }) const handle: SubprocessHandle = { - pid: options.pid ?? 1234, + pid: Object.hasOwn(options, 'pid') ? options.pid : 1234, stdin, stdout, stderr: undefined, @@ -802,7 +802,7 @@ describe('official spawn projection', () => { }) it('emits spawn errors', async () => { - const child = fakeChild({ pid: -1 }) + const child = fakeChild({ pid: undefined }) const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() const removed = vi.fn() @@ -1299,6 +1299,16 @@ describe('run publication, cancellation, and settlement', () => { )).rejects.toThrow('aborted before SDK startup') expect(unused.options).toEqual([]) + const thrownAbort = new AbortController() + queryMock.mockImplementationOnce(() => { + thrownAbort.abort(new Error('startup cancelled before resource publication')) + throw new Error('query failed before resource publication') + }) + await expect(startClaudeCodeRun( + request(undefined, thrownAbort.signal), + unused.spec, + )).rejects.toThrow('aborted before SDK startup') + const noChildClose = vi.fn() queryMock.mockImplementationOnce( () => queryFrom([], undefined, noChildClose), @@ -1439,7 +1449,7 @@ describe('run publication, cancellation, and settlement', () => { { code: 'EACCES', path: '/sdk/claude' }, ) const failedSpawn = fakeChild({ - pid: -1, + pid: undefined, doneError: spawnError, }) const failed = fakeRun([], undefined, failedSpawn) @@ -1449,12 +1459,12 @@ 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({ - pid: -1, + pid: undefined, doneError: spawnError, }) const cancelledFailedClose = vi.fn() @@ -1474,7 +1484,7 @@ describe('run publication, cancellation, and settlement', () => { throw cancelledFailedSpawnCloseError }) const cancelledFailedSpawnWithCloseFailure = fakeChild({ - pid: -1, + pid: undefined, doneError: spawnError, }) const failedSpawnAbortWithCloseFailure = new AbortController() @@ -1507,7 +1517,7 @@ describe('run publication, cancellation, and settlement', () => { const failedSpawnCloseError = new Error('query close failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ - pid: -1, + pid: undefined, doneError: spawnError, }) queryMock.mockImplementationOnce(({ options }) => { @@ -1555,6 +1565,32 @@ describe('run publication, cancellation, and settlement', () => { await expect(liveCleanupFailure) .rejects.not.toThrow('live child cleanup failed') }) + + it('waits one event-loop turn for a queued provider startup rejection', async () => { + const spawnError = Object.assign( + new Error('spawn /sdk/claude ENOENT'), + { code: 'ENOENT', path: '/sdk/claude' }, + ) + const child = fakeChild({ pid: undefined }) + const close = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + queueMicrotask(() => { child.fail(spawnError) }) + return queryFrom([], undefined, close) + }) + + const startup = startClaudeCodeRun(request(), { + cwd: '/workspace', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) + await expect(startup).rejects.toMatchObject({ cause: spawnError }) + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + }) }) describe('query and process disposal', () => { diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 9db5d7086c..835b470abc 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -188,32 +188,27 @@ export async function disposeCodexChild( ): Promise { wire.close() - if (child.pid > 0) { - let outcome: SubprocessOutcome | undefined - 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(() => {}) + let outcome: SubprocessOutcome | undefined + void child.done.then( + (value) => { outcome = value }, + () => {}, + ) + 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.catch(() => {}) } /** diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 296c8afa09..f590554b16 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -140,7 +140,7 @@ class ProtocolPeer { } interface FakeChildOptions { - readonly pid?: number + readonly pid?: number | undefined readonly exitOnTerminate?: boolean readonly doneError?: Error readonly waitForExitError?: Error @@ -212,7 +212,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) }) const handle: SubprocessHandle = { - pid: options.pid ?? 1234, + pid: Object.hasOwn(options, 'pid') ? options.pid : 1234, stdin: toChild, stdout: fromChild, stderr, @@ -1912,7 +1912,7 @@ describe('run lifecycle and quiescence', () => { await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') const asyncSpawnFailureChild = fakeChild({ - pid: -1, + pid: undefined, doneError: new Error('SECRET_TOKEN async spawn failure'), }) const asyncSpawnFailure = startCodexRun( @@ -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)) @@ -2295,16 +2296,16 @@ describe('disposeCodexChild', () => { .resolves.toBeUndefined() }) - it('handles a spawn-level failure with no process tree', async () => { + it('still runs idempotent cleanup when the target pid was never published', async () => { const child = fakeChild({ - pid: -1, + pid: undefined, doneError: new Error('spawn failed'), }) 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 () => { diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index c2535a33e3..87989ffd9d 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 30dc28a0be9db966ef8ffc1286ffa07858c8f81d -README.zh.md: 1a5a6c17efad9ea2a1e0fb2980b23e61679a68f5 +README.md: d8cdcbaec8a33b2f9a8af3a1a75dac2e647cce18 +README.zh.md: 7ec64e45f459a6c6a00aa4153d5ffaecb6c00dd0 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 30dc28a0be..d8cdcbaec8 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, gives ordinary Linux and Windows commands an OS-owned managed range when the host supports it, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)). +Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessRuntime` resolves local executables, gives ordinary Linux and Windows commands plus Linux terminal sessions an OS-owned managed range when the host supports it, and implements terminal processes through `node-pty` plus platform process inspection. It has no config: every disposition, limit, terminal dimension, grace, and directory arrives from the calling capability seams ([`dsh-bash-local`](../../shell/bash-local/README.md), [`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.md), and [`dsh-terminal-bash`](../../terminal/terminal-bash/README.md)). ## Behavior -- **One managed range for signal and wait** — Linux uses a transient user-systemd scope when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned. +- **One managed range for signal and wait** — Linux ordinary commands and terminal sessions use transient user-systemd scopes when the manager supports literal argv and readable scope state. On Windows the parent creates private named-pipe endpoints for non-inherited streams; the runner opens only the target-side handles, creates the target suspended, assigns it to its kill-on-close Job, resumes it, publishes startup, and then closes those pipe handles. The runner alone retains the original target process handle and Job, reports the direct result, and exits successfully only after `ActiveProcesses` reaches zero; the parent never opens either native object. Linux scopes and POSIX process-group fallbacks receive TERM and then KILL after `graceMs`; Windows Job and `taskkill` owners force-terminate on the first request. `waitForExit()` succeeds only after the selected owner proves the range empty and rejects when that proof is unavailable. After the direct result arrives, `.done` waits up to `graceMs` for every non-inherited output stream to close; at that bound, only collected streams are force-closed while raw pipes remain caller-owned. - **Explicit weaker fallback** — macOS, old or unavailable user-systemd, and unavailable Windows native support keep the existing detached PGID or `taskkill /T` path. The provider warns once before the first affected command. It never retries through fallback after a native runner may have started the user command. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation. Supported Linux hosts wrap the original terminal argv directly in the same user-systemd scope model as ordinary commands, preserving the node-pty PID, session leader, controlling terminal, and foreground-input inspection while the scope owns reparented or `setsid` descendants. Fallback hosts sweep observable descendants before and after terminating the top-level shell; exact pid/start identities prevent cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can run each provider-owned termination procedure and await its exit; quiescent and spawn-failed handles leave the live set after managed-range or terminal-session cleanup finishes. -- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and observable terminal session still in the live sets. Linux issues the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback and terminal paths retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener synchronously signals every ordinary managed range and terminal session still in the live sets. Linux native owners issue the scope KILL request; the Windows runner treats parent IPC disconnect as Job termination; fallback owners retain their PGID, `taskkill`, and captured-identity behavior. The listener creates no promise or timer, preserves the host exit code and diagnostic, contains each target failure, and does not claim quiescence. Normal disposal keeps the awaited managed-range path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,10 +27,10 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — the first ordinary spawn probes host capability once for that provider instance, with a 5-second bound on each probe command. The local native path publishes a numeric target pid before returning, so each launch waits synchronously for its per-spawn runner to report target start or spawn failure. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native launch has a synchronous setup cost** — every eligible ordinary or terminal spawn probes host capability before executing the user command, with a 5-second bound on each probe command; only the weaker-path warning is cached per provider. Each native ordinary launch waits synchronously for its per-spawn runner to report target start or spawn failure before publishing a numeric target pid. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native ordinary command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. Linux terminal launch passes the scoped argv directly to `node-pty` and adds no runner. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. -- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. +- **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 1a5a6c17ef..7ec64e45f4 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,在宿主支持时为普通 Linux 与 Windows 命令建立 OS-owned managed range,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.zh.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.zh.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.zh.md))。 +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.zh.md) seam 的本地 Service Provider。`LocalSubprocessRuntime` 解析本地可执行文件,在宿主支持时为普通 Linux 与 Windows 命令以及 Linux terminal session 建立 OS-owned managed range,并通过 `node-pty` 加平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方能力 seam([`dsh-bash-local`](../../shell/bash-local/README.zh.md)、[`dsh-lsp-stdio`](../../lsp/lsp-stdio/README.zh.md) 和 [`dsh-terminal-bash`](../../terminal/terminal-bash/README.zh.md))。 ## 行为 -- **signal 与 wait 使用同一个 managed range**:Linux 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。 +- **signal 与 wait 使用同一个 managed range**:Linux ordinary command 与 terminal session 在 manager 支持 literal argv 与可读 scope 状态时使用 transient user-systemd scope。Windows parent 为非继承流创建 private named-pipe endpoint;runner 只打开 target 侧 handle,以 suspended 状态创建目标,把它分配给自身的 kill-on-close Job,恢复目标,发布启动事实,然后关闭这些 pipe handle。只有 runner 保留原始 target process handle 与 Job,报告 direct result,并只在 `ActiveProcesses` 归零后成功退出;parent 不打开这两个 native object。Linux scope 与 POSIX 进程组 fallback 先发送 TERM,并在 `graceMs` 后发送 KILL;Windows Job 与 `taskkill` owner 在首次请求时立即强制终止。`waitForExit()` 只在所选 owner 证明范围为空后成功,无法取得该证明时则拒绝。direct result 到达后,`.done` 会等待所有非继承输出流关闭,最长不超过 `graceMs`;到达该界限时仅强制关闭 collected stream,raw pipe 仍归调用方所有。 - **明确披露较弱 fallback**:macOS、旧版或不可用的 user-systemd,以及不可用的 Windows native 支持继续使用既有 detached PGID 或 `taskkill /T` 路径。provider 会在首个受影响命令前只告警一次。native runner 可能已经启动用户命令后绝不通过 fallback 重试。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。 - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作。受支持的 Linux 宿主会把原始 terminal argv 直接包装进与 ordinary command 相同的 user-systemd scope,在保留 node-pty PID、session leader、控制终端与前台输入检查的同时,由 scope 持有已 reparent 或调用 `setsid` 的后代。fallback 宿主则在终止顶层 shell 前后清理可观察后代;精确的 pid/start 身份会防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能执行每个 provider-owned termination procedure 并等待其退出;完全停稳与 spawn 失败的句柄会在 managed range 或 terminal session 清理完成后离开存活集合。 -- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与可观察 terminal session 发信号。Linux 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback 与 terminal 路径保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会同步向存活集合中的每个普通 managed range 与 terminal session 发信号。Linux native owner 发出 scope KILL 请求;Windows runner 把 parent IPC 断开视为 Job 终止;fallback owner 保留 PGID、`taskkill` 和 captured-identity 行为。listener 不创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待 managed-range 路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 ## 模型体验 @@ -27,10 +27,10 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:首条 ordinary spawn 会为该 provider instance 探测一次宿主能力,每条 probe command 的上限为 5 秒。本地 native 路径会在返回前发布数值 target pid,因此每次 launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native launch 有同步 setup 成本**:每次符合条件的 ordinary 或 terminal spawn 都会在执行用户命令前探测宿主能力,每条 probe command 的上限为 5 秒;每个 provider 只缓存较弱路径的告警。每次 native ordinary launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure,再发布数值 target pid。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native ordinary command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。Linux terminal launch 会把 scoped argv 直接交给 `node-pty`,不增加 runner。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 -- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 +- **fallback terminal ownership 仍依赖观察**:在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有 terminal session,就可能逃出进程表扫描。本地 provider 不会新增持续进程表 monitor;受支持的 Linux native mode 改由 scope membership 持有这些后代。 - **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 2320738123..37df80a38e 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -19,7 +19,7 @@ "default": "./lib/index.js" }, "./spawn-runner": { - "types": "./lib/types/spawn-runner.d.ts", + "types": "./lib/types/bin.d.ts", "default": "./lib/spawn-runner.js" }, "./invariant": { diff --git a/packages/subprocess/subprocess-local/src/bin.ts b/packages/subprocess/subprocess-local/src/bin.ts new file mode 100644 index 0000000000..9713423b14 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +/** Thin process entry for the ordinary subprocess native runner. */ + +import { reportSpawnRunnerFailure, runSpawnRunner } from './spawn-runner.ts' + +const argv = process.argv.slice(2) +try { + await runSpawnRunner(argv) +} catch (error: unknown) { + reportSpawnRunnerFailure(argv, error) + process.exitCode = 127 +} diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 62280c77c7..73fa42a761 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -29,7 +29,7 @@ import { validateSubprocessSpec, } from './spawn.ts' import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' -import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts' +import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' @@ -49,8 +49,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} - /** Ordinary native containment mode, selected once before its first user command. */ - private ordinaryMode: 'linux-scope' | 'windows-job' | 'fallback' | undefined + /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */ + private fallbackWarningIssued = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -159,13 +159,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { validateSubprocessSpec(spec) - const mode = this.selectOrdinaryMode() + const containmentMode = this.selectContainmentMode('ordinary') let handle: LocalSubprocessHandle - if (mode === 'fallback') { + if (containmentMode === 'fallback') { handle = spawnSubprocess(spec, this.internals) } else { const binding = prepareManagedProcessBinding(this.internals) - const launch = mode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec) + const launch = containmentMode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec) handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) @@ -179,23 +179,27 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { return handle } - private selectOrdinaryMode(): 'linux-scope' | 'windows-job' | 'fallback' { - if (this.ordinaryMode !== undefined) return this.ordinaryMode + private selectContainmentMode( + kind: 'ordinary' | 'terminal', + ): 'linux-scope' | 'windows-job' | 'fallback' { const platform = this.internals.platform ?? process.platform - if (platform === 'linux' && probeLinuxScope()) this.ordinaryMode = 'linux-scope' - else if (platform === 'win32' && probeWindowsJob()) this.ordinaryMode = 'windows-job' - else this.ordinaryMode = 'fallback' - if (this.ordinaryMode === 'fallback') this.warnFallback(platform) - return this.ordinaryMode + if (platform === 'linux' && probeLinuxScope()) return 'linux-scope' + if (kind === 'ordinary' && platform === 'win32' && probeWindowsJob()) return 'windows-job' + this.warnFallback(platform, kind) + return 'fallback' } - private warnFallback(platform: NodeJS.Platform): void { + private warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void { + if (this.fallbackWarningIssued) return + this.fallbackWarningIssued = true const reason = platform === 'darwin' ? 'macOS has no supported persistent process-range owner' : platform === 'linux' ? 'a modern readable user-systemd scope is unavailable' : platform === 'win32' - ? 'the Win32 Job runner is unavailable' + ? kind === 'terminal' + ? 'Windows ConPTY remains outside Job containment' + : 'the Win32 Job runner is unavailable' : `platform ${platform} has no native managed range` this.ctx.logger.warn( `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`, @@ -218,8 +222,25 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { env: childEnv(spec.env), } const inspector = this.terminalInspector ?? createProcessInspector() - const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options) - const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs) + const containmentMode = this.selectContainmentMode('terminal') + const scope = containmentMode === 'linux-scope' + ? prepareLinuxTerminalScope(spec.argv) + : undefined + const terminal = nodePty.spawn( + scope?.command ?? file, + scope?.args ?? [...spec.argv.slice(1)], + options, + ) + // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published. + let handle: LocalTerminalHandle | undefined + const owner = scope?.bindOwner(() => handle?.running ?? true) + handle = new LocalTerminalHandle( + terminal, + inspector, + spec.graceMs, + this.internals.platform ?? process.platform, + owner, + ) this.terminals.add(handle) const release = async (): Promise => { await handle.terminate() diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index df6455c5b3..5a5605fa67 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -2,7 +2,6 @@ import { randomBytes } from 'node:crypto' import { execFile, spawn, spawnSync } from 'node:child_process' -import type { ChildProcess } from 'node:child_process' import { setTimeout as sleepMs } from 'node:timers/promises' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' @@ -16,6 +15,7 @@ import { runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' +import { cleanupRunnerFiles } from './runner-protocol.ts' /** Test seams for systemd command execution. */ export interface LinuxScopeInternals { @@ -83,18 +83,27 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { timeout, }) if (manager.error !== undefined || manager.status !== 0) return false + const runner = runSync(runnerCommand, [...runnerPrefix, '--mode', 'probe-node'], { + env: childEnv(), + stdio: 'ignore', + timeout, + }) + if (runner.error !== undefined || runner.status !== 0) return false + const unitBase = unitStem('dsh-subprocess-probe') const probe = runSync(systemdRun, [ '--user', '--scope', '--quiet', '--collect', '--expand-environment=no', - `--unit=${unitStem('dsh-subprocess-probe')}`, + `--unit=${unitBase}`, '--', - runnerCommand, - ...runnerPrefix, - '--mode', - 'probe-node', + systemctl, + '--user', + 'show', + `${unitBase}.scope`, + '--property=ActiveState', + '--value', ], { env: childEnv(), stdio: 'ignore', @@ -113,7 +122,7 @@ class SystemdScopeOwner implements BoundProcessOwner { private readonly systemctl: string, private readonly runSync: typeof spawnSync, private readonly query: (command: string, args: readonly string[]) => Promise, - private readonly runner: ChildProcess, + private readonly launcherRunning: () => boolean, private readonly onForceKillAttempt: () => void, ) {} @@ -149,7 +158,7 @@ class SystemdScopeOwner implements BoundProcessOwner { const output = `${result.stdout}\n${result.stderr}` if (result.status !== 0) { if (MISSING_UNIT.test(output)) { - if (this.runner.pid === undefined || this.runner.exitCode !== null || this.runner.signalCode !== null) return false + if (!this.launcherRunning()) return false } else { if (result.error !== undefined) throw result.error throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) @@ -175,6 +184,51 @@ class SystemdScopeOwner implements BoundProcessOwner { } } +/** Prepared node-pty argv plus the owner for the exact transient scope it enters. */ +export interface LinuxTerminalScopeLaunch { + command: string + args: string[] + bindOwner(launcherRunning: () => boolean): BoundProcessOwner +} + +/** + * Wrap one terminal argv directly in a transient user-systemd scope. + * @param argv - original terminal command and arguments. + * @param internals - injected systemd commands used by tests. + * @returns the node-pty command, literal arguments, and owner binding for the same unit. + */ +export function prepareLinuxTerminalScope( + argv: readonly string[], + internals: LinuxScopeInternals = {}, +): LinuxTerminalScopeLaunch { + const runSync = internals.spawnSync ?? spawnSync + const query = internals.systemctlQuery ?? querySystemctl + const systemdRun = internals.systemdRun ?? 'systemd-run' + const systemctl = internals.systemctl ?? 'systemctl' + const unitBase = unitStem('dsh-terminal') + return { + command: systemdRun, + args: [ + '--user', + '--scope', + '--quiet', + '--collect', + '--expand-environment=no', + `--unit=${unitBase}`, + '--', + ...argv, + ], + bindOwner: launcherRunning => new SystemdScopeOwner( + `${unitBase}.scope`, + systemctl, + runSync, + query, + launcherRunning, + () => {}, + ), + } +} + /** * Launch one direct command inside a transient user scope. * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. @@ -193,25 +247,31 @@ export function launchLinuxScope( const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const files = runnerFiles(spec) const unitBase = unitStem('dsh-subprocess') - const child = run(systemdRun, [ - '--user', - '--scope', - '--quiet', - '--collect', - '--expand-environment=no', - `--unit=${unitBase}`, - '--', - ...invocation, - '--mode', - 'node', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ], { - env: childEnv(), - stdio: runnerStdio(spec), - }) + let child: ReturnType + try { + child = run(systemdRun, [ + '--user', + '--scope', + '--quiet', + '--collect', + '--expand-environment=no', + `--unit=${unitBase}`, + '--', + ...invocation, + '--mode', + 'node', + '--request', + files.requestPath, + '--events', + files.eventsPath, + ], { + env: childEnv(), + stdio: runnerStdio(spec), + }) + } catch (error) { + cleanupRunnerFiles(files) + throw error + } const lifecycle = observeChildLifecycle(child) let forceKillAttempted = false const owner = new SystemdScopeOwner( @@ -219,7 +279,7 @@ export function launchLinuxScope( systemctl, runSync, query, - child, + () => child.pid !== undefined && child.exitCode === null && child.signalCode === null, () => { forceKillAttempted = true }, ) const result = runnerDirectResult(child, files, lifecycle.exited) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 197fa81752..5d5bd0b58f 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -22,7 +22,7 @@ export interface ManagedProcessLaunch { stdin: Writable | null stdout: Readable | null stderr: Readable | null - pid: number + pid: number | undefined direct: Promise owner: BoundProcessOwner } @@ -70,6 +70,7 @@ export async function waitWithAbort(pending: Promise, signal?: AbortSignal const aborted = Promise.withResolvers() const onAbort = (): void => { aborted.resolve(false) } signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() try { return await Promise.race([pending.then(() => true), aborted.promise]) } finally { diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 0609639175..555fba8254 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -37,7 +37,7 @@ export function spawnRunnerInvocation(): RunnerInvocation { return [process.execPath, builtEntry] } /* v8 ignore stop */ - const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')) + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')) return [process.execPath, '--import', 'tsx/esm', sourceEntry] } @@ -68,11 +68,25 @@ export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles { } interface RunnerHandshake { - pid: number + pid: number | undefined events: RunnerEvent[] failureReported: boolean } +function directTerminalResult( + events: readonly RunnerEvent[], +): { outcome: SubprocessOutcome } | { error: Error } | undefined { + for (const event of events) { + if (event.type === 'exit') { + return { outcome: { exitCode: event.exitCode, signal: event.signal } } + } + if (event.type === 'spawn-error' || event.type === 'runner-error') { + return { error: deserializeSpawnError(event.error) } + } + } + return undefined +} + /** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ function runnerExited(child: ChildProcess, pid: number): boolean { if (child.exitCode !== null || child.signalCode !== null) return true @@ -105,7 +119,7 @@ function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): Runner const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') if (terminal?.type === 'started') return { pid: terminal.pid, events, failureReported: false } if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { - return { pid: -1, events, failureReported: true } + return { pid: undefined, events, failureReported: true } } if (child.pid === undefined) throw new Error('native subprocess runner failed to start') if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') @@ -119,7 +133,7 @@ async function waitForDirectResult( initial: RunnerEvent[], exited: Promise, ): Promise { - let seen = 0 + let seen = initial.length const wrapperState = { exited: false } void exited.then(() => { wrapperState.exited = true }) for (;;) { @@ -128,11 +142,12 @@ async function waitForDirectResult( // event was written before the runner exited. const exitedBeforeRead = wrapperState.exited const events = await readRunnerEventsAsync(files.eventsPath) - for (const event of events.slice(seen)) { - if (event.type === 'exit') return { exitCode: event.exitCode, signal: event.signal } - if (event.type === 'spawn-error' || event.type === 'runner-error') throw deserializeSpawnError(event.error) + const terminal = directTerminalResult(events.slice(seen)) + if (terminal !== undefined) { + if ('error' in terminal) throw terminal.error + return terminal.outcome } - seen = Math.max(seen, events.length, initial.length) + seen = Math.max(seen, events.length) if (exitedBeforeRead) { throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') } @@ -152,7 +167,7 @@ export function runnerDirectResult( files: RunnerFiles, exited: Promise, ): { - pid: number + pid: number | undefined direct: Promise failureReported: boolean } { @@ -161,11 +176,16 @@ export function runnerDirectResult( handshake = waitForRunnerHandshake(child, files) } catch (error) { cleanupRunnerFiles(files) - return { pid: -1, direct: Promise.resolve().then(() => { throw error }), failureReported: false } + return { pid: undefined, direct: Promise.resolve().then(() => { throw error }), failureReported: false } } + const terminal = directTerminalResult(handshake.events) return { pid: handshake.pid, - direct: waitForDirectResult(files, handshake.events, exited), + direct: terminal === undefined + ? waitForDirectResult(files, handshake.events, exited) + : 'error' in terminal + ? Promise.reject(terminal.error) + : Promise.resolve(terminal.outcome), failureReported: handshake.failureReported, } } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 3722277a2f..a447cae348 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -7,7 +7,7 @@ import { loadWin32ProcessBindings, openNamedPipeForStdio, pollProcessExit, - spawnOrdinaryJobProcess, + spawnCurrentTokenJobProcess, terminateJob, waitForProcessExit, Win32Error, @@ -33,6 +33,35 @@ type RunnerArgs = stderrPipe?: string } +type RunnerHost = Pick< + NodeJS.Process, + 'env' | 'exitCode' | 'connected' | 'cwd' | 'chdir' | 'on' | 'off' | 'disconnect' +> + +interface RunnerInternals { + spawn: typeof spawn + loadWin32ProcessBindings: typeof loadWin32ProcessBindings + openNamedPipeForStdio: typeof openNamedPipeForStdio + spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess + pollProcessExit: typeof pollProcessExit + isJobEmpty: typeof isJobEmpty + terminateJob: typeof terminateJob + waitForProcessExit: typeof waitForProcessExit + closeHandleChecked: typeof closeHandleChecked +} + +const defaultRunnerInternals: RunnerInternals = { + spawn, + loadWin32ProcessBindings, + openNamedPipeForStdio, + spawnCurrentTokenJobProcess, + pollProcessExit, + isJobEmpty, + terminateJob, + waitForProcessExit, + closeHandleChecked, +} + function parseArgs(argv: string[]): RunnerArgs { let mode: string | undefined let requestPath: string | undefined @@ -89,49 +118,68 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw } } -function runNode(request: RunnerRequest, eventsPath: string): void { +async function runNode( + request: RunnerRequest, + eventsPath: string, + host: RunnerHost, + internals: RunnerInternals, +): Promise { + const ignoreScopeSignal = (): void => { /* The target receives the scope signal; the runner reports its outcome. */ } for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { - process.on(signal, () => { /* The scope target receives it; the runner stays to report direct outcome. */ }) + host.on(signal, ignoreScopeSignal) } const [program, ...args] = request.argv - const child = spawn(program as string, args, { + const child = internals.spawn(program as string, args, { cwd: request.cwd, env: request.env, stdio: 'inherit', }) - let started = false - let failed = false - child.once('spawn', () => { - started = true - appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number }) - }) - child.once('error', (error) => { - failed = true - if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) }) - else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) - process.exitCode = 127 - }) - child.once('exit', (exitCode, signal) => { - if (failed) return - appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal }) - process.exitCode = exitCode ?? 1 + await new Promise((resolve) => { + let started = false + let failed = false + let settled = false + const finish = (): void => { + if (settled) return + settled = true + for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) host.off(signal, ignoreScopeSignal) + resolve() + } + child.once('spawn', () => { + started = true + appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number }) + }) + child.once('error', (error) => { + failed = true + if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) }) + else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) + host.exitCode = 127 + finish() + }) + child.once('exit', (exitCode, signal) => { + if (!failed) { + appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal }) + host.exitCode = exitCode ?? 1 + } + finish() + }) }) } -function replaceEnvironment(env: Record): void { - for (const key of Object.keys(process.env)) Reflect.deleteProperty(process.env, key) - Object.assign(process.env, env) +function replaceEnvironment(target: NodeJS.ProcessEnv, env: Record): void { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + Object.assign(target, env) } function closeStdioHandles( api: ReturnType, handles: Array<{ handle: NativePtr; label: string }>, reportFailure: boolean, + internals: RunnerInternals, ): void { let failure: Error | undefined for (const owned of handles.splice(0)) { try { - closeHandleChecked(api, owned.handle, owned.label) + internals.closeHandleChecked(api, owned.handle, owned.label) } catch (error) { handles.push(owned) failure ??= error instanceof Error ? error : new Error(serializeSpawnError(error).message) @@ -144,9 +192,11 @@ async function runWin32( request: RunnerRequest, eventsPath: string, pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, + host: RunnerHost, + internals: RunnerInternals, ): Promise { - replaceEnvironment(request.env) - const api = loadWin32ProcessBindings() + replaceEnvironment(host.env, request.env) + const api = internals.loadWin32ProcessBindings() let processHandle: NativePtr | undefined let jobHandle: NativePtr | undefined const openedStdio: Array<{ handle: NativePtr; label: string }> = [] @@ -158,27 +208,27 @@ async function runWin32( ['stderr', pipes.stderrPipe, 'write'], ] as const) { if (path === undefined) continue - const handle = openNamedPipeForStdio(api, path, access) + const handle = internals.openNamedPipeForStdio(api, path, access) stdio[key] = handle openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) } // Match Node's cwd-relative executable lookup and spawn-error attribution. - const runnerCwd = process.cwd() - process.chdir(request.cwd) + const runnerCwd = host.cwd() + host.chdir(request.cwd) try { const [command, ...args] = request.argv - const spawned = spawnOrdinaryJobProcess( + const spawned = internals.spawnCurrentTokenJobProcess( api, - { command: command as string, args, cwd: process.cwd() }, + { command: command as string, args, cwd: host.cwd() }, stdio, ) processHandle = spawned.process jobHandle = spawned.job appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) } finally { - process.chdir(runnerCwd) + host.chdir(runnerCwd) } - closeStdioHandles(api, openedStdio, true) + closeStdioHandles(api, openedStdio, true, internals) await new Promise((resolve, reject) => { let settled = false @@ -187,8 +237,8 @@ async function runWin32( if (settled) return settled = true clearInterval(timer) - process.off('message', onMessage) - process.off('disconnect', onDisconnect) + host.off('message', onMessage) + host.off('disconnect', onDisconnect) if (error === undefined) resolve() else reject(error instanceof Error ? error : new Error(serializeSpawnError(error).message)) } @@ -196,7 +246,7 @@ async function runWin32( if (terminationRequested || jobHandle === undefined) return terminationRequested = true try { - terminateJob(api, jobHandle, 1) + internals.terminateJob(api, jobHandle, 1) } catch (error) { settle(error) } @@ -207,20 +257,20 @@ async function runWin32( } } const onDisconnect = (): void => { terminate() } - process.on('message', onMessage) - process.on('disconnect', onDisconnect) + host.on('message', onMessage) + host.on('disconnect', onDisconnect) const timer = setInterval(() => { try { if (processHandle !== undefined) { - const exitCode = pollProcessExit(api, processHandle) + const exitCode = internals.pollProcessExit(api, processHandle) if (exitCode !== undefined) { appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) - closeHandleChecked(api, processHandle, 'ordinary direct process') + internals.closeHandleChecked(api, processHandle, 'ordinary direct process') processHandle = undefined } } - if (processHandle === undefined && jobHandle !== undefined && isJobEmpty(api, jobHandle)) { - closeHandleChecked(api, jobHandle, 'ordinary process Job') + if (processHandle === undefined && jobHandle !== undefined && internals.isJobEmpty(api, jobHandle)) { + internals.closeHandleChecked(api, jobHandle, 'ordinary process Job') jobHandle = undefined settle() } @@ -238,61 +288,76 @@ async function runWin32( type: targetSpawnFailed ? 'spawn-error' : 'runner-error', error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error), }) - if (!targetSpawnFailed) process.exitCode = 127 + if (!targetSpawnFailed) host.exitCode = 127 } finally { - closeStdioHandles(api, openedStdio, false) + closeStdioHandles(api, openedStdio, false, internals) if (processHandle !== undefined) { - try { closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } + try { internals.closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } } if (jobHandle !== undefined) { - try { closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ } + try { internals.closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ } } } } -function probeWin32Job(): void { - const command = process.env.ComSpec ?? process.env.COMSPEC +function probeWin32Job(host: RunnerHost, internals: RunnerInternals): void { + const command = host.env.ComSpec ?? host.env.COMSPEC if (command === undefined) throw new Error('subprocess runner cannot probe a Windows Job without ComSpec') - const api = loadWin32ProcessBindings() - const spawned = spawnOrdinaryJobProcess(api, { + const api = internals.loadWin32ProcessBindings() + const spawned = internals.spawnCurrentTokenJobProcess(api, { command, args: ['/d', '/s', '/c', 'exit 0'], - cwd: process.cwd(), + cwd: host.cwd(), }) try { - const exitCode = waitForProcessExit(api, spawned.process) + const exitCode = internals.waitForProcessExit(api, spawned.process) if (exitCode !== 0) throw new Error(`subprocess Windows Job probe exited with code ${String(exitCode)}`) } finally { - closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe') + internals.closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe') } } -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)) +/** + * Execute one parsed private-runner request. + * @param argv - runner arguments after the executable and entry path. + * @param host - process operations; tests provide an isolated host facade. + * @param internals - platform operations; tests replace native Win32 calls. + * @returns after the requested probe or target lifecycle completes. + */ +export async function runSpawnRunner( + argv: string[], + host: RunnerHost = process, + internals: RunnerInternals = defaultRunnerInternals, +): Promise { + const args = parseArgs(argv) if (args.mode === 'probe-node') return if (args.mode === 'probe-win32') { - probeWin32Job() + probeWin32Job(host, internals) return } const request = consumeRunnerRequest(args.requestPath) - if (args.mode === 'node') runNode(request, args.eventsPath) + if (args.mode === 'node') await runNode(request, args.eventsPath, host, internals) else { try { - await runWin32(request, args.eventsPath, args) + await runWin32(request, args.eventsPath, args, host, internals) } finally { - if (process.connected) process.disconnect() + if (host.connected) host.disconnect() } } } -main().catch((error: unknown) => { +/** + * Publish an infrastructure failure when runner arguments still identify an event file. + * @param argv - original runner arguments. + * @param error - uncaught runner failure. + */ +export function reportSpawnRunnerFailure(argv: string[], error: unknown): void { try { - const args = parseArgs(process.argv.slice(2)) + const args = parseArgs(argv) if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) } } catch { // No trustworthy transport remains; the parent reports the missing result. } - process.exitCode = 127 -}) +} diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 160e1d0bb0..2b65e75ea6 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -267,12 +267,12 @@ export class OutputCollector { /** * Send `sig` to a detached POSIX process group. Never throws: delivery races * process exit and may run in a timer callback, so failures are contained and - * a non-positive pid is a no-op. - * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. + * a missing pid is a no-op. + * @param pid - the group leader's pid, when the spawn published one. * @param sig - the signal to deliver to the whole group. */ -export function killGroup(pid: number, sig: NodeJS.Signals): void { - if (pid <= 0) return +export function killGroup(pid: number | undefined, sig: NodeJS.Signals): void { + if (pid === undefined) return try { process.kill(-pid, sig) } catch { @@ -285,10 +285,10 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { * POSIX group signalling — delivery races tree exit, so an absent tree, a * nonzero status, or a missing taskkill binary must not break idempotent * teardown. - * @param pid - root process id; non-positive is a no-op. + * @param pid - root process id, when the spawn published one. */ -export function taskkillProcessTree(pid: number): void { - if (pid <= 0) return +export function taskkillProcessTree(pid: number | undefined): void { + if (pid === undefined) return // Outcome deliberately unchecked: an already-absent tree (status 128), exit // races, and a missing taskkill binary (spawnSync reports, never throws) are // as tolerable here as ESRCH is for a POSIX group signal. @@ -303,17 +303,17 @@ export function taskkillProcessTree(pid: number): void { */ function signalTree( platform: NodeJS.Platform, - pid: number, + pid: number | undefined, sig: NodeJS.Signals, child: ChildProcess, taskkill: (pid: number) => void, ): void { + /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false without a pid; this guard protects direct callers only. */ + if (pid === undefined) return if (platform === 'win32') { taskkill(pid) return } - /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */ - if (pid <= 0) return try { process.kill(-pid, sig) } catch { @@ -367,7 +367,7 @@ function directChildResult(child: ChildProcess): Promise { function fallbackOwner( platform: NodeJS.Platform, - pid: number, + pid: number | undefined, child: ChildProcess, taskkill: (pid: number) => void, linuxGroupHasLiveMembers: (processGroupId: number) => boolean | undefined, @@ -382,7 +382,7 @@ function fallbackOwner( ) const alive = (): boolean => { - if (stopped || pid <= 0) return false + if (stopped || pid === undefined) return false if (platform === 'win32') return child.exitCode === null && child.signalCode === null try { process.kill(-pid, 0) @@ -601,7 +601,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter detached: platform !== 'win32', }) const direct = directChildResult(child) - const pid = child.pid ?? -1 + const pid = child.pid const owner = fallbackOwner( platform, pid, diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 0a51287d24..5440101604 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -10,6 +10,7 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSignal, } from '@deepseek-ai/dsh-subprocess' +import type { BoundProcessOwner } from './managed-owner.ts' import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' function delay(ms: number): Promise { @@ -25,7 +26,8 @@ function signalName(number: number | undefined): NodeJS.Signals | null { } /** - * A local terminal whose process-session ownership stays below the PTY backend. + * A local terminal whose native managed range or fallback process-session + * ownership stays below the PTY backend. * The seam's terminate() promise — no write, inspection, or signal in flight * after settlement — holds here without operation tracking only because every * handle call completes synchronously under the hood (node-pty write, ps-based @@ -57,6 +59,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly inspector: ProcessInspector, private readonly graceMs: number, private readonly platform: NodeJS.Platform = process.platform, + private readonly managedOwner?: BoundProcessOwner, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -73,6 +76,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { }) } + /** Whether node-pty has not yet published the top-level exit event. */ + get running(): boolean { + return !this.exited + } + // node-pty writes synchronously; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async write(data: string): Promise { @@ -130,6 +138,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * event. This does not claim quiescence and does not replace terminate(). */ terminateForHostExit(): void { + if (this.managedOwner !== undefined) { + this.managedOwner.signal('SIGKILL') + return + } this.forceStopDescendants() this.forceStopShell() this.forceStopDescendants() @@ -291,6 +303,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async closeOnce(): Promise { + if (this.managedOwner !== undefined) { + await this.closeManagedRange(this.managedOwner) + this.dataDisposable.dispose() + this.exitDisposable.dispose() + return + } let survivors = await this.stopDescendants() if (survivors.length > 0) { throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`) @@ -305,6 +323,31 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.exitDisposable.dispose() } + private async closeManagedRange(owner: BoundProcessOwner): Promise { + 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 })), + ]) + if (first.kind !== 'stopped') { + owner.signal('SIGKILL') + if (first.kind === 'failed') { + // The observation failure is still authoritative, but force cleanup + // must be attempted before exposing it to the caller. + throw first.error + } + await observation + } + if (!this.exited) { + await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) + } + if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) + } + private settleExitIfGone(): void { // An externally taskkilled Windows shell may never fire node-pty's exit // notification (its console-list agent fails without a parent console), diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index d74cadad70..bf24393d51 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,7 +1,9 @@ import { spawn, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts' +import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from '../src/linux-scope.ts' import { spawnRunnerInvocation } from '../src/runner-launch.ts' function spec(argv: string[]): SubprocessSpawnSpec { @@ -46,24 +48,60 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () 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(calls[1]).toEqual([...runnerInvocation, '--mode', 'probe-node']) + expect(calls[2]).toContain('--expand-environment=no') + expect(calls[2]).not.toContain('--pipe') + expect(calls[2]).not.toContain('--wait') + const unitArg = calls[2]?.find(arg => arg.startsWith('--unit=')) + if (unitArg === undefined) throw new Error('scope probe did not publish its unit') + const separator = calls[2]?.indexOf('--') ?? -1 + expect(calls[2]?.slice(separator + 1)).toEqual([ + 'systemctl', + '--user', + 'show', + `${unitArg.slice('--unit='.length)}.scope`, + '--property=ActiveState', + '--value', + ]) expect(environments[0]?.LC_ALL).toBe('C') - expect(environments[0]).not.toHaveProperty(secretName) + for (const environment of environments) expect(environment).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, + status: command === 'systemd-run' ? 1 : 0, error: undefined, })) as unknown as typeof spawnSync expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false) + const failedRunner = vi.fn((command: string) => ({ + status: command === 'node-runtime' ? 1 : 0, + error: undefined, + })) as unknown as typeof spawnSync + expect(probeLinuxScope({ + spawnSync: failedRunner, + runnerInvocation: ['node-runtime', 'runner-entry.js'], + })).toBe(false) + expect(failedRunner).toHaveBeenCalledTimes(2) + + let unreadableProbeCalls = 0 + const unreadableScope = vi.fn(() => ({ + status: ++unreadableProbeCalls === 3 ? 1 : 0, + error: undefined, + })) as unknown as typeof spawnSync + expect(probeLinuxScope({ spawnSync: unreadableScope })).toBe(false) + + let erroredProbeCalls = 0 + const erroredScope = vi.fn(() => { + erroredProbeCalls += 1 + return erroredProbeCalls === 3 + ? { status: null, error: new Error('scope read failed') } + : { status: 0, error: undefined } + }) as unknown as typeof spawnSync + expect(probeLinuxScope({ spawnSync: erroredScope })).toBe(false) + const managerError = new Error('missing user manager') expect(probeLinuxScope({ spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync, @@ -74,6 +112,78 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) + it('removes private runner files when systemd-run throws synchronously', () => { + const failure = new Error('systemd-run threw') + let requestPath: string | undefined + const run = vi.fn((_command: string, args: readonly string[]) => { + const requestIndex = args.indexOf('--request') + requestPath = args[requestIndex + 1] + throw failure + }) as unknown as typeof spawn + + expect(() => launchLinuxScope(spec([process.execPath, '-e', '']), { + spawn: run, + runnerInvocation: spawnRunnerInvocation(), + })).toThrow(failure) + expect(requestPath).toBeDefined() + expect(existsSync(dirname(requestPath as string))).toBe(false) + }) + + it('wraps terminal argv literally and binds signalling and observation to the same scope', async () => { + const signalCalls: Array<[string, readonly string[]]> = [] + const queryCalls: Array<[string, readonly string[]]> = [] + const runSync = vi.fn((command: string, args: readonly string[]) => { + signalCalls.push([command, args]) + return { status: 0, stdout: '', stderr: '', error: undefined } + }) as unknown as typeof spawnSync + const query = vi.fn(async (command: string, args: readonly string[]) => { + queryCalls.push([command, args]) + return { status: 0, stdout: 'inactive\n', stderr: '' } + }) + const argv = ['/bin/bash', '-c', 'printf "%s" "$HOME"'] + const launch = prepareLinuxTerminalScope(argv, { + spawnSync: runSync, + systemdRun: '/usr/bin/systemd-run', + systemctl: '/usr/bin/systemctl', + systemctlQuery: query, + }) + const unitArg = launch.args.find(arg => arg.startsWith('--unit=')) + if (unitArg === undefined) throw new Error('terminal scope did not publish its unit') + const unit = `${unitArg.slice('--unit='.length)}.scope` + + expect(launch.command).toBe('/usr/bin/systemd-run') + expect(launch.args.slice(0, -argv.length)).toEqual([ + '--user', + '--scope', + '--quiet', + '--collect', + '--expand-environment=no', + unitArg, + '--', + ]) + expect(launch.args.slice(-argv.length)).toEqual(argv) + + const owner = launch.bindOwner(() => false) + owner.signal('SIGTERM') + owner.signal('SIGKILL') + await owner.waitForExit() + + expect(signalCalls).toEqual([ + [ + '/usr/bin/systemctl', + ['--user', 'kill', '--kill-whom=all', '--signal=SIGTERM', unit], + ], + [ + '/usr/bin/systemctl', + ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit], + ], + ]) + expect(queryCalls).toEqual([[ + '/usr/bin/systemctl', + ['--user', 'show', unit, '--property=ActiveState', '--value'], + ]]) + }) + it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { let wrapper: ReturnType | undefined let systemdArgs: readonly string[] = [] @@ -267,7 +377,7 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }), runnerInvocation: spawnRunnerInvocation(), }) - expect(launch.pid).toBe(-1) + expect(launch.pid).toBeUndefined() await expect(launch.direct).rejects.toThrow('runner failed to start') await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) @@ -391,6 +501,9 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () try { const defaults = await import('../src/linux-scope.ts') expect(defaults.probeLinuxScope()).toBe(true) + const terminalLaunch = defaults.prepareLinuxTerminalScope(['shell', 'literal $HOME']) + expect(terminalLaunch.command).toBe('systemd-run') + expect(terminalLaunch.args.slice(-3)).toEqual(['--', 'shell', 'literal $HOME']) const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBeUndefined() diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 37bcae6866..42b7fa22cc 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -380,6 +380,91 @@ describe('LocalSubprocessRuntime', () => { } }) + it('wraps Linux terminals in the selected scope and binds owner liveness', async () => { + let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined + let launcherRunning: (() => boolean) | undefined + const terminal = { + pid: 123, + onData: () => ({ dispose: () => {} }), + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => { + exitListener = listener + return { dispose: () => {} } + }, + write: () => {}, + kill: () => {}, + } + const nodePtySpawn = vi.fn(() => terminal) + const owner = { + signal: vi.fn(), + waitForExit: vi.fn(async () => {}), + } + const launcherStates: boolean[] = [] + const bindOwner = vi.fn((running: () => boolean) => { + launcherRunning = running + launcherStates.push(running()) + return owner + }) + const prepareLinuxTerminalScope = vi.fn((argv: readonly string[]) => ({ + command: '/usr/bin/systemd-run', + args: ['--user', '--scope', '--', ...argv], + bindOwner, + })) + const probeLinuxScope = vi.fn(() => true) + const inspector = { + foregroundPgid: () => undefined, + isStdinWaiting: () => false, + processTree: () => [{ pid: 123, started: 'shell' }], + processSession: () => [], + isAlive: () => false, + signalGroup: () => {}, + signalProcess: () => {}, + } + + vi.resetModules() + vi.doMock('node-pty', () => ({ spawn: nodePtySpawn })) + vi.doMock('../src/linux-scope.ts', () => ({ + launchLinuxScope: vi.fn(), + prepareLinuxTerminalScope, + probeLinuxScope, + })) + let fiber: { dispose(): Promise } | undefined + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const ctx = new Context() + fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) + const runtime = ctx.subprocess as InstanceType + runtime.internals = { platform: 'linux' } + runtime.terminalInspector = inspector + + const handle = await runtime.spawnTerminal({ + argv: ['shell', '--literal'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + }) + + expect(probeLinuxScope).toHaveBeenCalledOnce() + expect(prepareLinuxTerminalScope).toHaveBeenCalledExactlyOnceWith(['shell', '--literal']) + expect(nodePtySpawn).toHaveBeenCalledWith( + '/usr/bin/systemd-run', + ['--user', '--scope', '--', 'shell', '--literal'], + expect.objectContaining({ rows: 24, cols: 80 }), + ) + expect(bindOwner).toHaveBeenCalledOnce() + expect(launcherStates).toEqual([true]) + expect(launcherRunning?.()).toBe(true) + + exitListener?.({ exitCode: 0 }) + expect(launcherRunning?.()).toBe(false) + await handle.done + await new Promise(resolve => setImmediate(resolve)) + expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM') + expect(owner.waitForExit).toHaveBeenCalledOnce() + } finally { + await fiber?.dispose() + vi.doUnmock('node-pty') + vi.doUnmock('../src/linux-scope.ts') + vi.resetModules() + } + }) + it('retains a terminal whose automatic cleanup fails', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined const terminal = { @@ -456,31 +541,32 @@ describe('LocalSubprocessRuntime', () => { }) it('reports the platform-specific reason for every fallback mode', async () => { - const ctx = new Context() - const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const fiber = await ctx.plugin(LocalSubprocessRuntime) - const runtime = ctx.subprocess as unknown as { - warnFallback(platform: NodeJS.Platform): void - } - try { - for (const [platform, reason] of [ - ['darwin', 'macOS has no supported persistent process-range owner'], - ['linux', 'a modern readable user-systemd scope is unavailable'], - ['win32', 'the Win32 Job runner is unavailable'], - ['freebsd', 'platform freebsd has no native managed range'], - ] as const) { - runtime.warnFallback(platform) + for (const [platform, kind, reason] of [ + ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner'], + ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable'], + ['win32', 'ordinary', 'the Win32 Job runner is unavailable'], + ['win32', 'terminal', 'Windows ConPTY remains outside Job containment'], + ['freebsd', 'ordinary', 'platform freebsd has no native managed range'], + ] as const) { + const ctx = new Context() + const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const fiber = await ctx.plugin(LocalSubprocessRuntime) + const runtime = ctx.subprocess as unknown as { + warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void + } + try { + runtime.warnFallback(platform, kind) expect(warning).toHaveBeenLastCalledWith( expect.stringContaining(reason), ) + } finally { + warning.mockRestore() + await fiber.dispose() } - } finally { - warning.mockRestore() - await fiber.dispose() } }) - it('selects each available native owner once and contains release-observer failures', async () => { + it('selects an available native owner for every eligible spawn and contains release-observer failures', async () => { const linuxLaunch = { kind: 'linux' } const windowsLaunch = { kind: 'windows' } const launchLinuxScope = vi.fn(() => linuxLaunch) @@ -536,7 +622,7 @@ describe('LocalSubprocessRuntime', () => { await new Promise(resolve => setImmediate(resolve)) await linuxRuntime.spawn(spec('true')).done await new Promise(resolve => setImmediate(resolve)) - expect(probeLinuxScope).toHaveBeenCalledOnce() + expect(probeLinuxScope).toHaveBeenCalledTimes(3) expect(launchLinuxScope).toHaveBeenCalledTimes(2) const windowsContext = new Context() diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 7b428dab11..45dd4d92fe 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -16,6 +16,21 @@ function spec(graceMs = 30): SubprocessSpawnSpec { } describe('managed process binding', () => { + it('does not miss an abort between the initial check and listener registration', async () => { + let aborted = false + const addEventListener = vi.fn(() => { aborted = true }) + const removeEventListener = vi.fn() + const signal = { + get aborted() { return aborted }, + addEventListener, + removeEventListener, + } as unknown as AbortSignal + + await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) + expect(addEventListener).toHaveBeenCalledOnce() + expect(removeEventListener).toHaveBeenCalledOnce() + }) + it('contains owner failure after an already-aborted wait returns false', async () => { const controller = new AbortController() const ownerFailure = Promise.withResolvers() @@ -129,7 +144,7 @@ describe('managed process binding', () => { stdin: wrapper.stdin, stdout: wrapper.stdout, stderr: wrapper.stderr, - pid: wrapper.pid as number, + pid: wrapper.pid, direct: direct.promise, owner: { signal: vi.fn(), waitForExit: async () => {} }, }) @@ -154,7 +169,7 @@ describe('managed process binding', () => { stdin: wrapper.stdin, stdout: wrapper.stdout, stderr: wrapper.stderr, - pid: wrapper.pid as number, + pid: wrapper.pid, direct: new Promise(() => {}), owner: { signal: vi.fn(), waitForExit: async () => { throw failure } }, }) @@ -178,7 +193,7 @@ describe('managed process binding', () => { stdin: wrapper.stdin, stdout: wrapper.stdout, stderr: wrapper.stderr, - pid: wrapper.pid as number, + pid: wrapper.pid, direct, owner: { signal, waitForExit: async () => {} }, }) @@ -208,7 +223,7 @@ describe('managed process binding', () => { stdin: wrapper.stdin, stdout: wrapper.stdout, stderr: wrapper.stderr, - pid: wrapper.pid as number, + pid: wrapper.pid, direct: direct.promise, owner: { signal, diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 5b023db576..403a682178 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -2,7 +2,9 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { Context } from '@deepseek-ai/cordis' +import type { SubprocessSpawnSpec, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessRuntime from '../src/index.ts' import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts' import { bindManagedProcess } from '../src/spawn.ts' @@ -56,6 +58,77 @@ async function waitGone(pid: number): Promise { throw new Error(`pid ${pid} remained alive`) } +interface LinuxProcessState { + parentPid: number + processGroupId: number + sessionId: number + ttyNumber: number + foregroundProcessGroupId: number +} + +function readLinuxProcessState(pid: number): LinuxProcessState { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/) + const [parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId] = fields + .slice(1, 6) + .map(Number) + if ([parentPid, processGroupId, sessionId, ttyNumber, foregroundProcessGroupId] + .some(value => !Number.isSafeInteger(value))) { + throw new Error(`invalid /proc state for pid ${String(pid)}`) + } + return { + parentPid: parentPid as number, + processGroupId: processGroupId as number, + sessionId: sessionId as number, + ttyNumber: ttyNumber as number, + foregroundProcessGroupId: foregroundProcessGroupId as number, + } +} + +async function waitReparented(pid: number, originalParentPid: number): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const state = readLinuxProcessState(pid) + if (state.parentPid !== originalParentPid) return state + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`pid ${String(pid)} remained parented to ${String(originalParentPid)}`) +} + +function captureTerminalOutput(handle: SubprocessTerminalHandle): { + text(): string + waitFor(marker: string): Promise +} { + let output = '' + handle.output.on('data', (chunk: Buffer) => { output += chunk.toString() }) + return { + text: () => output, + waitFor: async (marker) => { + const deadline = Date.now() + 5_000 + while (!output.includes(marker) && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)) + } + if (!output.includes(marker)) { + throw new Error(`terminal did not emit ${JSON.stringify(marker)}; output: ${JSON.stringify(output)}`) + } + return output + }, + } +} + +async function waitForInputReadiness(handle: SubprocessTerminalHandle): Promise<{ + processGroupId: number + inputWaiting: boolean +}> { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const foreground = await handle.inspectForeground() + if (foreground?.inputWaiting === true) return foreground + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`terminal ${String(handle.pid)} never became input-ready`) +} + const linuxNative = process.platform === 'linux' && probeLinuxScope() describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { @@ -82,4 +155,78 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied)) await expect(deniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) }) + + it('keeps PTY identity and readiness while containing a reparented setsid descendant', async () => { + const escapedPath = join(scratch, `escaped-terminal-${Date.now()}.sh`) + const terminalPath = join(scratch, `terminal-${Date.now()}.sh`) + const launcherPidFile = join(scratch, `terminal-launcher-${Date.now()}.pid`) + const descendantPidFile = join(scratch, `terminal-descendant-${Date.now()}.pid`) + writeFileSync(escapedPath, `#!/bin/sh +printf '%s\\n' "$$" > "$1" +trap '' TERM +while :; do sleep 60; done +`, { mode: 0o700 }) + writeFileSync(terminalPath, `#!/bin/bash +set -eu +launcher_pid_file=$1 +descendant_pid_file=$2 +escaped_path=$3 +sh -c 'printf "%s\\n" "$$" > "$1"; setsid "$2" "$3" /dev/null 2>&1 &' sh "$launcher_pid_file" "$escaped_path" "$descendant_pid_file" +while [ ! -s "$descendant_pid_file" ]; do sleep 0.01; done +if [ -r /dev/tty ] && [ -w /dev/tty ]; then tty_ready=yes; else tty_ready=no; fi +printf 'PTY_READY pid=%s tty=%s\\n' "$$" "$tty_ready" > /dev/tty +IFS= read -r value < /dev/tty +printf 'PTY_INPUT=%s\\n' "$value" > /dev/tty +while :; do sleep 60; done +`, { mode: 0o700 }) + + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessRuntime) + let descendant: number | undefined + let handle: SubprocessTerminalHandle | undefined + try { + handle = await ctx.subprocess.spawnTerminal({ + argv: [terminalPath, launcherPidFile, descendantPidFile, escapedPath], + cwd: scratch, + rows: 24, + cols: 80, + graceMs: 100, + }) + const output = captureTerminalOutput(handle) + const readyOutput = await output.waitFor('PTY_READY') + const reportedPid = Number(/PTY_READY pid=(\d+) tty=yes/.exec(readyOutput)?.[1]) + expect(reportedPid, readyOutput).toBe(handle.pid) + + const top = readLinuxProcessState(handle.pid) + expect(top).toMatchObject({ + processGroupId: handle.pid, + sessionId: handle.pid, + foregroundProcessGroupId: handle.pid, + }) + expect(top.ttyNumber).not.toBe(0) + + const foreground = await waitForInputReadiness(handle) + expect(foreground).toEqual({ processGroupId: handle.pid, inputWaiting: true }) + await handle.write('continue\n') + await output.waitFor('PTY_INPUT=continue') + + const launcher = await waitForPid(launcherPidFile) + descendant = await waitForPid(descendantPidFile) + const escaped = await waitReparented(descendant, launcher) + expect(escaped.parentPid).not.toBe(launcher) + expect(escaped.processGroupId).toBe(descendant) + expect(escaped.sessionId).toBe(descendant) + expect(escaped.sessionId).not.toBe(handle.pid) + + await handle.terminate() + await handle.done + await waitGone(descendant) + } finally { + if (handle !== undefined) await handle.terminate().catch(() => {}) + if (descendant !== undefined) { + try { process.kill(descendant, 'SIGKILL') } catch { /* already contained */ } + } + await fiber.dispose() + } + }, 15_000) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index ba326cad6a..8d0ca45ed3 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,12 +1,14 @@ import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { Win32Error } from '@deepseek-ai/dsh-win32-process' +import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import { cleanupAfterRunner, runnerDirectResult, @@ -25,12 +27,13 @@ import { readRunnerEventsAsync, serializeSpawnError, } from '../src/runner-protocol.ts' +import { reportSpawnRunnerFailure, runSpawnRunner } from '../src/spawn-runner.ts' const sourceInvocation = [ process.execPath, '--import', 'tsx/esm', - fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/spawn-runner.ts')), + fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')), ] function spec(overrides: Partial = {}): SubprocessSpawnSpec { return { @@ -46,6 +49,60 @@ function fakeChild(pid: number | undefined): ChildProcess { return { pid } as ChildProcess } +class FakeRunnerHost extends EventEmitter { + env: NodeJS.ProcessEnv = {} + exitCode: number | undefined + connected = false + directory = process.cwd() + readonly disconnect = vi.fn(() => { this.connected = false }) + + cwd(): string { return this.directory } + chdir(directory: string): void { this.directory = directory } +} + +function asRunnerHost(host: FakeRunnerHost): Parameters[1] { + return host as unknown as Parameters[1] +} + +type RunnerInternals = NonNullable[2]> + +const fakeWin32Api = {} as Win32ProcessBindings +const fakeProcessHandle = 60n as NativePtr +const fakeJobHandle = 50n as NativePtr + +function fakeRunnerInternals(overrides: Partial = {}): RunnerInternals { + let nextPipeHandle = 70n + return { + spawn, + loadWin32ProcessBindings: vi.fn(() => fakeWin32Api), + openNamedPipeForStdio: vi.fn(() => nextPipeHandle++), + spawnCurrentTokenJobProcess: vi.fn(() => ({ + pid: 1234, + process: fakeProcessHandle, + job: fakeJobHandle, + })), + pollProcessExit: vi.fn(() => 0), + isJobEmpty: vi.fn(() => true), + terminateJob: vi.fn(), + waitForProcessExit: vi.fn(() => 0), + closeHandleChecked: vi.fn(), + ...overrides, + } as RunnerInternals +} + +function win32RunnerArgs( + requestPath: string, + eventsPath: string, + pipes: string[] = [], +): string[] { + return [ + '--mode', 'win32', + '--request', requestPath, + '--events', eventsPath, + ...pipes, + ] +} + function runRunner(invocation: string[], requestPath: string, eventsPath: string) { const [command, ...prefix] = invocation return spawnSync(command as string, [ @@ -62,6 +119,14 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string describe('spawn runner transport', () => { it('selects the source runner from source-plane execution', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) + const manifest = JSON.parse(readFileSync( + fileURLToPath(new URL('../package.json', import.meta.url)), + 'utf8', + )) as { exports: Record } + expect(manifest.exports['./spawn-runner']).toEqual({ + types: './lib/types/bin.d.ts', + default: './lib/spawn-runner.js', + }) }) it('does not require SharedArrayBuffer until a native handshake runs', async () => { @@ -100,6 +165,629 @@ describe('spawn runner transport', () => { expect(result.status).toBe(0) }) + it('runs the Node target lifecycle in-process through the coverable runner logic', async () => { + const files = createRunnerFiles({ + argv: [process.execPath, '-e', 'process.exit(12)'], + cwd: process.cwd(), + env: {}, + }) + const host = new FakeRunnerHost() + try { + await runSpawnRunner([ + '--mode', 'node', + '--request', files.requestPath, + '--events', files.eventsPath, + ], asRunnerHost(host)) + expect(host.exitCode).toBe(12) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect.objectContaining({ type: 'started' }), + { type: 'exit', exitCode: 12, signal: null }, + ]) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('reports an in-process Node target spawn failure', async () => { + const files = createRunnerFiles({ + argv: [`missing-dsh-runner-target-${String(process.pid)}-${String(Date.now())}`], + cwd: process.cwd(), + env: {}, + }) + const host = new FakeRunnerHost() + try { + await runSpawnRunner([ + '--mode', 'node', + '--request', files.requestPath, + '--events', files.eventsPath, + ], asRunnerHost(host)) + expect(host.exitCode).toBe(127) + const [event] = readRunnerEvents(files.eventsPath) + expect(event?.type).toBe('spawn-error') + if (event?.type !== 'spawn-error') throw new Error('expected spawn error') + expect(event.error.code).toBe('ENOENT') + } finally { + cleanupRunnerFiles(files) + } + }) + + it('contains a post-start Node runner error and ignores scope signals', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} }) + const host = new FakeRunnerHost() + const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess + const injectedSpawn = vi.fn(() => { + queueMicrotask(() => { + host.emit('SIGTERM') + child.emit('spawn') + child.emit('error', new Error('post-start node failure')) + child.emit('exit', 0, null) + }) + return child + }) as unknown as typeof spawn + try { + await runSpawnRunner([ + '--mode', 'node', + '--request', files.requestPath, + '--events', files.eventsPath, + ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) + expect(host.exitCode).toBe(127) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 4321 }, + { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } }, + ]) + expect(host.listenerCount('SIGTERM')).toBe(0) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('maps a signal-only Node exit to the runner failure exit code', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} }) + const host = new FakeRunnerHost() + const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess + const injectedSpawn = vi.fn(() => { + queueMicrotask(() => { + child.emit('spawn') + child.emit('exit', null, 'SIGTERM') + }) + return child + }) as unknown as typeof spawn + try { + await runSpawnRunner([ + '--mode', 'node', + '--request', files.requestPath, + '--events', files.eventsPath, + ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) + expect(host.exitCode).toBe(1) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 4321 }, + { type: 'exit', exitCode: null, signal: 'SIGTERM' }, + ]) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('runs the in-process capability probes and always closes the probe Job', async () => { + const nodeHost = new FakeRunnerHost() + await expect(runSpawnRunner( + ['--mode', 'probe-node'], + asRunnerHost(nodeHost), + fakeRunnerInternals(), + )).resolves.toBeUndefined() + + const host = new FakeRunnerHost() + host.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' + host.directory = 'C:\\runner' + const internals = fakeRunnerInternals() + await expect(runSpawnRunner( + ['--mode', 'probe-win32'], + asRunnerHost(host), + internals, + )).resolves.toBeUndefined() + expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(fakeWin32Api, { + command: 'C:\\Windows\\System32\\cmd.exe', + args: ['/d', '/s', '/c', 'exit 0'], + cwd: 'C:\\runner', + }) + expect(internals.waitForProcessExit).toHaveBeenCalledWith(fakeWin32Api, fakeProcessHandle) + expect(internals.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeJobHandle, + 'subprocess Windows Job probe', + ) + + const legacyHost = new FakeRunnerHost() + legacyHost.env.COMSPEC = 'legacy-cmd.exe' + const failing = fakeRunnerInternals({ waitForProcessExit: vi.fn(() => 9) }) + await expect(runSpawnRunner( + ['--mode', 'probe-win32'], + asRunnerHost(legacyHost), + failing, + )).rejects.toThrow('probe exited with code 9') + expect(failing.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeJobHandle, + 'subprocess Windows Job probe', + ) + + await expect(runSpawnRunner( + ['--mode', 'probe-win32'], + asRunnerHost(new FakeRunnerHost()), + fakeRunnerInternals(), + )).rejects.toThrow('without ComSpec') + }) + + it('runs the Win32 target, forwards every pipe, and waits for an empty Job', async () => { + vi.useFakeTimers() + const files = createRunnerFiles({ + argv: ['tool.exe', 'literal $HOME'], + cwd: 'C:\\target', + env: { ONLY: 'kept' }, + }) + const host = new FakeRunnerHost() + host.env.STALE = 'removed' + host.directory = 'C:\\runner' + host.connected = true + const pollProcessExit = vi.fn() + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(42) + const isJobEmpty = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty }) + try { + const running = runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ + '--stdin-pipe', '\\\\.\\pipe\\stdin', + '--stdout-pipe', '\\\\.\\pipe\\stdout', + '--stderr-pipe', '\\\\.\\pipe\\stderr', + ]), asRunnerHost(host), internals) + await vi.advanceTimersByTimeAsync(30) + await running + + expect(host.env).toEqual({ ONLY: 'kept' }) + expect(host.directory).toBe('C:\\runner') + expect(host.disconnect).toHaveBeenCalledOnce() + expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( + 1, + fakeWin32Api, + '\\\\.\\pipe\\stdin', + 'read', + ) + expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( + 2, + fakeWin32Api, + '\\\\.\\pipe\\stdout', + 'write', + ) + expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( + 3, + fakeWin32Api, + '\\\\.\\pipe\\stderr', + 'write', + ) + expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith( + fakeWin32Api, + { command: 'tool.exe', args: ['literal $HOME'], cwd: 'C:\\target' }, + { + stdin: 70n, + stdout: 71n, + stderr: 72n, + }, + ) + expect(pollProcessExit).toHaveBeenCalledTimes(2) + expect(isJobEmpty).toHaveBeenCalledTimes(2) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { type: 'exit', exitCode: 42, signal: null }, + ]) + expect(internals.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeProcessHandle, + 'ordinary direct process', + ) + expect(internals.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeJobHandle, + 'ordinary process Job', + ) + } finally { + vi.useRealTimers() + cleanupRunnerFiles(files) + } + }) + + it('accepts only the Win32 terminate IPC message and coalesces disconnect', async () => { + vi.useFakeTimers() + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + const internals = fakeRunnerInternals() + try { + const running = runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + ) + host.emit('message', null) + host.emit('message', 'terminate') + host.emit('message', { type: 'other' }) + host.emit('message', { type: 'terminate' }) + host.emit('message', { type: 'terminate' }) + host.emit('disconnect') + await vi.advanceTimersByTimeAsync(10) + await running + + expect(internals.terminateJob).toHaveBeenCalledOnce() + expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1) + expect(host.disconnect).not.toHaveBeenCalled() + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { type: 'exit', exitCode: 0, signal: null }, + ]) + } finally { + vi.useRealTimers() + cleanupRunnerFiles(files) + } + }) + + it('reports a non-Error Win32 termination failure and closes both live handles', async () => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + host.connected = true + const terminateJob = vi.fn(() => { throw 'raw termination failure' }) + const internals = fakeRunnerInternals({ terminateJob }) + try { + const running = runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + ) + host.emit('disconnect') + await running + + expect(host.exitCode).toBe(127) + expect(host.disconnect).toHaveBeenCalledOnce() + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } }, + ]) + expect(internals.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeProcessHandle, + 'ordinary direct process cleanup', + ) + expect(internals.closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeJobHandle, + 'ordinary process Job cleanup', + ) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + [2, 'ENOENT'], + [3, 'ENOENT'], + [267, 'ENOENT'], + [5, 'EACCES'], + [193, 'EFTYPE'], + [999, 'UNKNOWN'], + ] as const)('maps Win32 CreateProcess error %i to %s', async (win32Code, code) => { + const files = createRunnerFiles({ + argv: ['missing.exe', 'literal argument'], + cwd: 'C:\\target', + env: {}, + }) + const host = new FakeRunnerHost() + const internals = fakeRunnerInternals({ + spawnCurrentTokenJobProcess: vi.fn(() => { + throw new Win32Error('CreateProcessW', win32Code) + }), + }) + try { + await runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + ) + expect(host.exitCode).toBeUndefined() + const [event] = readRunnerEvents(files.eventsPath) + expect(event?.type).toBe('spawn-error') + if (event?.type !== 'spawn-error') throw new Error('expected spawn error') + expect(event.error).toMatchObject({ + code, + syscall: 'spawn missing.exe', + path: 'missing.exe', + spawnargs: ['literal argument'], + }) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + [undefined, false], + ['ENOENT', true], + ] as const)('maps a target chdir failure with code %s', async (code, hasSpawnShape) => { + const files = createRunnerFiles({ argv: ['tool.exe', 'arg'], cwd: 'C:\\missing', env: {} }) + const host = new FakeRunnerHost() + const error = Object.assign(new Error('target cwd failed'), { + syscall: 'chdir', + ...code === undefined ? {} : { code }, + }) + host.chdir = vi.fn(() => { throw error }) + try { + await runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + fakeRunnerInternals(), + ) + expect(host.exitCode).toBeUndefined() + const [event] = readRunnerEvents(files.eventsPath) + expect(event?.type).toBe('spawn-error') + if (event?.type !== 'spawn-error') throw new Error('expected spawn error') + expect(typeof event.error.message).toBe('string') + expect('path' in event.error).toBe(hasSpawnShape) + if (hasSpawnShape) { + expect(event.error).toMatchObject({ + code: 'ENOENT', + syscall: 'spawn tool.exe', + path: 'tool.exe', + spawnargs: ['arg'], + }) + } else { + expect(event.error).toMatchObject({ message: 'target cwd failed', syscall: 'chdir' }) + } + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + ['a non-CreateProcess Win32 error', new Win32Error('CreateFileW', 5), 'Win32Error'], + ['a non-Error setup failure', 'raw pipe setup failure', 'Error'], + ])('reports %s as runner infrastructure failure', async (_label, failure, name) => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + const internals = fakeRunnerInternals({ + openNamedPipeForStdio: vi.fn(() => { throw failure }), + }) + try { + await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ + '--stdin-pipe', '\\\\.\\pipe\\stdin', + ]), asRunnerHost(host), internals) + expect(host.exitCode).toBe(127) + const [event] = readRunnerEvents(files.eventsPath) + expect(event?.type).toBe('runner-error') + if (event?.type !== 'runner-error') throw new Error('expected runner error') + expect(event.error.name).toBe(name) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + ['an Error', new Error('stdio close failed')], + ['a non-Error value', 'raw stdio close failure'], + ])('reports %s from the initial stdio close and retries cleanup', async (_label, failure) => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + let failedOnce = false + const closeHandleChecked = vi.fn((_api, _handle, label: string) => { + if (!failedOnce && label.includes('pipe')) { + failedOnce = true + throw failure + } + }) + const internals = fakeRunnerInternals({ closeHandleChecked }) + try { + await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ + '--stdin-pipe', '\\\\.\\pipe\\stdin', + ]), asRunnerHost(new FakeRunnerHost()), internals) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { + type: 'runner-error', + error: { name: 'Error', message: failure instanceof Error ? failure.message : failure }, + }, + ]) + expect(closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + 70n, + 'ordinary target stdin pipe', + ) + expect(closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + 70n, + 'ordinary target stdin pipe', + ) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('preserves the first stdio close failure while retaining every failed handle', async () => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + let remainingFailures = 2 + const closeHandleChecked = vi.fn((_api, _handle, label: string) => { + if (remainingFailures > 0 && label.includes('pipe')) { + remainingFailures -= 1 + throw remainingFailures === 1 ? new Error('first close failure') : 'second close failure' + } + }) + const internals = fakeRunnerInternals({ closeHandleChecked }) + try { + await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ + '--stdin-pipe', '\\\\.\\pipe\\stdin', + '--stdout-pipe', '\\\\.\\pipe\\stdout', + ]), asRunnerHost(new FakeRunnerHost()), internals) + expect(readRunnerEvents(files.eventsPath)).toContainEqual({ + type: 'runner-error', + error: { name: 'Error', message: 'first close failure' }, + }) + expect(closeHandleChecked).toHaveBeenCalledTimes(6) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + ['poll', 'poll failed'], + ['direct close', 'direct close failed'], + ['Job query', 'Job query failed'], + ['Job close', 'Job close failed'], + ] as const)('reports a Win32 %s failure and cleans remaining handles', async (stage, message) => { + vi.useFakeTimers() + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const pollProcessExit = vi.fn(() => { + if (stage === 'poll') throw new Error(message) + return 0 + }) + const isJobEmpty = vi.fn(() => { + if (stage === 'Job query') throw new Error(message) + return true + }) + const closeHandleChecked = vi.fn((_api, _handle, label: string) => { + if (stage === 'direct close' && label === 'ordinary direct process') { + throw new Error(message) + } + if (stage === 'Job close' && label === 'ordinary process Job') { + throw new Error(message) + } + if (label.endsWith('cleanup')) throw new Error('ignored cleanup failure') + }) + const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty, closeHandleChecked }) + try { + const running = runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(new FakeRunnerHost()), + internals, + ) + await vi.advanceTimersByTimeAsync(10) + await running + + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }], + { type: 'runner-error', error: { name: 'Error', message } }, + ]) + expect(closeHandleChecked).toHaveBeenCalledWith( + fakeWin32Api, + fakeJobHandle, + expect.stringContaining('Job'), + ) + } finally { + vi.useRealTimers() + cleanupRunnerFiles(files) + } + }) + + it('preserves the first failure when termination settles reentrantly during polling', async () => { + vi.useFakeTimers() + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + const terminateJob = vi.fn(() => { throw new Error('reentrant termination failed') }) + const pollProcessExit = vi.fn(() => { + host.emit('disconnect') + return 0 + }) + const internals = fakeRunnerInternals({ terminateJob, pollProcessExit }) + try { + const running = runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + ) + await vi.advanceTimersByTimeAsync(10) + await running + + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { type: 'exit', exitCode: 0, signal: null }, + { + type: 'runner-error', + error: { name: 'Error', message: 'reentrant termination failed' }, + }, + ]) + } finally { + vi.useRealTimers() + cleanupRunnerFiles(files) + } + }) + + it('reports failure while restoring cwd after a successful Win32 spawn', async () => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + host.directory = 'C:\\runner' + const chdir = vi.fn((directory: string) => { + if (directory === 'C:\\runner') throw new Error('cwd restore failed') + host.directory = directory + }) + host.chdir = chdir + try { + await runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + fakeRunnerInternals(), + ) + expect(chdir).toHaveBeenCalledTimes(2) + expect(host.exitCode).toBe(127) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'started', pid: 1234 }, + { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } }, + ]) + } finally { + cleanupRunnerFiles(files) + } + }) + + it('disconnects after an uncaught Win32 binding setup failure', async () => { + const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) + const host = new FakeRunnerHost() + host.connected = true + const internals = fakeRunnerInternals({ + loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding setup failed') }), + }) + try { + await expect(runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + )).rejects.toThrow('binding setup failed') + expect(host.disconnect).toHaveBeenCalledOnce() + expect(readRunnerEvents(files.eventsPath)).toEqual([]) + } finally { + cleanupRunnerFiles(files) + } + }) + + it.each([ + [['--mode'], 'missing value'], + [['--unknown', 'value'], 'unknown argument'], + [['--mode', 'unknown'], 'unknown mode'], + [['--mode', 'node'], 'requires request and event paths'], + ] as const)('rejects invalid runner arguments: %s', async (argv, message) => { + await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message) + }) + + it('reports only failures whose arguments identify an event transport', () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + reportSpawnRunnerFailure([ + '--mode', 'node', + '--request', files.requestPath, + '--events', files.eventsPath, + ], new Error('runner main failed')) + reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure')) + reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure')) + expect(readRunnerEvents(files.eventsPath)).toEqual([ + { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } }, + ]) + } finally { + cleanupRunnerFiles(files) + } + }) + it('maps every target stdio disposition', () => { expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) expect(runnerStdio(spec({ @@ -299,7 +987,7 @@ describe('spawn runner transport', () => { error: { name: 'Error', message: 'runner setup failed', code: 'EIO' }, }) const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise(() => {})) - expect(result.pid).toBe(-1) + expect(result.pid).toBeUndefined() expect(result.failureReported).toBe(true) await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' }) } finally { @@ -309,11 +997,11 @@ describe('spawn runner transport', () => { const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) + const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) appendRunnerEvent(afterStartFailure.eventsPath, { type: 'runner-error', error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, }) - const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) expect(result.pid).toBe(456) expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) @@ -334,6 +1022,38 @@ describe('spawn runner transport', () => { }) + it('publishes terminal events already present in the handshake snapshot', async () => { + const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(failed.eventsPath, { + type: 'spawn-error', + error: { name: 'Error', message: 'target missing', code: 'ENOENT' }, + }) + const result = runnerDirectResult(fakeChild(123), failed, new Promise(() => {})) + let observed: Error | undefined + void result.direct.catch((error: unknown) => { + observed = error instanceof Error ? error : new Error(String(error)) + }) + await Promise.resolve() + expect(observed).toMatchObject({ message: 'target missing', code: 'ENOENT' }) + } finally { + cleanupRunnerFiles(failed) + } + + const exited = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) + try { + appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 }) + appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null }) + const result = runnerDirectResult(fakeChild(123), exited, new Promise(() => {})) + let observed: SubprocessOutcome | undefined + void result.direct.then((outcome) => { observed = outcome }) + await Promise.resolve() + expect(observed).toEqual({ exitCode: 23, signal: null }) + } finally { + cleanupRunnerFiles(exited) + } + }) + it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => { const staleRead = Promise.withResolvers>>() let readCount = 0 @@ -395,7 +1115,7 @@ describe('spawn runner transport', () => { }) const lifecycle = observeChildLifecycle(child) const result = runnerDirectResult(child, files, lifecycle.exited) - expect(result.pid).toBe(-1) + expect(result.pid).toBeUndefined() expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toThrow('runner failed to start') await expect(lifecycle.closed).resolves.toBeUndefined() @@ -407,14 +1127,14 @@ describe('spawn runner transport', () => { it('reports runner startup failure and handshake timeout without leaking request files', async () => { const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise(() => {})) - expect(missingResult.pid).toBe(-1) + expect(missingResult.pid).toBeUndefined() expect(missingResult.failureReported).toBe(false) await expect(missingResult.direct).rejects.toThrow('runner failed to start') expect(existsSync(missingChild.directory)).toBe(false) const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise(() => {})) - expect(exitedResult.pid).toBe(-1) + expect(exitedResult.pid).toBeUndefined() expect(exitedResult.failureReported).toBe(false) await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start') expect(existsSync(exitedChild.directory)).toBe(false) @@ -423,7 +1143,7 @@ describe('spawn runner transport', () => { const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) try { const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise(() => {})) - expect(timedOutResult.pid).toBe(-1) + expect(timedOutResult.pid).toBeUndefined() expect(timedOutResult.failureReported).toBe(false) await expect(timedOutResult.direct).rejects.toThrow('did not report target start') expect(existsSync(timedOut.directory)).toBe(false) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 2eb2f42b36..8b54a6b3de 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -252,12 +252,14 @@ describe('spawnSubprocess', () => { ...spec('unused', { graceMs }), argv: [process.execPath, '-e', childScript], }) + const rootPid = running.pid + if (rootPid === undefined) throw new Error('test child did not publish a pid') const helper = await waitForPidFile(pidFile) const realKill: typeof process.kill = process.kill.bind(process) let termAt = 0 let forceSignals = 0 const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { - if (target !== -running.pid) return realKill(target, signal) + if (target !== -rootPid) return realKill(target, signal) if (signal === 'SIGTERM') { termAt = Date.now() return realKill(target, signal) @@ -582,9 +584,8 @@ describe('OutputCollector', () => { }) describe('killGroup', () => { - it('ignores non-positive pids', () => { - expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow() - expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow() + it('ignores an unpublished pid', () => { + expect(() => { killGroup(undefined, 'SIGTERM') }).not.toThrow() }) it('swallows ESRCH for vanished groups', async () => { @@ -774,9 +775,8 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina }) describe('coverage seams', () => { - it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { - expect(() => { taskkillProcessTree(-1) }).not.toThrow() - expect(() => { taskkillProcessTree(0) }).not.toThrow() + it('taskkillProcessTree ignores an unpublished pid and contains a missing binary', () => { + expect(() => { taskkillProcessTree(undefined) }).not.toThrow() // On POSIX there is no taskkill; spawnSync reports the failure in its // result and the function stays silent — the same containment Windows // relies on for an already-absent tree. @@ -792,11 +792,13 @@ describe('coverage seams', () => { platform: 'linux', linuxProcessGroupHasLiveMembers: () => false, }) + const rootPid = running.pid + if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0) { if (signal === 0) return true - if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL') + if (signal === 'SIGKILL') realKill(rootPid, 'SIGKILL') return true } return realKill(target, signal) @@ -812,6 +814,8 @@ describe('coverage seams', () => { it('treats a vanished group probe as quiescent without signalling', async () => { const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const rootPid = running.pid + if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0) { @@ -822,7 +826,7 @@ describe('coverage seams', () => { try { running.terminate() await new Promise(resolve => setTimeout(resolve, 20)) - realKill(running.pid, 'SIGKILL') + realKill(rootPid, 'SIGKILL') await running.done await expect(running.waitForExit()).resolves.toBe(true) } finally { @@ -832,6 +836,8 @@ describe('coverage seams', () => { it('treats an EPERM group probe as still alive', async () => { const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const rootPid = running.pid + if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0 && signal === 0) { @@ -843,7 +849,7 @@ describe('coverage seams', () => { await expect(running.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) } finally { killSpy.mockRestore() - realKill(-running.pid, 'SIGKILL') + realKill(-rootPid, 'SIGKILL') await running.done } }) @@ -1001,6 +1007,8 @@ describe('coverage seams 2', () => { // An inert taskkill simulates a tree that never reports exit: terminate() // delivers nothing, so a bounded consumer wait must come back false. const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) + const rootPid = running.pid + if (rootPid === undefined) throw new Error('test child did not publish a pid') running.terminate() const bound = new AbortController() const timer = setTimeout(() => { bound.abort() }, 60) @@ -1008,7 +1016,7 @@ describe('coverage seams 2', () => { clearTimeout(timer) // Real cleanup: the injected platform spawned without detachment, so the // child is a plain (group-less) POSIX process — kill it directly. - process.kill(running.pid, 'SIGKILL') + process.kill(rootPid, 'SIGKILL') await running.done }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index c2aca88085..b54600ba3d 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -5,6 +5,7 @@ import type { ProcessIdentity, ProcessInspector, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import type { BoundProcessOwner } from '@deepseek-ai/dsh-subprocess-local/src/managed-owner.ts' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' class FakePty { @@ -91,6 +92,113 @@ 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 () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const stopped = Promise.withResolvers() + const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const owner: BoundProcessOwner = { + signal(signal) { + signals.push(signal) + if (signal === 'SIGTERM') { + pty.emitExit(0, 15) + stopped.resolve(undefined) + } + }, + waitForExit: () => stopped.promise, + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) + + await handle.terminate() + + expect(signals).toEqual(['SIGTERM']) + await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) + }) + + it('escalates a managed range to KILL after the TERM grace expires', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + const stopped = Promise.withResolvers() + const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const owner: BoundProcessOwner = { + signal(signal) { + signals.push(signal) + if (signal === 'SIGKILL') { + pty.emitExit(0, 9) + stopped.resolve(undefined) + } + }, + waitForExit: () => stopped.promise, + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) + + const terminating = handle.terminate() + await vi.advanceTimersByTimeAsync(10) + await terminating + + expect(signals).toEqual(['SIGTERM', 'SIGKILL']) + }) + + it('force-kills a managed range when observation rejects and preserves that failure', async () => { + const pty = new FakePty() + const failure = new Error('scope became unreadable') + const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const owner: BoundProcessOwner = { + signal: (signal) => { signals.push(signal) }, + waitForExit: async () => { throw failure }, + } + const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) + + await expect(handle.terminate()).rejects.toBe(failure) + expect(signals).toEqual(['SIGTERM', 'SIGKILL']) + }) + + it('routes managed terminal host exit directly to KILL', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const signal = vi.fn() + const owner: BoundProcessOwner = { signal, waitForExit: async () => {} } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) + + handle.terminateForHostExit() + + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('waits for the node-pty exit event after the managed range becomes empty', async () => { + const pty = new FakePty() + const owner: BoundProcessOwner = { signal: vi.fn(), waitForExit: async () => {} } + const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner) + let settled = false + + const terminating = handle.terminate().then(() => { settled = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + + pty.emitExit() + await terminating + }) + + it('rejects when a managed range stops but node-pty never publishes exit', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const owner: BoundProcessOwner = { + signal: (signal) => { signals.push(signal) }, + waitForExit: async () => {}, + } + const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) + + const terminating = handle.terminate() + const rejected = expect(terminating).rejects.toThrow('terminal cleanup failed; surviving pid: 123') + await vi.advanceTimersByTimeAsync(10) + await rejected + expect(signals).toEqual(['SIGTERM']) + }) + it('force-kills descendants around the shell during synchronous host exit', () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/packages/subprocess/subprocess-local/tsdown.config.ts b/packages/subprocess/subprocess-local/tsdown.config.ts index 7902eb0af1..845b418bde 100644 --- a/packages/subprocess/subprocess-local/tsdown.config.ts +++ b/packages/subprocess/subprocess-local/tsdown.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', - 'spawn-runner': 'lib/types/spawn-runner.js', + 'spawn-runner': 'lib/types/bin.js', }, outDir: 'lib', format: ['esm'], diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 398e87e81c..3e75c9df2e 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 61aec9024427a6530f4e877c2fd35cdf301af169 -README.zh.md: 4740bef7062162f444cdf7b713ed4a5c52fc02be +README.md: 4006cb8365962f4f01b2ae4bb9bcaff093dfd2b8 +README.zh.md: 2abd58727e9e51f891206faad3636486808cdabd diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 61aec90244..4006cb8365 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -6,7 +6,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl ## Contract -- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`, which remains `-1` while unavailable or after startup fails. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn or provider failures. +- `spawn(spec)` returns a live handle synchronously. The provider owns the meaning and publication timing of `pid`; it is `undefined` until a real target PID is available and does not encode failure. `done` resolves with the spawned command's exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects for spawn or provider failures. - Spawn working directories and executable paths belong to the provider's execution world. `resolveExecutable(command, env?, signal?)` verifies absolute commands or resolves bare names against that world's scrubbed PATH plus explicit overrides. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the caller's config, not to a hidden subprocess-service default (the `dsh-shell` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 4740bef706..2abd58727e 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,7 +6,7 @@ ## 约定 -- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;尚不可用或启动失败后,其值为 `-1`。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 或 provider 失败时 reject。 +- `spawn(spec)` 同步返回活动句柄。`pid` 的含义和发布时间由 provider 拥有;真实目标 PID 尚不可用时为 `undefined`,且不用于表示失败。`done` 以已启动命令的退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),并在 spawn 或 provider 失败时 reject。 - spawn 工作目录和可执行文件路径属于提供方的执行世界。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方的配置,而不属于某个隐藏的子进程服务默认值(`dsh-shell` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index c2b43f919f..bfb2ce1ad1 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -165,8 +165,8 @@ export interface SubprocessCollectedOutputs { * observe. */ export interface SubprocessHandle { - /** Provider-published process identifier; -1 while unavailable or after startup fails. */ - readonly pid: number + /** Provider-published target process identifier, or undefined until it is available. */ + readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index bd752352d2..d2b75b4536 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -26,7 +26,7 @@ class StubSubprocessRuntime extends SubprocessRuntime { ? { stdout: { readFrom: () => read } } : {} return { - pid: spec.argv.length, + pid: spec.argv[0] === 'pending' ? undefined : spec.argv.length, stdin: undefined, stdout: undefined, stderr: undefined, @@ -68,6 +68,21 @@ describe('SubprocessRuntime seam', () => { expect(outcome.exitCode).toBe(0) }) + it('preserves an unavailable provider pid without treating it as failure', async () => { + const ctx = new Context() + await ctx.plugin(StubSubprocessRuntime) + const handle = ctx.subprocess.spawn({ + argv: ['pending'], + cwd: '/stub', + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + graceMs: 1, + }) + + expect(handle.pid).toBeUndefined() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).resolves.toBe(true) + }) + it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 648514158c..fe847ff48d 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 3859a504d2ec011d01df0a43141f96f2dc89d1c9 -README.zh.md: 877acb4f4f30a7aa387dc95a2df2efee726f36e6 +README.md: dd29d5f4a30d322b6d8a4cbb16cb3d8bc32b4ba4 +README.zh.md: fc7456c3277afcd408b074b7dda96b436c8236ea diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 3859a504d2..dd29d5f4a3 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -10,8 +10,8 @@ Low-level Win32 process library consumed by the Windows ACL sandbox and the ordi - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnOrdinaryJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle. -- **Ordinary Job runner primitive** — `spawnOrdinaryJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero. +- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnCurrentTokenJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle. +- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 877acb4f4f..fc7456c327 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -10,8 +10,8 @@ - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnOrdinaryJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。 -- **ordinary Job runner 原语** — `spawnOrdinaryJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。 +- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnCurrentTokenJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。 +- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index ba5dfdcc9d..fe61f4a409 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -24,14 +24,14 @@ export { openNamedPipeForStdio, pollProcessExit, spawnInheritedJobProcess, - spawnOrdinaryJobProcess, + spawnCurrentTokenJobProcess, spawnPipedProcess, terminateJob, waitForProcessExit, } from './process.ts' export type { ChildStdioHandles, - OrdinaryProcessSpawnOptions, + CurrentTokenProcessSpawnOptions, SpawnedJobProcess, SpawnedPipedProcess, } from './process.ts' diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index b56804e0df..608e31c100 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -54,7 +54,7 @@ export function buildCommandLine(program: string, args: readonly string[]): stri } /** Ordinary process creation inputs used by the local Win32 runner. */ -export interface OrdinaryProcessSpawnOptions { +export interface CurrentTokenProcessSpawnOptions { /** Executable argv entry passed through CreateProcess. */ command: string /** Arguments excluding the executable. */ @@ -71,7 +71,7 @@ export interface ChildStdioHandles { } /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ -export interface RestrictedProcessSpawnOptions extends OrdinaryProcessSpawnOptions { +export interface RestrictedProcessSpawnOptions extends CurrentTokenProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ token: NativePtr } @@ -357,7 +357,7 @@ export function openNamedPipeForStdio( /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, - options: OrdinaryProcessSpawnOptions, + options: CurrentTokenProcessSpawnOptions, stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, @@ -486,9 +486,9 @@ export function spawnInheritedJobProcess( * @param stdio - optional explicit handles opened for this target. * @returns caller-owned process and Job handles after successful resume. */ -export function spawnOrdinaryJobProcess( +export function spawnCurrentTokenJobProcess( api: Win32ProcessBindings, - options: OrdinaryProcessSpawnOptions, + options: CurrentTokenProcessSpawnOptions, stdio: ChildStdioHandles = {}, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 60f51bbef6..6035b7c3a6 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -5,7 +5,7 @@ import { isJobEmpty, openNamedPipeForStdio, pollProcessExit, - spawnOrdinaryJobProcess, + spawnCurrentTokenJobProcess, terminateJob, Win32Error, } from '../src/index.ts' @@ -83,7 +83,7 @@ describe('ordinary Job process operations', () => { resumeThread: vi.fn(() => { events.push('resume'); return 0 }), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), }) - expect(spawnOrdinaryJobProcess(bindings, { + expect(spawnCurrentTokenJobProcess(bindings, { command: 'probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', @@ -109,7 +109,7 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnOrdinaryJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) + spawnCurrentTokenJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) } catch (error) { caught = error } @@ -132,7 +132,7 @@ describe('ordinary Job process operations', () => { return 1 }), }) - expect(spawnOrdinaryJobProcess(bindings, { + expect(spawnCurrentTokenJobProcess(bindings, { command: 'probe.exe', args: [], cwd: 'C:\\work', diff --git a/packages/typert/generator/src/cordis-catalog.ts b/packages/typert/generator/src/cordis-catalog.ts index 8ce666b96c..ec3065d94a 100644 --- a/packages/typert/generator/src/cordis-catalog.ts +++ b/packages/typert/generator/src/cordis-catalog.ts @@ -826,7 +826,7 @@ function renderRuntimeApi( ' const next: string[] = []', ' for (const entry of TYPE_API) {', ' if (included.has(entry.name)) continue', - ' const pattern = new RegExp(`\\b${entry.name}\\b`)', + ' const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)', ' if (!frontier.some(text => pattern.test(text))) continue', ' included.add(entry.name)', ' next.push(entry.declaration)', diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index ab62291aed..ed68088e09 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -80,7 +80,9 @@ describe('Typert-backed Cordis catalog', () => { ) } } - expect(projector.renderRuntimeApi(model)).toBe( + const runtimeApi = projector.renderRuntimeApi(model) + expect(runtimeApi).toContain('const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)') + expect(runtimeApi).toBe( expected('packages/extensions/tool-cordis/src/api-catalog.ts'), ) }) diff --git a/vitest.config.ts b/vitest.config.ts index 9c8c0730dd..7ac8101425 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -84,13 +84,6 @@ const windowsRunnerCoverageExclusions = process.platform === 'win32' ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] : [] -// The ordinary subprocess runner is a source/built child-process entry on -// every platform. Its real-entry smoke tests execute it out of process, where -// the parent Vitest coverage provider cannot instrument the module. -const subprocessRunnerCoverageExclusions = [ - 'packages/subprocess/subprocess-local/src/spawn-runner.ts', -] - // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh // (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -199,7 +192,6 @@ export default defineConfig({ 'packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', - ...subprocessRunnerCoverageExclusions, // Dynamic Host/Client composition is covered by its focused lifecycle // tests and assembled application checks rather than per-file coverage. 'packages/self-modification/*/src/**/*.{ts,tsx}', From 26969588b7f3960c2857306f024c586557b9bf44 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 21:51:22 +0800 Subject: [PATCH 061/110] fix(subprocess): address native containment review findings --- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 6 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 6 +- ...20-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-20-subprocess-native-containment.md | 6 +- ...-08-20-subprocess-native-containment.zh.md | 6 +- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +- ...2026-07-22-cross-platform-test-fixtures.md | 4 +- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 6 +- packages/e2b/subprocess-e2b/README.zh.md | 6 +- packages/e2b/subprocess-e2b/src/process.ts | 18 ++-- .../subprocess-e2b/tests/subprocess.spec.ts | 7 +- .../subagent/subagent-claude-code/src/run.ts | 7 +- .../tests/subagent-claude-code.spec.ts | 19 ++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 35 ++++++- .../subprocess-local/src/linux-scope.ts | 46 ++++++--- .../subprocess-local/src/runner-launch.ts | 89 ++++------------- .../subprocess/subprocess-local/src/spawn.ts | 2 +- .../subprocess-local/src/windows-job.ts | 9 +- .../tests/linux-scope.spec.ts | 64 ++++++------- .../subprocess-local/tests/local.spec.ts | 96 ++++++++++++++++++- .../tests/managed-spawn.spec.ts | 20 ++++ .../tests/native-windows.spec.ts | 6 +- .../tests/spawn-runner.spec.ts | 76 ++++++--------- .../tests/windows-job.spec.ts | 10 +- 30 files changed, 340 insertions(+), 232 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 4bd104ef6d..759d994677 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: d029e3eb368b175264c730cd080b431b46c73b34 -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 247af134728eee420d838b92f93105857517bb60 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 086bb2f3763af513b07e0c20910ef80116695ca4 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 524fa6b0d4e4f5012c44e108fd0cecc5d7012802 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index d029e3eb36..086bb2f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -17,7 +17,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: - An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. -- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. +- A terminal handle with a native Linux owner synchronously signals that scope with SIGKILL. A fallback terminal instead signals every captured and currently observable descendant, kills the PTY root, then rescans once for members that became observable during that boundary. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: POSIX ordinary ranges receive TERM, the configured grace, then KILL; Windows ordinary ranges terminate immediately; and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. @@ -32,7 +32,7 @@ Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subpr A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. -Unit evidence pins synchronous native-owner and fallback delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. +Unit evidence pins synchronous native-owner and fallback delivery, native terminal owner routing, fallback scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. ## Alternatives considered @@ -48,4 +48,4 @@ Unit evidence pins synchronous native-owner and fallback delivery, terminal scan Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. -The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it. Native ordinary-process ownership is described by the [containment decision](2026-08-20-subprocess-native-containment.md); PTY ownership remains a separate boundary. +The listener cannot cover failures that do not execute JavaScript. Supported Linux terminals signal the scope described by the [containment decision](2026-08-20-subprocess-native-containment.md); fallback terminals still cannot discover a descendant that escaped before the provider observed it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 247af13472..524fa6b0d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -17,7 +17,7 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: - 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 -- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- 具有 native Linux owner 的 terminal handle 会同步向该 scope 发送 SIGKILL。fallback terminal 则向全部已捕获及当前可观察的后代发送信号,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:POSIX ordinary range 先接收 TERM,经过配置的宽限期后再接收 KILL;Windows ordinary range 立即终止;每个 ordinary 或 terminal 清理都会等待完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 @@ -32,7 +32,7 @@ Status: implemented 父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 -单元证据固定同步 native-owner 与 fallback 投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 +单元证据固定同步 native-owner 与 fallback 投递、native terminal owner 路由、PTY root 终止前后的 fallback terminal 扫描、重复最终清理、逐目标失败包含、正常 TERM 到 KILL dispose、dispose 等待期间保留存活集合,以及 dispose 后移除 listener。 ## Alternatives considered @@ -48,4 +48,4 @@ Status: implemented 每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 -listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代。native ordinary-process ownership 由[containment decision](2026-08-20-subprocess-native-containment.zh.md)说明;PTY ownership 仍是独立边界。 +listener 无法覆盖不执行 JavaScript 的故障。受支持的 Linux terminal 会向[containment decision](2026-08-20-subprocess-native-containment.zh.md)所述的 scope 发出信号;fallback terminal 仍无法发现 provider 首次观察前已经逃逸的后代。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml index bf489f9217..ef14983ff5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.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: 7772284635435ecf11dbc0416021c8b701535275 -2026-08-20-subprocess-native-containment.zh.md: 538677ca4bd53192188d1a412f6cf649c32deac8 +2026-08-20-subprocess-native-containment.md: 4ed116f5db247278771575549e216b1aa91c97fb +2026-08-20-subprocess-native-containment.zh.md: 95fdd1c73c1b7d5f53360389e8aef31c5b4ca979 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md index 7772284635..4ed116f5db 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md @@ -10,7 +10,7 @@ The local subprocess provider treated a POSIX process group or a Windows direct- ## Decision -`LocalSubprocessRuntime` selects containment before every eligible ordinary or terminal user command; capability results are not cached, while the weaker-path warning is emitted at most once per provider. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows ordinary launch uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each native launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. +`LocalSubprocessRuntime` selects containment before every eligible ordinary or terminal user command. Linux rechecks the live user manager for every launch; successful stable systemd-scope and ordinary-runner probes are cached for the provider lifetime, failed probes are retried, and terminal selection never probes the ordinary runner. Windows likewise caches only a successful Job-runner probe. The weaker-path warning is emitted at most once per provider. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows ordinary launch uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each native launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. 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. @@ -18,7 +18,7 @@ Linux ordinary user argv never enters the `systemd-run` command line. The runner Linux terminal launch passes `systemd-run --user --scope --quiet --collect --expand-environment=no -- ` directly to `node-pty`; `systemd-run --scope` replaces itself with the target, so node-pty continues to observe the target PID, session leader, process group, controlling terminal, foreground input wait, and prompt readiness. The terminal handle binds the same scope owner for normal termination and host-exit KILL, so a descendant that reparents or creates a new session remains in the managed range without a second PTY runner or a continuous process-table monitor. -On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, publishes the target identity, and closes its pipe handles in the same synchronous startup step before processing control messages. It retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. +On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, appends the target identity to the private event file, and closes its pipe handles before processing control messages. The parent returns the handle immediately with `pid` undefined and publishes that identity when its asynchronous event reader observes the record. The runner retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback. @@ -40,4 +40,4 @@ Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs separate or ## Consequences -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. Every eligible ordinary or terminal spawn probes capability before target execution with a 5-second bound per probe command. Native ordinary launch then requires a bounded per-launch handshake before it can publish the target pid; the fixed upper bound is 10 seconds when a runner never reports, and each native ordinary range retains one runner process until settlement. Linux PTY launch adds no runner. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. After publication, event-file reads use asynchronous 100 ms polling and systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. +Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. Linux pays one live-manager probe before every eligible ordinary or terminal target; stable scope and ordinary-runner probes stop after their first success, retry after failure, and carry a 5-second bound per command. Terminal launch never runs the ordinary-runner probe. Windows repeats its bounded Job-runner probe only until the first success. A native ordinary handle has no per-launch target-publication handshake or timeout: it returns with `pid` undefined, and the asynchronous 100 ms event-file poll publishes the PID or settles `.done`. A runner that remains alive without a terminal event therefore leaves those facts pending until it exits or the range is terminated. Each native ordinary range retains one runner process until settlement; Linux PTY launch adds no runner. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. Systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md index 538677ca4b..95fdd1c73c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LocalSubprocessRuntime` 会在每次符合条件的 ordinary 或 terminal 用户命令前选择 containment;capability 结果不会缓存,较弱路径的告警则由每个 provider 至多发出一次。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows ordinary launch 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 native launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 +`LocalSubprocessRuntime` 会在每次符合条件的 ordinary 或 terminal 用户命令前选择 containment。Linux 会在每次 launch 时重查 live user manager;稳定的 systemd scope 与 ordinary runner 探测只在成功后按 provider 生命周期缓存,失败探测会重试,而且 terminal 选择绝不会探测 ordinary runner。Windows 同样只缓存成功的 Job runner 探测。较弱路径的告警由每个 provider 至多发出一次。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows ordinary launch 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 native launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 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。 @@ -18,7 +18,7 @@ Linux ordinary user argv 从不进入 `systemd-run` 命令行。runner 从 priva Linux terminal launch 会把 `systemd-run --user --scope --quiet --collect --expand-environment=no -- <原始 argv>` 直接交给 `node-pty`;`systemd-run --scope` 会以 target 替换自身,因此 node-pty 继续观察 target PID、session leader、process group、控制终端、前台 input wait 与 prompt readiness。terminal handle 会为正常终止与 host-exit KILL 绑定同一个 scope owner,因此已 reparent 或新建 session 的 descendant 仍留在 managed range 内,无需第二个 PTY runner 或持续进程表 monitor。 -Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,在处理 control message 前的同一个同步启动步骤中发布 target identity 并关闭自身 pipe handle。它会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 +Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,把 target identity 追加到 private event file,并在处理 control message 前关闭自身 pipe handle。parent 会立即返回 `pid` 为 `undefined` 的 handle,并在异步 event reader 观察到该记录后发布 identity。runner 会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 @@ -40,4 +40,4 @@ Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境分别运行 or ## Consequences -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。每次符合条件的 ordinary 或 terminal spawn 都会在 target 执行前探测能力,每条 probe command 的上限为 5 秒。native ordinary launch 随后必须在发布 target pid 前完成每次 launch 的有界握手;runner 始终不报告时,固定上限为 10 秒,每个 native ordinary range 还会保留一个 runner process 直到 settlement。Linux PTY launch 不增加 runner。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。handle 发布后,event file 使用异步 100 ms 轮询,systemd state 使用异步 200 ms 轮询,不再阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 +受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。Linux 会在每个符合条件的 ordinary 或 terminal target 前执行一次 live manager 探测;稳定的 scope 与 ordinary runner 探测会在首次成功后停止,失败后则重试,每条命令的上限为 5 秒。terminal launch 绝不会运行 ordinary runner 探测。Windows 的有界 Job runner 探测也只重复到首次成功。native ordinary handle 没有每次 launch 的 target publication 握手或超时:它以 `pid` 为 `undefined` 的状态返回,再由每 100 ms 异步读取一次的 event file 发布 PID 或结算 `.done`。runner 如果保持存活却始终没有 terminal event,这些事实会保持待定,直到 runner 退出或该范围被终止。每个 native ordinary range 会保留一个 runner process 直到 settlement;Linux PTY launch 不增加 runner。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。systemd state 每 200 ms 异步读取一次,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index fa6988b3fa..491c5f953f 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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/testing/2026-07-22-cross-platform-test-fixtures.md -2026-07-22-cross-platform-test-fixtures.md: f2fdbcac1d19089fcb2bd7f9f02c21a27b96f020 -2026-07-22-cross-platform-test-fixtures.zh.md: c3975c82c05d9b92553b76ba1f7549fab3e94547 +2026-07-22-cross-platform-test-fixtures.md: 36df88153cf9419f6eb5ce58195d2c62fd56cc71 +2026-07-22-cross-platform-test-fixtures.zh.md: 8785ef6bbc4e7715dcaf25fe0a241e596e1159d1 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index f2fdbcac1d..36df88153c 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown delegates to the subprocess provider's managed range: supported local Linux uses a user-systemd scope and Windows uses a kill-on-close Job, while explicit fallbacks use a negative process-group id or synchronous `taskkill /T /F`. See the [ordinary subprocess native-containment decision](../bug-fix/2026-08-20-subprocess-native-containment.md). Windows fallback suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown delegates to the subprocess provider's managed range: supported local Linux uses a user-systemd scope and Windows uses a kill-on-close Job, while explicit fallbacks use a negative process-group id or synchronous `taskkill /T /F`. See the [ordinary subprocess native-containment decision](../bug-fix/2026-08-20-subprocess-native-containment.md). Windows fallback treats every taskkill result as best-effort and ignores command, permission, absent-tree, and other status failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Supported native Windows hosts use Job ownership; fallback Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed. A successful synchronous fallback result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Supported native Windows hosts use Job ownership; fallback Windows teardown makes one synchronous best-effort `taskkill` call after graceful protocol shutdown has failed. Its result is ignored, so the fallback neither reports taskkill failure nor proves descendant exit before cleanup returns. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index c3975c82c0..8785ef6bbc 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会委托给 subprocess provider 的 managed range:受支持的本地 Linux 使用 user-systemd scope,Windows 使用 kill-on-close Job;明确的 fallback 才使用负数进程组 ID 或同步 `taskkill /T /F`。参见[普通子进程 native containment 决策](../bug-fix/2026-08-20-subprocess-native-containment.zh.md)。Windows fallback 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会委托给 subprocess provider 的 managed range:受支持的本地 Linux 使用 user-systemd scope,Windows 使用 kill-on-close Job;明确的 fallback 才使用负数进程组 ID 或同步 `taskkill /T /F`。参见[普通子进程 native containment 决策](../bug-fix/2026-08-20-subprocess-native-containment.zh.md)。Windows fallback 把所有 taskkill 结果都视为 best-effort,并忽略命令、权限、进程树不存在及其他状态失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受支持的原生 Windows 宿主使用 Job 所有权;fallback Windows 的资源清理则在协议级优雅关停失败后依赖宿主的 `taskkill` 命令。同步 fallback 成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受支持的原生 Windows 宿主使用 Job 所有权;fallback Windows 的资源清理则在协议级优雅关停失败后同步发出一次 best-effort `taskkill`。该调用的结果会被忽略,因此 fallback 既不报告 taskkill 失败,也不证明清理返回前后代进程已经退出。 diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index a4afb52b1a..2a82c3c5d7 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.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 packages/e2b/subprocess-e2b/README.md -README.md: baa86b345f65c55515cb2e880d1fe707432184f9 -README.zh.md: 3476bca21da304517763b279cbc0f3a49a1d3724 +README.md: e61f2bdf5e38ced47a18f2dd227188621385c566 +README.zh.md: db60cecec39663f59dd406b41858f4bc60eb3f54 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index baa86b345f..e61f2bdf5e 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -12,9 +12,9 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr ## Behavior -- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `undefined` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. +- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. The public `pid` remains `undefined` because the adapter learns only the wrapper's process-group ID, not the requested target PID; stdin and ordinary observation wait for that private group identity. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. - **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides, and rejects relative paths containing separators like every subprocess provider. -- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes. +- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file as its private termination identity instead of treating the SDK command PID or wrapper group as the public target PID. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes. - **Environment boundary** — one trusted control-shell probe resolves the sandbox user's login home from its passwd entry and transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward without changing the sandbox user's umask. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting. - **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle. - **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; termination rejects new handle operations, aborts and joins in-flight writes, inspections, and signals, and treats zombie-only groups as quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. Terminal output is pushed to the handle's stream without awaiting host backpressure: a flowing consumer (the PTY backend attaches one at construction) folds bytes into its own bounded state, while a paused consumer buffers in host memory. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, and fence publication; sandbox disposal or timeout bounds a setup rollback that also fails. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-terminal-bash`. @@ -33,7 +33,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream. -- **Synchronous-PID consumers are unsupported** — `pid` remains `undefined` during remote startup; consumers that require a PID immediately cannot use this provider unchanged. +- **Target PID is unavailable** — the public `pid` is always `undefined`; the private wrapper process-group ID is retained only for containment and is not the requested target PID. Consumers that require a numeric target PID cannot use this provider unchanged. - **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 3476bca21d..db60cecec3 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -12,9 +12,9 @@ ## 行为 -- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `undefined`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 +- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。公开 `pid` 始终为 `undefined`,因为适配器只能取得包装层的进程组 ID,而不是请求目标的 PID;stdin 和普通进程观察会等待这个私有进程组身份。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 - **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称,并与所有 subprocess 提供方一致地拒绝含分隔符的相对路径。 -- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放沙箱。 +- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,把它作为私有终止身份,而不会把 SDK 命令 PID 或包装层进程组当作公开的目标 PID。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放沙箱。 - **环境边界**:一次受信任的控制 shell 探测会从 passwd 条目解析沙箱用户的登录主目录,以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 会在不改变沙箱用户 umask 的前提下接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。 - **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。 - **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;终止会拒绝新的句柄操作,中止并等待在途写入、检查和信号操作结算,并把仅含僵尸进程的进程组视为已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。终端输出推入句柄流时不等待宿主背压:流动的消费方(PTY 后端在构造时就挂上一个)把字节折叠进自身的有界状态,而暂停的消费方会在宿主内存中缓冲。在句柄发布前会一直等待 PTY 分配完成,之后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup 并阻止发布;若 setup 回滚也失败,则由沙箱 dispose 或超时约束其存活时间。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-terminal-bash` 所有。 @@ -33,7 +33,7 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具: ## 已知限制与延后工作 - **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 和 `.stderr` 仍会累积 base64 传输内容,因此无法达到进程管理 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。 -- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `undefined`;要求立即获得 PID 的消费方无法原样使用本提供方。 +- **无法取得目标 PID**:公开 `pid` 始终为 `undefined`;私有包装层进程组 ID 只用于 containment,并不是请求目标的 PID。需要数值目标 PID 的消费方无法原样使用本提供方。 - **私有状态随沙箱生命周期存在**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。 - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index d12405c54b..2afde2a79b 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -174,7 +174,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { private readonly stderrReader: E2BOutputReader | undefined private readonly paths: RemotePaths private controlEnvs: Record = {} - private remotePid: number | undefined + private remoteProcessGroupId: number | undefined private outputTransportError: Error | undefined private outputDrainExpired = false private stateDirectoryCreated = false @@ -225,9 +225,9 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (spec.signal?.aborted === true) this.terminate() } - /** Remote process id after publication; undefined while startup is pending or unavailable. */ + /** E2B does not expose the requested target process identity. */ get pid(): number | undefined { - return this.remotePid + return undefined } /** @inheritdoc */ @@ -260,7 +260,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { this.markQuiescent() return true } - if (this.remotePid === undefined) { + if (this.remoteProcessGroupId === undefined) { const attempt = this.terminationAttempt if (attempt !== undefined && await waitWithSignal(attempt.catch(() => undefined), signal) === WAIT_ABORTED) { return false @@ -293,7 +293,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } throw error } - const processGroupId = this.remotePid ?? handle.pid + const processGroupId = this.remoteProcessGroupId ?? handle.pid while (await this.groupAlive(sandbox, processGroupId, signal)) { this.throwTerminationFailure() if (!await waitTick(this.pollMs, signal)) return false @@ -349,7 +349,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } this.commandState.resolve(handle) try { - this.remotePid = await this.waitForProcessGroupId(sandbox, completion) + this.remoteProcessGroupId = await this.waitForProcessGroupId(sandbox, completion) } catch (error: unknown) { try { await this.rollbackUnpublishedGroup(sandbox, handle) @@ -559,7 +559,7 @@ export class E2BSubprocessHandle implements SubprocessHandle { } private async rollbackPublishedFailure(error: unknown): Promise { - if (this.remotePid === undefined || this.quiescenceProven) return error + if (this.remoteProcessGroupId === undefined || this.quiescenceProven) return error this.terminate() try { await this.waitForExit() @@ -599,13 +599,13 @@ export class E2BSubprocessHandle implements SubprocessHandle { this.markQuiescent() return } - if (!isValidProcessId(handle.pid) && this.remotePid === undefined) { + if (!isValidProcessId(handle.pid) && this.remoteProcessGroupId === undefined) { await handle.kill() this.markQuiescent() return } const sandbox = await this.runtime.getSandbox() - const processGroupId = this.remotePid ?? handle.pid + const processGroupId = this.remoteProcessGroupId ?? handle.pid await this.terminateGroup(sandbox, handle, processGroupId) } diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index a1de4780f3..8af74a8abf 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -407,7 +407,7 @@ describe('E2BSubprocessHandle', () => { handle.stdin!.end() fake.releaseStart() await flush() - expect(handle.pid).toBe(4343) + expect(handle.pid).toBeUndefined() expect(fake.handle.sent.map(value => String(value))).toEqual(['hello']) expect(fake.handle.closes).toBe(1) const controlEnvs = fake.startOptions?.envs @@ -1449,11 +1449,12 @@ describe('E2BSubprocessHandle', () => { await expect(observed.waitForExit()).resolves.toBe(true) }) - it('waits for delayed process-group publication', async () => { + it('keeps the public pid unavailable after delayed private process-group publication', async () => { const fake = new FakeSandbox() fake.processGroupReads.push('', '4242\n') const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group') - await vi.waitFor(() => { expect(handle.pid).toBe(4242) }) + await vi.waitFor(() => { expect(fake.processGroupReads).toHaveLength(0) }) + expect(handle.pid).toBeUndefined() fake.finish() await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) }) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 48a117e44f..8be3b0bc9d 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -270,6 +270,11 @@ export async function disposeClaudeCodeChild( child: SubprocessHandle, ): Promise { const failures: Error[] = [] + let outcome: SubprocessOutcome | undefined + void child.done.then( + (value) => { outcome = value }, + () => {}, + ) try { query?.close() } catch (error: unknown) { @@ -282,7 +287,6 @@ export async function disposeClaudeCodeChild( } catch (error: unknown) { failures.push(thrown(error)) } - const outcome = await child.done.catch(() => undefined) const firstFailure = failures[0] if (firstFailure !== undefined) { @@ -296,6 +300,7 @@ export async function disposeClaudeCodeChild( : new AggregateError(failures, 'Claude Code teardown failures') throw new ClaudeCodeFailure(facts, cause) } + await child.done.catch(() => {}) } /** diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 518825e4df..538a8a0fbb 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1672,4 +1672,23 @@ describe('query and process disposal', () => { ]) expect(waitFailure.terminate).toHaveBeenCalledOnce() }) + + it('reports a tree-wait failure without waiting for a pending direct outcome', async () => { + const waitFailure = new Error('managed range observation failed') + const child = fakeChild({ + exitOnTerminate: false, + waitForExitError: waitFailure, + }) + const result = await Promise.race([ + disposeClaudeCodeChild({ close: vi.fn() }, child.handle).then( + () => undefined, + (error: unknown) => error, + ), + new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)), + ]) + + expect(result).not.toBe('timeout') + expect(errorCause(result)).toBe(waitFailure) + expect(child.terminate).toHaveBeenCalledOnce() + }) }) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 87989ffd9d..c56bff0195 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: d8cdcbaec8a33b2f9a8af3a1a75dac2e647cce18 -README.zh.md: 7ec64e45f459a6c6a00aa4153d5ffaecb6c00dd0 +README.md: 3aa8d9068c2ee8b6e0f26b5cebd26419ce4ada51 +README.zh.md: 75d06f711801c8749297eab3f38a3aae6b8616b4 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index d8cdcbaec8..3aa8d9068c 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -27,7 +27,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native launch has a synchronous setup cost** — every eligible ordinary or terminal spawn probes host capability before executing the user command, with a 5-second bound on each probe command; only the weaker-path warning is cached per provider. Each native ordinary launch waits synchronously for its per-spawn runner to report target start or spawn failure before publishing a numeric target pid. The built runner normally completes this handshake promptly; a runner that never publishes a result holds the caller for the fixed 10-second protocol bound. Each supported native ordinary command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. Linux terminal launch passes the scoped argv directly to `node-pty` and adds no runner. After publication, runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native selection has bounded probe and runner costs** — Linux rechecks the live user manager before every eligible ordinary or terminal spawn. Successful stable systemd-scope and ordinary-runner probes are cached for the provider lifetime, failed probes are retried, and terminal selection never probes the ordinary runner. Windows likewise caches only a successful Job-runner probe. Each synchronous probe command has a 5-second bound and completes before the user command can run. A native ordinary handle returns before target publication: `pid` starts as `undefined` and updates from asynchronously polled runner events, while `.done` carries target startup failure or direct outcome. There is no target-publication timeout; a runner that remains alive without a terminal event leaves `pid` undefined and `.done` pending until it exits or the range is terminated. Each supported native ordinary command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. Linux terminal launch passes the scoped argv directly to `node-pty` and adds no runner. Runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 7ec64e45f4..75d06f7118 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -27,7 +27,7 @@ ## 已知限制与暂缓事项 - **native ownership 有明确宿主条件**:Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native launch 有同步 setup 成本**:每次符合条件的 ordinary 或 terminal spawn 都会在执行用户命令前探测宿主能力,每条 probe command 的上限为 5 秒;每个 provider 只缓存较弱路径的告警。每次 native ordinary launch 都会同步等待 per-spawn runner 报告 target start 或 spawn failure,再发布数值 target pid。built runner 通常会迅速完成该握手;若 runner 始终不发布结果,调用方会等待固定的 10 秒 protocol bound。每条受支持的 native ordinary command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。Linux terminal launch 会把 scoped argv 直接交给 `node-pty`,不增加 runner。handle 发布后,runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 +- **native 选择具有有界的探测与 runner 成本**:Linux 会在每次符合条件的 ordinary 或 terminal spawn 前重新检查 live user manager。稳定的 systemd scope 与 ordinary runner 探测只在成功后按 provider 生命周期缓存,失败探测会重试,而且 terminal 选择绝不会探测 ordinary runner。Windows 同样只缓存成功的 Job runner 探测。每条同步 probe command 的上限为 5 秒,并在用户命令可能运行前完成。native ordinary handle 会在 target 发布前返回:`pid` 起初为 `undefined`,随后由异步轮询的 runner event 更新;`.done` 则承载 target 启动失败或 direct outcome。target 发布没有单独的超时;runner 如果保持存活却始终没有 terminal event,`pid` 会保持为 `undefined`,`.done` 也会保持待定,直到 runner 退出或该范围被终止。每条受支持的 native ordinary command 都会保留一个 runner process,直到 OS-owned range 为空;Windows 还会创建 private per-spawn named-pipe endpoint。Linux terminal launch 会把 scoped argv 直接交给 `node-pty`,不增加 runner。runner event 每 100 ms、Linux scope state 每 200 ms 异步轮询。 - **Windows Job inheritance 有明确排除项**:普通 descendant 默认继承 Job,但 breakaway process 不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **fallback terminal ownership 仍依赖观察**:在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有 terminal session,就可能逃出进程表扫描。本地 provider 不会新增持续进程表 monitor;受支持的 Linux native mode 改由 scope membership 持有这些后代。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 73fa42a761..ed22ce3a8b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -29,7 +29,13 @@ import { validateSubprocessSpec, } from './spawn.ts' import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' -import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from './linux-scope.ts' +import { + launchLinuxScope, + prepareLinuxTerminalScope, + probeLinuxRunner, + probeLinuxScope, + probeLinuxUserManager, +} from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' @@ -51,6 +57,12 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { internals: SpawnInternals = {} /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */ private fallbackWarningIssued = false + /** Stable Linux scope features, cached only after a successful probe. */ + private linuxScopeCapabilityConfirmed = false + /** Stable ordinary-runner availability, cached only after a successful probe. */ + private linuxRunnerCapabilityConfirmed = false + /** Stable Windows Job support, cached only after a successful probe. */ + private windowsJobCapabilityConfirmed = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -183,8 +195,25 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { kind: 'ordinary' | 'terminal', ): 'linux-scope' | 'windows-job' | 'fallback' { const platform = this.internals.platform ?? process.platform - if (platform === 'linux' && probeLinuxScope()) return 'linux-scope' - if (kind === 'ordinary' && platform === 'win32' && probeWindowsJob()) return 'windows-job' + if (platform === 'linux') { + const managerAvailable = probeLinuxUserManager() + if (managerAvailable && !this.linuxScopeCapabilityConfirmed) { + this.linuxScopeCapabilityConfirmed = probeLinuxScope() + } + if (managerAvailable && this.linuxScopeCapabilityConfirmed) { + if (kind === 'terminal') return 'linux-scope' + if (!this.linuxRunnerCapabilityConfirmed) { + this.linuxRunnerCapabilityConfirmed = probeLinuxRunner() + } + if (this.linuxRunnerCapabilityConfirmed) return 'linux-scope' + } + } + if (kind === 'ordinary' && platform === 'win32') { + if (!this.windowsJobCapabilityConfirmed) { + this.windowsJobCapabilityConfirmed = probeWindowsJob() + } + if (this.windowsJobCapabilityConfirmed) return 'windows-job' + } this.warnFallback(platform, kind) return 'fallback' } diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 5a5605fa67..01263abc05 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -65,30 +65,48 @@ function unitStem(prefix: string): string { } /** - * Confirm a modern readable user manager and literal-argument scope launch. - * @param internals - injected command paths and runners. - * @returns true only before any user command is selected for native launch. + * Confirm that the live user manager is readable for this launch. + * @param internals - injected command paths used by tests. + * @returns true when the current user manager can be queried. */ -export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { +export function probeLinuxUserManager(internals: LinuxScopeInternals = {}): boolean { const runSync = internals.spawnSync ?? spawnSync - const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() - const [runnerCommand, ...runnerPrefix] = invocation - const systemdRun = internals.systemdRun ?? 'systemd-run' const systemctl = internals.systemctl ?? 'systemctl' - const timeout = 5_000 const manager = runSync(systemctl, ['--user', 'show-environment'], { encoding: 'utf8', env: systemctlEnv(), stdio: 'ignore', - timeout, + timeout: SYSTEMCTL_TIMEOUT_MS, }) - if (manager.error !== undefined || manager.status !== 0) return false + return manager.error === undefined && manager.status === 0 +} + +/** + * Confirm that the ordinary native runner is executable on this host. + * @param internals - injected runner invocation used by tests. + * @returns true when the private ordinary runner probe succeeds. + */ +export function probeLinuxRunner(internals: LinuxScopeInternals = {}): boolean { + const runSync = internals.spawnSync ?? spawnSync + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const [runnerCommand, ...runnerPrefix] = invocation const runner = runSync(runnerCommand, [...runnerPrefix, '--mode', 'probe-node'], { env: childEnv(), stdio: 'ignore', - timeout, + timeout: SYSTEMCTL_TIMEOUT_MS, }) - if (runner.error !== undefined || runner.status !== 0) return false + return runner.error === undefined && runner.status === 0 +} + +/** + * Confirm literal-argument transient-scope support and readable scope state. + * @param internals - injected command paths used by tests. + * @returns true only before any user command is selected for native launch. + */ +export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { + const runSync = internals.spawnSync ?? spawnSync + const systemdRun = internals.systemdRun ?? 'systemd-run' + const systemctl = internals.systemctl ?? 'systemctl' const unitBase = unitStem('dsh-subprocess-probe') const probe = runSync(systemdRun, [ '--user', @@ -107,7 +125,7 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { ], { env: childEnv(), stdio: 'ignore', - timeout, + timeout: SYSTEMCTL_TIMEOUT_MS, }) return probe.error === undefined && probe.status === 0 } @@ -293,7 +311,7 @@ export function launchLinuxScope( stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, - pid: result.pid, + get pid() { return result.pid }, direct, owner, } diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 555fba8254..5929add57c 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,7 +1,6 @@ /** Parent-side launch and direct-result transport for native runners. */ import type { ChildProcess, StdioOptions } from 'node:child_process' -import { readFileSync } from 'node:fs' import { extname } from 'node:path' import { fileURLToPath } from 'node:url' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -10,14 +9,12 @@ import { cleanupRunnerFiles, createRunnerFiles, deserializeSpawnError, - readRunnerEvents, readRunnerEventsAsync, } from './runner-protocol.ts' import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' import { DirectResultUnavailableError } from './managed-owner.ts' import { childEnv } from './spawn.ts' -const RUNNER_HANDSHAKE_TIMEOUT_MS = 10_000 const RUNNER_EVENT_POLL_MS = 100 const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' @@ -67,12 +64,6 @@ export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles { return createRunnerFiles(request) } -interface RunnerHandshake { - pid: number | undefined - events: RunnerEvent[] - failureReported: boolean -} - function directTerminalResult( events: readonly RunnerEvent[], ): { outcome: SubprocessOutcome } | { error: Error } | undefined { @@ -87,53 +78,13 @@ function directTerminalResult( return undefined } -/** Observe wrapper death without waiting for Node's blocked event loop to emit close. */ -function runnerExited(child: ChildProcess, pid: number): boolean { - if (child.exitCode !== null || child.signalCode !== null) return true - /* v8 ignore start -- Linux zombie detection is exercised by the real user-systemd test environment. */ - if (process.platform === 'linux') { - try { - const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8') - const suffix = stat.slice(stat.lastIndexOf(')') + 2) - if (suffix.startsWith('Z') || suffix.startsWith('X')) return true - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true - } - } - /* v8 ignore stop */ - try { - process.kill(pid, 0) - return false - } catch (error) { - /* v8 ignore next -- EPERM means the known process still exists but is not signalable. */ - return (error as NodeJS.ErrnoException).code === 'ESRCH' - } -} - -/** Wait synchronously only until the runner reports target start or spawn failure. */ -function waitForRunnerHandshake(child: ChildProcess, files: RunnerFiles): RunnerHandshake { - const handshakeWait = new Int32Array(new SharedArrayBuffer(4)) - const deadline = Date.now() + RUNNER_HANDSHAKE_TIMEOUT_MS - while (Date.now() < deadline) { - const events = readRunnerEvents(files.eventsPath) - const terminal = events.find(event => event.type === 'started' || event.type === 'spawn-error' || event.type === 'runner-error') - if (terminal?.type === 'started') return { pid: terminal.pid, events, failureReported: false } - if (terminal?.type === 'spawn-error' || terminal?.type === 'runner-error') { - return { pid: undefined, events, failureReported: true } - } - if (child.pid === undefined) throw new Error('native subprocess runner failed to start') - if (runnerExited(child, child.pid)) throw new Error('native subprocess runner exited before reporting target start') - Atomics.wait(handshakeWait, 0, 0, 5) - } - throw new Error(`native subprocess runner did not report target start within ${String(RUNNER_HANDSHAKE_TIMEOUT_MS)}ms`) -} - async function waitForDirectResult( + child: ChildProcess, files: RunnerFiles, - initial: RunnerEvent[], exited: Promise, + publishPid: (pid: number) => void, ): Promise { - let seen = initial.length + let seen = 0 const wrapperState = { exited: false } void exited.then(() => { wrapperState.exited = true }) for (;;) { @@ -142,13 +93,18 @@ async function waitForDirectResult( // event was written before the runner exited. const exitedBeforeRead = wrapperState.exited const events = await readRunnerEventsAsync(files.eventsPath) - const terminal = directTerminalResult(events.slice(seen)) + const added = events.slice(seen) + for (const event of added) { + if (event.type === 'started') publishPid(event.pid) + } + const terminal = directTerminalResult(added) if (terminal !== undefined) { if ('error' in terminal) throw terminal.error return terminal.outcome } seen = Math.max(seen, events.length) if (exitedBeforeRead) { + if (child.pid === undefined) throw new Error('native subprocess runner failed to start') throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') } await sleepMs(RUNNER_EVENT_POLL_MS) @@ -156,37 +112,24 @@ async function waitForDirectResult( } /** - * Bind runner events into one direct result while preserving the target pid. + * Bind asynchronous runner events into one direct result and target-pid getter. * @param child - native wrapper process. * @param files - private request and result paths. - * @param exited - wrapper exit/error observation attached before the start handshake. - * @returns target pid, direct result, and whether the runner already reported a pre-start terminal failure. + * @param exited - wrapper exit/error observation attached before event polling. + * @returns a live target-pid view plus the direct result. */ export function runnerDirectResult( child: ChildProcess, files: RunnerFiles, exited: Promise, ): { - pid: number | undefined + readonly pid: number | undefined direct: Promise - failureReported: boolean } { - let handshake: RunnerHandshake - try { - handshake = waitForRunnerHandshake(child, files) - } catch (error) { - cleanupRunnerFiles(files) - return { pid: undefined, direct: Promise.resolve().then(() => { throw error }), failureReported: false } - } - const terminal = directTerminalResult(handshake.events) + let pid: number | undefined return { - pid: handshake.pid, - direct: terminal === undefined - ? waitForDirectResult(files, handshake.events, exited) - : 'error' in terminal - ? Promise.reject(terminal.error) - : Promise.resolve(terminal.outcome), - failureReported: handshake.failureReported, + get pid() { return pid }, + direct: waitForDirectResult(child, files, exited, (published) => { pid = published }), } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 2b65e75ea6..8aec51bc67 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -562,7 +562,7 @@ export function bindManagedProcess( } return { - pid: launch.pid, + get pid() { return launch.pid }, /* v8 ignore start -- pipe-mode streams exist on every conforming launch; the null-coalesces guard an internal adapter defect only. */ stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 14b34422f3..7a1c96b8f1 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -46,12 +46,11 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly runner: ReturnType, - private readonly startupFailureReported: boolean, ) { this.observation = new Promise((resolve, reject) => { runner.once('close', (exitCode, signal) => { this.runnerClosed = true - if (this.startupFailureReported || this.runner.pid === undefined || (exitCode === 0 && signal === null)) { + if (this.runner.pid === undefined || (exitCode === 0 && signal === null)) { this.stopped = true resolve() return @@ -70,7 +69,7 @@ class WindowsJobOwner implements BoundProcessOwner { } signal(_signal: 'SIGTERM' | 'SIGKILL'): void { - if (this.stopped || this.runnerClosed || this.startupFailureReported || this.runner.pid === undefined) return + if (this.stopped || this.runnerClosed || this.runner.pid === undefined) return try { if (this.runner.connected) { this.runner.send({ type: 'terminate' }, (error) => { @@ -136,7 +135,7 @@ export function launchWindowsJob( } const lifecycle = observeChildLifecycle(child) const result = runnerDirectResult(child, files, lifecycle.exited) - const owner = new WindowsJobOwner(child, result.failureReported) + const owner = new WindowsJobOwner(child) void result.direct.then( () => { stdio.closeInput() }, () => { stdio.dispose() }, @@ -146,7 +145,7 @@ export function launchWindowsJob( stdin: stdio.stdin, stdout: stdio.stdout, stderr: stdio.stderr, - pid: result.pid, + get pid() { return result.pid }, direct: result.direct, owner, } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index bf24393d51..c0540dea7f 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -3,7 +3,13 @@ import { existsSync } from 'node:fs' import { dirname } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxScope } from '../src/linux-scope.ts' +import { + launchLinuxScope, + prepareLinuxTerminalScope, + probeLinuxRunner, + probeLinuxScope, + probeLinuxUserManager, +} from '../src/linux-scope.ts' import { spawnRunnerInvocation } from '../src/runner-launch.ts' function spec(argv: string[]): SubprocessSpawnSpec { @@ -29,7 +35,7 @@ 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', () => { + it('separates the live manager, stable scope, and ordinary-runner probes', () => { const secretName = 'DSH_SCOPE_TEST_TOKEN' const previousSecret = process.env[secretName] process.env[secretName] = 'secret' @@ -42,12 +48,20 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) as unknown as typeof spawnSync const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js'] try { + expect(probeLinuxUserManager({ + spawnSync: runSync, + systemctl: 'systemctl', + })).toBe(true) + expect(probeLinuxRunner({ + spawnSync: runSync, + runnerInvocation, + })).toBe(true) expect(probeLinuxScope({ spawnSync: runSync, systemdRun: 'systemd-run', systemctl: 'systemctl', - runnerInvocation, })).toBe(true) + expect(calls[0]).toEqual(['systemctl', '--user', 'show-environment']) expect(calls[1]).toEqual([...runnerInvocation, '--mode', 'probe-node']) expect(calls[2]).toContain('--expand-environment=no') expect(calls[2]).not.toContain('--pipe') @@ -70,46 +84,30 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () else process.env[secretName] = previousSecret } - const oldSystemd = vi.fn((command: string) => ({ - status: command === 'systemd-run' ? 1 : 0, - error: undefined, - })) as unknown as typeof spawnSync + const oldSystemd = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false) - - const failedRunner = vi.fn((command: string) => ({ - status: command === 'node-runtime' ? 1 : 0, - error: undefined, - })) as unknown as typeof spawnSync expect(probeLinuxScope({ + spawnSync: vi.fn(() => ({ status: 0, error: new Error('scope failed') })) as unknown as typeof spawnSync, + })).toBe(false) + + const failedRunner = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync + expect(probeLinuxRunner({ spawnSync: failedRunner, runnerInvocation: ['node-runtime', 'runner-entry.js'], })).toBe(false) - expect(failedRunner).toHaveBeenCalledTimes(2) - - let unreadableProbeCalls = 0 - const unreadableScope = vi.fn(() => ({ - status: ++unreadableProbeCalls === 3 ? 1 : 0, - error: undefined, - })) as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: unreadableScope })).toBe(false) - - let erroredProbeCalls = 0 - const erroredScope = vi.fn(() => { - erroredProbeCalls += 1 - return erroredProbeCalls === 3 - ? { status: null, error: new Error('scope read failed') } - : { status: 0, error: undefined } - }) as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: erroredScope })).toBe(false) + expect(failedRunner).toHaveBeenCalledOnce() + expect(probeLinuxRunner({ + spawnSync: vi.fn(() => ({ status: 0, error: new Error('runner failed') })) as unknown as typeof spawnSync, + runnerInvocation: ['node-runtime', 'runner-entry.js'], + })).toBe(false) const managerError = new Error('missing user manager') - expect(probeLinuxScope({ + expect(probeLinuxUserManager({ spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync, })).toBe(false) - expect(probeLinuxScope({ + expect(probeLinuxUserManager({ spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, })).toBe(false) - }) it('removes private runner files when systemd-run throws synchronously', () => { @@ -500,6 +498,8 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () }) try { const defaults = await import('../src/linux-scope.ts') + expect(defaults.probeLinuxUserManager()).toBe(true) + expect(defaults.probeLinuxRunner()).toBe(true) expect(defaults.probeLinuxScope()).toBe(true) const terminalLaunch = defaults.prepareLinuxTerminalScope(['shell', 'literal $HOME']) expect(terminalLaunch.command).toBe('systemd-run') diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 42b7fa22cc..7eaea1822e 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -409,7 +409,11 @@ describe('LocalSubprocessRuntime', () => { args: ['--user', '--scope', '--', ...argv], bindOwner, })) + const probeLinuxUserManager = vi.fn(() => true) const probeLinuxScope = vi.fn(() => true) + const probeLinuxRunner = vi.fn(() => { + throw new Error('terminal selection must not probe the ordinary runner') + }) const inspector = { foregroundPgid: () => undefined, isStdinWaiting: () => false, @@ -425,7 +429,9 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope, + probeLinuxRunner, probeLinuxScope, + probeLinuxUserManager, })) let fiber: { dispose(): Promise } | undefined try { @@ -440,7 +446,9 @@ describe('LocalSubprocessRuntime', () => { argv: ['shell', '--literal'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, }) + expect(probeLinuxUserManager).toHaveBeenCalledOnce() expect(probeLinuxScope).toHaveBeenCalledOnce() + expect(probeLinuxRunner).not.toHaveBeenCalled() expect(prepareLinuxTerminalScope).toHaveBeenCalledExactlyOnceWith(['shell', '--literal']) expect(nodePtySpawn).toHaveBeenCalledWith( '/usr/bin/systemd-run', @@ -566,12 +574,14 @@ describe('LocalSubprocessRuntime', () => { } }) - it('selects an available native owner for every eligible spawn and contains release-observer failures', async () => { + it('rechecks the Linux manager while caching successful stable native probes', async () => { const linuxLaunch = { kind: 'linux' } const windowsLaunch = { kind: 'windows' } const launchLinuxScope = vi.fn(() => linuxLaunch) const launchWindowsJob = vi.fn(() => windowsLaunch) + const probeLinuxUserManager = vi.fn(() => true) const probeLinuxScope = vi.fn(() => true) + const probeLinuxRunner = vi.fn(() => true) const probeWindowsJob = vi.fn(() => true) const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' })) let nextPid = 100 @@ -598,7 +608,13 @@ describe('LocalSubprocessRuntime', () => { const spawnSubprocess = vi.fn() vi.resetModules() - vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope, probeLinuxScope })) + vi.doMock('../src/linux-scope.ts', () => ({ + launchLinuxScope, + prepareLinuxTerminalScope: vi.fn(), + probeLinuxRunner, + probeLinuxScope, + probeLinuxUserManager, + })) vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob })) vi.doMock('../src/spawn.ts', async importOriginal => ({ ...await importOriginal(), @@ -622,7 +638,9 @@ describe('LocalSubprocessRuntime', () => { await new Promise(resolve => setImmediate(resolve)) await linuxRuntime.spawn(spec('true')).done await new Promise(resolve => setImmediate(resolve)) - expect(probeLinuxScope).toHaveBeenCalledTimes(3) + expect(probeLinuxUserManager).toHaveBeenCalledTimes(3) + expect(probeLinuxScope).toHaveBeenCalledOnce() + expect(probeLinuxRunner).toHaveBeenCalledOnce() expect(launchLinuxScope).toHaveBeenCalledTimes(2) const windowsContext = new Context() @@ -650,6 +668,78 @@ describe('LocalSubprocessRuntime', () => { } }) + it('retries failed stable probes and does not cache Linux manager availability', async () => { + const probeLinuxUserManager = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + const probeLinuxScope = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + const probeLinuxRunner = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + const probeWindowsJob = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + + vi.resetModules() + vi.doMock('../src/linux-scope.ts', () => ({ + launchLinuxScope: vi.fn(), + prepareLinuxTerminalScope: vi.fn(), + probeLinuxRunner, + probeLinuxScope, + probeLinuxUserManager, + })) + vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob })) + const fibers: Array<{ dispose(): Promise }> = [] + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const linuxContext = new Context() + vi.spyOn(linuxContext.logger, 'warn').mockImplementation(() => {}) + const linuxFiber = await linuxContext.plugin(IsolatedLocalSubprocessRuntime) + fibers.push(linuxFiber) + const linuxRuntime = linuxContext.subprocess as InstanceType + linuxRuntime.internals = { platform: 'linux' } + const linuxSelect = (linuxRuntime as unknown as { + selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback' + }).selectContainmentMode.bind(linuxRuntime) + + expect(linuxSelect('ordinary')).toBe('fallback') + expect(linuxSelect('ordinary')).toBe('fallback') + expect(linuxSelect('ordinary')).toBe('fallback') + expect(linuxSelect('ordinary')).toBe('linux-scope') + expect(linuxSelect('ordinary')).toBe('fallback') + expect(linuxSelect('ordinary')).toBe('linux-scope') + expect(probeLinuxUserManager).toHaveBeenCalledTimes(6) + expect(probeLinuxScope).toHaveBeenCalledTimes(2) + expect(probeLinuxRunner).toHaveBeenCalledTimes(2) + + const windowsContext = new Context() + vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {}) + const windowsFiber = await windowsContext.plugin(IsolatedLocalSubprocessRuntime) + fibers.push(windowsFiber) + const windowsRuntime = windowsContext.subprocess as InstanceType + windowsRuntime.internals = { platform: 'win32' } + const windowsSelect = (windowsRuntime as unknown as { + selectContainmentMode(kind: 'ordinary' | 'terminal'): 'linux-scope' | 'windows-job' | 'fallback' + }).selectContainmentMode.bind(windowsRuntime) + + expect(windowsSelect('ordinary')).toBe('fallback') + expect(windowsSelect('ordinary')).toBe('windows-job') + expect(windowsSelect('ordinary')).toBe('windows-job') + expect(probeWindowsJob).toHaveBeenCalledTimes(2) + } finally { + for (const fiber of fibers.reverse()) await fiber.dispose() + vi.doUnmock('../src/linux-scope.ts') + vi.doUnmock('../src/windows-job.ts') + vi.resetModules() + } + }) + it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 45dd4d92fe..a35f847d7e 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -16,6 +16,26 @@ function spec(graceMs = 30): SubprocessSpawnSpec { } describe('managed process binding', () => { + it('forwards target pid publication after the handle is returned', async () => { + const target = { pid: undefined as number | undefined } + const handle = bindManagedProcess({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }, { + stdin: null, + stdout: null, + stderr: null, + get pid() { return target.pid }, + direct: Promise.resolve({ exitCode: 0, signal: null }), + owner: { signal: vi.fn(), waitForExit: async () => {} }, + }) + + expect(handle.pid).toBeUndefined() + target.pid = 4242 + expect(handle.pid).toBe(4242) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + }) + it('does not miss an abort between the initial check and listener registration', async () => { let aborted = false const addEventListener = vi.fn(() => { aborted = true }) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index a8bd8bd814..0284f6d789 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -73,7 +73,7 @@ function directSpawnFailure(argv: string[], cwd = scratch): Promise { - it('keeps raw stdin writable after the launch handshake', async () => { + it('keeps raw stdin writable before target pid publication', async () => { const output = join(scratch, `stdin-${Date.now()}.txt`) const script = ` const { writeFileSync } = require('node:fs') @@ -90,11 +90,11 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { if (handle.stdin === undefined) throw new Error('expected piped stdin') await new Promise((resolve, reject) => { handle.stdin?.once('error', reject) - handle.stdin?.end('after-handshake', resolve) + handle.stdin?.end('before-publication', resolve) }) await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) await expect(handle.waitForExit()).resolves.toBe(true) - expect(readFileSync(output, 'utf8')).toBe('after-handshake') + expect(readFileSync(output, 'utf8')).toBe('before-publication') }) it('reports direct exit before terminating its default-inheritance descendant', async () => { diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 8d0ca45ed3..ea789dfcd1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' -import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import { @@ -46,7 +46,9 @@ function spec(overrides: Partial = {}): SubprocessSpawnSpec } function fakeChild(pid: number | undefined): ChildProcess { - return { pid } as ChildProcess + const child = new EventEmitter() as ChildProcess + Object.assign(child, { pid, exitCode: null, signalCode: null }) + return child } class FakeRunnerHost extends EventEmitter { @@ -129,14 +131,20 @@ describe('spawn runner transport', () => { }) }) - it('does not require SharedArrayBuffer until a native handshake runs', async () => { + it('observes runner events without SharedArrayBuffer', async () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer') Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined }) vi.resetModules() + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { const isolated = await import('../src/runner-launch.ts') - expect(isolated.runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) + const result = isolated.runnerDirectResult(fakeChild(123), files, new Promise(() => {})) + appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) + appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + expect(result.pid).toBe(456) } finally { + cleanupRunnerFiles(files) if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer') else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor) vi.resetModules() @@ -988,7 +996,6 @@ describe('spawn runner transport', () => { }) const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise(() => {})) expect(result.pid).toBeUndefined() - expect(result.failureReported).toBe(true) await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' }) } finally { cleanupRunnerFiles(runnerFailure) @@ -998,13 +1005,13 @@ describe('spawn runner transport', () => { try { appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) + const directFailure = result.direct.catch((error: unknown) => error) appendRunnerEvent(afterStartFailure.eventsPath, { type: 'runner-error', error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, }) - expect(result.pid).toBe(456) - expect(result.failureReported).toBe(false) - await expect(result.direct).rejects.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) + await vi.waitFor(() => { expect(result.pid).toBe(456) }) + await expect(directFailure).resolves.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) } finally { cleanupRunnerFiles(afterStartFailure) } @@ -1013,8 +1020,7 @@ describe('spawn runner transport', () => { try { appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve()) - expect(result.pid).toBe(456) - expect(result.failureReported).toBe(false) + await vi.waitFor(() => { expect(result.pid).toBe(456) }) await expect(result.direct).rejects.toThrow('exited without a direct-command result') } finally { cleanupRunnerFiles(missing) @@ -1022,7 +1028,7 @@ describe('spawn runner transport', () => { }) - it('publishes terminal events already present in the handshake snapshot', async () => { + it('publishes terminal events already present when asynchronous observation starts', async () => { const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { appendRunnerEvent(failed.eventsPath, { @@ -1030,12 +1036,7 @@ describe('spawn runner transport', () => { error: { name: 'Error', message: 'target missing', code: 'ENOENT' }, }) const result = runnerDirectResult(fakeChild(123), failed, new Promise(() => {})) - let observed: Error | undefined - void result.direct.catch((error: unknown) => { - observed = error instanceof Error ? error : new Error(String(error)) - }) - await Promise.resolve() - expect(observed).toMatchObject({ message: 'target missing', code: 'ENOENT' }) + await expect(result.direct).rejects.toMatchObject({ message: 'target missing', code: 'ENOENT' }) } finally { cleanupRunnerFiles(failed) } @@ -1045,10 +1046,8 @@ describe('spawn runner transport', () => { appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 }) appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null }) const result = runnerDirectResult(fakeChild(123), exited, new Promise(() => {})) - let observed: SubprocessOutcome | undefined - void result.direct.then((outcome) => { observed = outcome }) - await Promise.resolve() - expect(observed).toEqual({ exitCode: 23, signal: null }) + await expect(result.direct).resolves.toEqual({ exitCode: 23, signal: null }) + expect(result.pid).toBe(456) } finally { cleanupRunnerFiles(exited) } @@ -1075,7 +1074,6 @@ describe('spawn runner transport', () => { const exited = Promise.withResolvers() const isolated = await import('../src/runner-launch.ts') const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise) - expect(result.failureReported).toBe(false) expect(readCount).toBe(1) exited.resolve(undefined) await Promise.resolve() @@ -1116,7 +1114,6 @@ describe('spawn runner transport', () => { const lifecycle = observeChildLifecycle(child) const result = runnerDirectResult(child, files, lifecycle.exited) expect(result.pid).toBeUndefined() - expect(result.failureReported).toBe(false) await expect(result.direct).rejects.toThrow('runner failed to start') await expect(lifecycle.closed).resolves.toBeUndefined() } finally { @@ -1124,32 +1121,17 @@ describe('spawn runner transport', () => { } }) - it('reports runner startup failure and handshake timeout without leaking request files', async () => { - const missingChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const missingResult = runnerDirectResult(fakeChild(undefined), missingChild, new Promise(() => {})) - expect(missingResult.pid).toBeUndefined() - expect(missingResult.failureReported).toBe(false) - await expect(missingResult.direct).rejects.toThrow('runner failed to start') - expect(existsSync(missingChild.directory)).toBe(false) - - const exitedChild = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const exitedResult = runnerDirectResult(fakeChild(2_147_483_647), exitedChild, new Promise(() => {})) - expect(exitedResult.pid).toBeUndefined() - expect(exitedResult.failureReported).toBe(false) - await expect(exitedResult.direct).rejects.toThrow('exited before reporting target start') - expect(existsSync(exitedChild.directory)).toBe(false) - - const timedOut = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(10_001) + it('returns before target publication and updates the pid getter from runner events', async () => { + const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { - const timedOutResult = runnerDirectResult(fakeChild(process.pid), timedOut, new Promise(() => {})) - expect(timedOutResult.pid).toBeUndefined() - expect(timedOutResult.failureReported).toBe(false) - await expect(timedOutResult.direct).rejects.toThrow('did not report target start') - expect(existsSync(timedOut.directory)).toBe(false) + const result = runnerDirectResult(fakeChild(process.pid), files, new Promise(() => {})) + expect(result.pid).toBeUndefined() + appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) + await vi.waitFor(() => { expect(result.pid).toBe(456) }) + appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) } finally { - now.mockRestore() - cleanupRunnerFiles(timedOut) + cleanupRunnerFiles(files) } }) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 7434efb1fb..850f0daf24 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -45,7 +45,8 @@ describe('Windows Job runner adapter', () => { spawn, runnerInvocation: invocation, }) - expect(launch.pid).toBeGreaterThan(0) + expect(launch.pid).toBeUndefined() + await vi.waitFor(() => { expect(launch.pid).toBeGreaterThan(0) }) await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) @@ -88,7 +89,7 @@ describe('Windows Job runner adapter', () => { expect(kill).not.toHaveBeenCalled() }) - it('lets a runner-reported startup failure close without a termination race', async () => { + it('uses runner exit status as the managed-range settlement fact after startup failure', async () => { const child = new EventEmitter() as ChildProcess const kill = vi.fn(() => true) const send = vi.fn() @@ -105,7 +106,7 @@ describe('Windows Job runner adapter', () => { launch.owner.signal('SIGTERM') await expect(launch.direct).rejects.toMatchObject({ code: 'ENOENT' }) - expect(send).not.toHaveBeenCalled() + expect(send).toHaveBeenCalledExactlyOnceWith({ type: 'terminate' }, expect.any(Function)) expect(kill).not.toHaveBeenCalled() child.emit('close', 0, null) await expect(launch.owner.waitForExit()).resolves.toBeUndefined() @@ -118,10 +119,11 @@ describe('Windows Job runner adapter', () => { const run = vi.fn(() => child) as unknown as typeof spawn const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['missing-runner'] }) + child.emit('error', new Error('spawn missing-runner ENOENT')) + child.emit('close', -2, null) await expect(launch.direct).rejects.toThrow('runner failed to start') launch.owner.signal('SIGTERM') expect(kill).not.toHaveBeenCalled() - child.emit('close', -2, null) await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) From ff413cfe5e98341f05c7c2df0eadcb5cf6405d99 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 22:24:20 +0800 Subject: [PATCH 062/110] fix(subprocess): align async pid evidence --- packages/e2b/subprocess-e2b/src/process.ts | 2 +- .../subprocess/subprocess-local/tests/native-windows.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 2afde2a79b..641e61e2ac 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -155,7 +155,7 @@ function waitWithSignal(promise: Promise, signal: AbortSignal | undefined) }) } -/** E2B-backed subprocess handle with deferred remote PID acquisition. */ +/** E2B-backed subprocess handle with private remote process-group tracking. */ export class E2BSubprocessHandle implements SubprocessHandle { readonly stdin: Writable | undefined readonly stdout: PassThrough | undefined diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 0284f6d789..7547875ddf 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -179,8 +179,9 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { if (command === undefined) throw new Error('expected ComSpec for the Windows runner test') const request = spec([command, '/d', '/s', '/c', 'exit 0']) const launch = launchWindowsJob(request) - expect(launch.pid).toBeGreaterThan(0) + expect(launch.pid).toBeUndefined() const failure = await launch.direct.catch((error: unknown) => error) + expect(launch.pid).toBeGreaterThan(0) expect(failure).toMatchObject({ message: 'injected runner cwd restoration failure', code: 'ENOENT', From cf6f25d283baffe0a1fbf1cfa8a88f2d0d04e444 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 14:10:08 +0800 Subject: [PATCH 063/110] fix(subprocess): keep native runner private --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- apps/cli/src/bin.ts | 6 +++++- packages/bundle/sdk-app/tsconfig.json | 3 --- .../subprocess/subprocess-local/package.json | 4 ---- .../subprocess/subprocess-local/src/bin.ts | 1 - .../subprocess-local/src/runner-launch.ts | 4 ++-- .../tests/native-windows.spec.ts | 2 -- .../tests/spawn-runner-built.e2e.ts | 7 +++++-- .../tests/spawn-runner.spec.ts | 10 ++++------ scripts/verify-application-entrypoints.ts | 1 - 10 files changed, 25 insertions(+), 31 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e079fa8c57..03c01e88af 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -113,18 +113,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 9dad00e9e5..0758c8d32f 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -25,7 +25,11 @@ function readVersion(): string { if (process.argv[2] === PACKAGED_RUNNER_ARG) { process.argv.splice(2, 1) - await import('@deepseek-ai/dsh-subprocess-local/spawn-runner') + const runnerEntry = new URL( + './lib/spawn-runner.js', + import.meta.resolve('@deepseek-ai/dsh-subprocess-local/package.json'), + ) + await import(runnerEntry.href) } else { const invocation = parseDshArgs(process.argv.slice(2), readVersion()) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json index f367b79af8..0a98d3117c 100644 --- a/packages/bundle/sdk-app/tsconfig.json +++ b/packages/bundle/sdk-app/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../subprocess/subprocess-local" - }, { "path": "../../runtime-diagnostics/invariants" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 37df80a38e..d207c90f50 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -18,10 +18,6 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./spawn-runner": { - "types": "./lib/types/bin.d.ts", - "default": "./lib/spawn-runner.js" - }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" diff --git a/packages/subprocess/subprocess-local/src/bin.ts b/packages/subprocess/subprocess-local/src/bin.ts index 9713423b14..0b7e50243b 100644 --- a/packages/subprocess/subprocess-local/src/bin.ts +++ b/packages/subprocess/subprocess-local/src/bin.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node /** Thin process entry for the ordinary subprocess native runner. */ import { reportSpawnRunnerFailure, runSpawnRunner } from './spawn-runner.ts' diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 5929add57c..3ab9f0abdd 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -28,9 +28,9 @@ export type RunnerInvocation = [string, ...string[]] export function spawnRunnerInvocation(): RunnerInvocation { if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] /* v8 ignore start -- source-plane coverage cannot execute the bundled module; - the required built-runner smoke executes its published entry. */ + the required built-runner smoke executes its private built entry. */ if (extname(fileURLToPath(import.meta.url)) !== '.ts') { - const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) + const builtEntry = fileURLToPath(new URL('./spawn-runner.js', import.meta.url)) return [process.execPath, builtEntry] } /* v8 ignore stop */ diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 7547875ddf..517ecbeaa0 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -209,10 +209,8 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { await expect(missingHandle.waitForExit()).resolves.toBe(true) const accessDenied = spec([scratch]) - const expectedAccessDenied = await directSpawnFailure([scratch]) const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied)) await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) - await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: expectedAccessDenied.code }) await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true) const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index 7b8703b215..d9819b87ff 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -4,11 +4,14 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { cleanupRunnerFiles, createRunnerFiles, readRunnerEvents } from '../src/runner-protocol.ts' -const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/spawn-runner')) +const builtEntry = fileURLToPath(new URL( + './lib/spawn-runner.js', + import.meta.resolve('@deepseek-ai/dsh-subprocess-local/package.json'), +)) const required = process.env.DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER === '1' describe.skipIf(!existsSync(builtEntry) && !required)('built subprocess runner entry', () => { - it('reports the direct target outcome through the published entry', () => { + it('reports the direct target outcome through the built private entry', () => { if (!existsSync(builtEntry)) throw new Error(`required built subprocess runner is missing: ${builtEntry}`) const files = createRunnerFiles({ argv: [process.execPath, '-e', 'process.exit(11)'], diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index ea789dfcd1..e98ad98992 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -119,16 +119,14 @@ function runRunner(invocation: string[], requestPath: string, eventsPath: string } describe('spawn runner transport', () => { - it('selects the source runner from source-plane execution', () => { + it('selects the source runner without publishing a runner package face', () => { expect(spawnRunnerInvocation()).toEqual(sourceInvocation) const manifest = JSON.parse(readFileSync( fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8', - )) as { exports: Record } - expect(manifest.exports['./spawn-runner']).toEqual({ - types: './lib/types/bin.d.ts', - default: './lib/spawn-runner.js', - }) + )) as { exports: Record } + expect(manifest.exports).not.toHaveProperty('./spawn-runner') + expect(manifest.exports['./package.json']).toBe('./package.json') }) it('observes runner events without SharedArrayBuffer', async () => { diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index d4c23359d1..bbe67e5b80 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -42,7 +42,6 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts', 'test-only subprocess driver'], ['packages/subagent/subagent-codex/tests/fixtures/loader/driver.ts', 'test-only subprocess driver'], ['packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/driver.ts', 'test-only subprocess driver'], - ['packages/subprocess/subprocess-local/src/bin.ts', 'private subprocess runner implementation'], ['packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts', 'test-only subprocess driver'], ['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'], ]) From 215c8c813283b9bcdc75d984761c8e5847f014b2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 18:51:14 +0800 Subject: [PATCH 064/110] fix(subagent): wait for real startup evidence --- packages/subagent/subagent-acp/src/run.ts | 41 +++++--- .../subagent-acp/tests/subagent-acp.spec.ts | 99 ++++++++++++++++++- .../subagent/subagent-claude-code/src/run.ts | 59 +++++++++-- .../tests/subagent-claude-code.spec.ts | 69 ++++++++++++- 4 files changed, 234 insertions(+), 34 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 0951df6745..47fbb436e5 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -285,11 +285,11 @@ function reportFailure(spec: AcpRunSpec, error: unknown): void { function startupFailure( error: unknown, stage: Extract, - child: SubprocessHandle, + processFailure: Error | undefined, outcome: SubprocessOutcome | undefined, ): AcpRunFailure { - if (child.pid === undefined) { - return new AcpRunFailure({ stage: 'process', category: 'process-start' }, error) + if (processFailure !== undefined) { + return new AcpRunFailure({ stage: 'process', category: 'process-start' }, processFailure) } return new AcpRunFailure( /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child protocol close during startup. */ @@ -366,10 +366,17 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } /* v8 ignore stop */ let processOutcome: SubprocessOutcome | undefined - const processDone = child.done.then((outcome) => { - processOutcome = outcome - return outcome - }) + let processFailure: Error | undefined + const processDone = child.done.then( + (outcome) => { + processOutcome = outcome + return outcome + }, + (error: unknown) => { + processFailure = toError(error) + throw processFailure + }, + ) // Spawn-level failure surfaces as `done` rejecting into the startup race; a // clean exit must never win it, so the success arm parks forever. (The ACP @@ -383,7 +390,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) const observeProcessOutcome = async (signal?: AbortSignal): Promise => { - if (processOutcome !== undefined || child.pid === undefined) return processOutcome + if (processOutcome !== undefined) return processOutcome const timeout = AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)) const bound = signal === undefined ? timeout : AbortSignal.any([signal, timeout]) const aborted = Promise.withResolvers() @@ -507,13 +514,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // A child closing its protocol stream can precede whole-tree exit // observation. Local cancellation does not need the discarded startup // classification; other failures use the configured process grace. + const observedOutcome = !cancelledBeforeCleanup && !(error instanceof AcpRunFailure) + ? await observeProcessOutcome() + : undefined const startup = cancelledBeforeCleanup ? { kind: 'cancelled' } as const : { kind: 'failed', failure: error instanceof AcpRunFailure ? error - : startupFailure(error, startupStage, child, await observeProcessOutcome()), + : startupFailure(error, startupStage, processFailure, observedOutcome), } as const if (startup.kind === 'cancelled') { // Local cancellation owns the startup outcome; only cleanup failure is @@ -521,7 +531,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } else { reportFailure(spec, error instanceof AcpRunFailure ? error.cause - : error) + : processFailure ?? error) } try { await disposeProcess() @@ -572,13 +582,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } catch (error: unknown) { if (!flags.cancelled) { const outcome = await observeProcessOutcome(request.signal) - /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child prompt transport failure. */ - const facts = outcome === undefined - ? { stage: 'prompt', category: 'transport' } as const - : { stage: 'process', category: 'process-exit', outcome } as const + const facts = processFailure !== undefined + ? { stage: 'process', category: 'process-start' } as const + : outcome === undefined + ? { stage: 'prompt', category: 'transport' } as const + : { stage: 'process', category: 'process-exit', outcome } as const diagnostic = diagnosticText(facts, latestPermission) } - throw error + throw processFailure ?? error } }, collectOutput, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 690c4f6d2a..a362932b49 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -179,6 +179,19 @@ function replaceProcessOutcome(child: SubprocessHandle, outcome: SubprocessOutco } } +function hideProcessPid(child: SubprocessHandle): SubprocessHandle { + return { + pid: undefined, + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + collected: child.collected, + done: child.done, + terminate: () => { child.terminate() }, + waitForExit: (signal?: AbortSignal) => child.waitForExit(signal), + } +} + describe('acpStopReason', () => { it('maps each ACP stop reason to the harness vocabulary', () => { expect(acpStopReason('end_turn')).toBe('completed') @@ -734,7 +747,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_INITIALIZE: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, - spawn: spawnSubprocess, + spawn: spec => hideProcessPid(spawnSubprocess(spec)), }).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( @@ -751,7 +764,7 @@ describe('dsh-subagent-acp', () => { env: {}, disposeEofGraceMs: 50, disposeGraceMs: 50, - spawn: spec => closeProtocolImmediately(spawnSubprocess(spec)), + spawn: spec => closeProtocolImmediately(hideProcessPid(spawnSubprocess(spec))), }).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( @@ -759,6 +772,37 @@ describe('dsh-subagent-acp', () => { ) }) + it('reuses a direct outcome already observed before the startup transport closes', async () => { + const outcome = { exitCode: 19, signal: null } as const + const stdin = new PassThrough() + const stdout = new PassThrough() + const starting = startAcpRun(request(), { + command: 'fake-acp', + args: [], + cwd: process.cwd(), + permission: 'reject', + env: {}, + disposeEofGraceMs: 50, + disposeGraceMs: 50, + spawn: () => ({ + pid: undefined, + stdin, + stdout, + stderr: undefined, + collected: {}, + done: Promise.resolve(outcome), + terminate: vi.fn(), + waitForExit: vi.fn().mockResolvedValue(true), + }), + }) + await Promise.resolve() + stdout.end() + + await expect(starting).rejects.toThrow( + `subagent-acp: ${expectedFailure('stage: initialize; category: process-exit; exit code: 19')}`, + ) + }) + it('reaps a child whose session/new response omits the session id', async () => { const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-')) const flushed = join(tmp, 'flushed') @@ -1144,8 +1188,16 @@ describe('dsh-subagent-acp', () => { }) it('preserves partial output and structured process facts when the child exits', async () => { - const ctx = await setup({ MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' }) - const run = await ctx.subagents.start('acp', request()) + const run = await startAcpRun(request(), { + command: process.execPath, + args: [mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' }, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spec => hideProcessPid(spawnSubprocess(spec)), + }) const result = await run.result expect(result).toEqual({ output: [{ type: 'text', text: 'partial answer' }], @@ -1155,6 +1207,45 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) + it('classifies a rejected direct result independently of PID publication', async () => { + const processFailure = new Error('remote provider failed before publishing a PID') + const direct = Promise.withResolvers() + let realChild: SubprocessHandle | undefined + const errors: Error[] = [] + const run = await startAcpRun(request(), { + command: process.execPath, + args: [mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_HANG: '1' }, + disposeEofGraceMs: 100, + disposeGraceMs: 100, + spawn: (spec) => { + const child = spawnSubprocess(spec) + realChild = child + return closeProtocolOnPrompt({ + pid: undefined, + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + collected: child.collected, + done: direct.promise, + terminate: () => { child.terminate() }, + waitForExit: (signal?: AbortSignal) => child.waitForExit(signal), + }, () => { direct.reject(processFailure) }) + }, + onError: (error) => { errors.push(error) }, + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailure('stage: process; category: process-start'), + stopReason: 'error', + }) + expect(errors).toContain(processFailure) + await run.dispose() + await realChild?.done + }) + it('reports a signal-only process outcome', async () => { const run = await startAcpRun(request(), { command: process.execPath, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 5ddf5ea703..6dba308789 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -259,6 +259,28 @@ export async function consumeClaudeQuery( } } +/** Continue one SDK iterator after startup consumed its first message. */ +async function* prefetchedClaudeQuery( + first: SDKMessage, + iterator: AsyncIterator, +): AsyncGenerator { + let completed = false + try { + yield first + while (true) { + const next = await iterator.next() + if (next.done) { + completed = true + return + } + yield next.value + } + } finally { + /* v8 ignore next -- the official Query iterator always owns return(). */ + if (!completed) await iterator.return?.() + } +} + /** * Close the official query, terminate the managed process tree, and wait for * the subprocess owner to prove it is gone. @@ -379,7 +401,7 @@ export function claudeQueryOptions( * Start one official Claude Agent SDK query and publish its one-shot run. * @param request - resolved shared subagent request. * @param spec - Workspace, environment, process service, and diagnostic policy. - * @returns the published run after both Query and real CLI handle exist. + * @returns the published run after Query, the real CLI handle, and the first SDK message exist. */ export async function startClaudeCodeRun( request: SubagentStartRequest, @@ -408,7 +430,9 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let childFailure: Error | undefined + let childStartupFailure: Promise | undefined let query: Query | undefined + let queryMessages: AsyncIterable | undefined let managedProcess: ManagedClaudeCodeProcess | undefined let diagnostic: string | undefined const capturePermissionDiagnostic = (value: string): void => { @@ -426,7 +450,14 @@ export async function startClaudeCodeRun( ): void => { child = captured managedProcess = process - void captured.done.catch((error: unknown) => { childFailure = thrown(error) }) + childStartupFailure = captured.done.then( + () => new Promise(() => {}), + (error: unknown) => { + childFailure = thrown(error) + throw childFailure + }, + ) + void childStartupFailure.catch(() => {}) } try { query = officialQuery({ @@ -438,19 +469,26 @@ export async function startClaudeCodeRun( capturePermissionDiagnostic, ), }) - if (child === undefined) { + if (child === undefined || childStartupFailure === undefined) { throw new Error( 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', ) } - // A provider may publish no PID and reject `done` through several already- - // queued promise reactions. PID absence is not failure; give that complete - // synchronous rejection chain one event-loop turn before publication. - await new Promise((resolve) => { setImmediate(resolve) }) - if (childFailure !== undefined) throw childFailure - if (controller.signal.aborted) { + if (isAborted(controller.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } + const iterator = query[Symbol.asyncIterator]() + const first = await Promise.race([ + childStartupFailure, + iterator.next(), + ]) + if (isAborted(controller.signal)) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + if (first.done) { + throw new Error('subagent-claude-code: official SDK query ended before its first message') + } + queryMessages = prefetchedClaudeQuery(first.value, iterator) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted @@ -513,11 +551,12 @@ export async function startClaudeCodeRun( const publishedQuery = query const publishedChild = child + const publishedMessages = queryMessages let receivedResult = false const result = settleRunResult({ attempt: async () => { try { - return await consumeClaudeQuery(publishedQuery, () => { + return await consumeClaudeQuery(publishedMessages, () => { capturePermissionDiagnostic(unattendedDiagnostic( spec.permissionMode, 'tool permission', diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index ae048ed6b4..4253308fc0 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -262,6 +262,7 @@ function queryFrom( function waitingQuery(signal: AbortSignal, close = vi.fn()): Query { async function* stream(): AsyncGenerator { + yield { type: 'system', subtype: 'init' } as SDKMessage await new Promise((_resolve, reject) => { const fail = (): void => { reject(signal.reason instanceof Error @@ -330,7 +331,7 @@ beforeEach(() => { env: options.env!, signal: options.abortController!.signal, })) - return queryFrom([]) + return queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]) }) }) @@ -686,7 +687,10 @@ describe('task admission and package contracts', () => { child.stdout.end() await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result'), + diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result', { + exitCode: 9, + signal: null, + }), stopReason: 'error', }) expect(warn).toHaveBeenCalledWith( @@ -1213,6 +1217,7 @@ describe('run publication, cancellation, and settlement', () => { for (const outcome of outcomes) { const child = fakeChild() async function* stream(): AsyncGenerator { + yield { type: 'system', subtype: 'init' } as SDKMessage child.settle(outcome) await Promise.resolve() throw new Error('SECRET_TOKEN from process transport') @@ -1592,7 +1597,7 @@ describe('run publication, cancellation, and settlement', () => { .rejects.not.toThrow('live child cleanup failed') }) - it('waits one event-loop turn for a queued provider startup rejection', async () => { + it('waits for the first SDK message or a delayed provider startup rejection', async () => { const spawnError = Object.assign( new Error('spawn /sdk/claude ENOENT'), { code: 'ENOENT', path: '/sdk/claude' }, @@ -1601,7 +1606,59 @@ describe('run publication, cancellation, and settlement', () => { const close = vi.fn() queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - queueMicrotask(() => { child.fail(spawnError) }) + async function* stream(): AsyncGenerator { + await new Promise(() => {}) + } + return Object.assign(stream(), { close }) as unknown as Query + }) + + const startup = startClaudeCodeRun(request(), { + cwd: '/workspace', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) + await nextTask() + child.fail(spawnError) + await expect(startup).rejects.toMatchObject({ cause: spawnError }) + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + }) + + it('keeps local cancellation authoritative when it arrives with the first SDK message', async () => { + const controller = new AbortController() + const child = fakeChild() + const close = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + async function* stream(): AsyncGenerator { + controller.abort(new Error('cancelled while the first message arrived')) + yield { type: 'system', subtype: 'init' } as SDKMessage + } + return Object.assign(stream(), { close }) as unknown as Query + }) + + await expect(startClaudeCodeRun( + request(undefined, controller.signal), + { + cwd: '/workspace', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }, + )).rejects.toThrow('aborted before SDK startup') + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + }) + + it('rejects an SDK stream that ends before its first message', async () => { + const child = fakeChild() + const close = vi.fn() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) return queryFrom([], undefined, close) }) @@ -1612,7 +1669,9 @@ describe('run publication, cancellation, and settlement', () => { disposeGraceMs: 5, spawn: () => child.handle, }) - await expect(startup).rejects.toMatchObject({ cause: spawnError }) + await expect(startup).rejects.toThrow( + expectedFailureDiagnostic('query-start', 'unknown'), + ) expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() expect(child.waitForExit).toHaveBeenCalledOnce() From 6a34a0dd4b2f8d454337cc496098c8874ccc9598 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 19:00:40 +0800 Subject: [PATCH 065/110] fix(subprocess): isolate Linux native target group --- packages/subprocess/subprocess-local/src/spawn-runner.ts | 1 + .../subprocess/subprocess-local/tests/spawn-runner.spec.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index a447cae348..3203ae2dba 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -133,6 +133,7 @@ async function runNode( cwd: request.cwd, env: request.env, stdio: 'inherit', + detached: true, }) await new Promise((resolve) => { let started = false diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index e98ad98992..7f55d66ec3 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -236,6 +236,13 @@ describe('spawn runner transport', () => { '--request', files.requestPath, '--events', files.eventsPath, ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) + expect(injectedSpawn).toHaveBeenCalledTimes(1) + expect(injectedSpawn).toHaveBeenCalledWith('node', [], { + cwd: process.cwd(), + env: {}, + stdio: 'inherit', + detached: true, + }) expect(host.exitCode).toBe(127) expect(readRunnerEvents(files.eventsPath)).toEqual([ { type: 'started', pid: 4321 }, From 166004a6a8cb0e88e9b2d848d775283661b257e5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 20:01:10 +0800 Subject: [PATCH 066/110] fix(subprocess): preserve Windows startup errors --- .../subprocess-local/src/spawn-runner.ts | 10 ++++- .../tests/spawn-runner.spec.ts | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 3203ae2dba..1e122e88b5 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -216,13 +216,19 @@ async function runWin32( // Match Node's cwd-relative executable lookup and spawn-error attribution. const runnerCwd = host.cwd() host.chdir(request.cwd) + const [command, ...args] = request.argv + let spawned: ReturnType try { - const [command, ...args] = request.argv - const spawned = internals.spawnCurrentTokenJobProcess( + spawned = internals.spawnCurrentTokenJobProcess( api, { command: command as string, args, cwd: host.cwd() }, stdio, ) + } catch (error) { + try { host.chdir(runnerCwd) } catch { /* Preserve the target startup failure. */ } + throw error + } + try { processHandle = spawned.process jobHandle = spawned.job appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 7f55d66ec3..6369357da7 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -519,6 +519,47 @@ describe('spawn runner transport', () => { } }) + it('preserves a Win32 target spawn failure when restoring the runner cwd also fails', async () => { + const files = createRunnerFiles({ + argv: ['missing.exe', 'literal argument'], + cwd: 'C:\\target', + env: {}, + }) + const host = new FakeRunnerHost() + host.directory = 'C:\\runner' + const chdir = vi.fn((directory: string) => { + if (directory === 'C:\\runner') throw new Error('cwd restore failed') + host.directory = directory + }) + host.chdir = chdir + const internals = fakeRunnerInternals({ + spawnCurrentTokenJobProcess: vi.fn(() => { + throw new Win32Error('CreateProcessW', 2) + }), + }) + try { + await runSpawnRunner( + win32RunnerArgs(files.requestPath, files.eventsPath), + asRunnerHost(host), + internals, + ) + expect(chdir).toHaveBeenCalledTimes(2) + expect(host.exitCode).toBeUndefined() + const [event] = readRunnerEvents(files.eventsPath) + expect(event?.type).toBe('spawn-error') + if (event?.type !== 'spawn-error') throw new Error('expected spawn error') + expect(event.error).toMatchObject({ + code: 'ENOENT', + syscall: 'spawn missing.exe', + path: 'missing.exe', + spawnargs: ['literal argument'], + }) + expect(event.error.message).not.toContain('cwd restore failed') + } finally { + cleanupRunnerFiles(files) + } + }) + it.each([ [undefined, false], ['ENOENT', true], From 3caa9e4f5a6816cad60cde0e1757b57161379cac Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 22:16:20 +0800 Subject: [PATCH 067/110] fix(subprocess): retain failed cleanup ownership --- packages/lsp/lsp-stdio/src/instance.ts | 40 ++++-- packages/lsp/lsp-stdio/tests/instance.spec.ts | 116 +++++++++++++++++- .../subprocess/subprocess-local/src/index.ts | 27 ++-- .../subprocess-local/src/linux-scope.ts | 6 +- .../subprocess-local/src/runner-protocol.ts | 16 --- .../subprocess/subprocess-local/src/spawn.ts | 11 +- .../subprocess-local/src/terminal.ts | 7 +- .../tests/linux-scope.spec.ts | 52 ++++++-- .../subprocess-local/tests/local.spec.ts | 45 +++++-- .../tests/managed-spawn.spec.ts | 10 +- .../tests/spawn-runner-built.e2e.ts | 6 +- .../tests/spawn-runner.spec.ts | 66 +++++----- .../subprocess-local/tests/terminal.spec.ts | 28 ++++- .../generator/tests/cordis-catalog.spec.ts | 1 - 14 files changed, 316 insertions(+), 115 deletions(-) diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index 2028129de7..d9ff83279f 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -97,7 +97,7 @@ export class LspInstance { const run = abortable(this.queue, signal) .then(() => this.runQuery(request, source, signal)) .catch(async (error: unknown) => { - if (this.isTransportFailure(error)) await this.startTeardown() + if (this.isTransportFailure(error)) await this.throwAfterTeardown(error) throw error }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The @@ -136,7 +136,7 @@ export class LspInstance { await abortable(this.ready, signal) } catch (error) { if (!this.dead) { - await this.startTeardown() + await this.throwAfterTeardown(error) } throw error } @@ -152,6 +152,8 @@ export class LspInstance { const uri = source.fileUrl let opened = false + let queryFailed = false + let queryFailure: unknown try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) @@ -162,12 +164,15 @@ export class LspInstance { } catch (error) { // A canceled backpressured write or failed stdin leaves the protocol stream unusable before // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. - await this.startTeardown() - throw error + await this.throwAfterTeardown(error) } opened = true const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) + } catch (error: unknown) { + queryFailed = true + queryFailure = error + throw error } finally { // A disposed or closed instance (e.g. an aborted request whose server ignored // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let @@ -175,14 +180,19 @@ export class LspInstance { if (opened && !this.dead) { try { await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) - } catch { + } catch (closeError: unknown) { // A close-write failure does not replace the settled result/error, but the instance can no - // longer be trusted: invalidate it and await bounded process termination. + // longer be trusted: invalidate it and await bounded process termination. If teardown also + // fails, every failure remains visible in operation, close, cleanup order. try { await this.startTeardown() - } catch { - /* v8 ignore next -- teardown owns all expected process races; this only preserves the - already-settled query outcome if an unexpected cleanup primitive itself rejects. */ + } catch (teardownError: unknown) { + throw new AggregateError( + queryFailed + ? [queryFailure, closeError, teardownError] + : [closeError, teardownError], + 'LSP query cleanup failed', + ) } } } @@ -232,7 +242,7 @@ export class LspInstance { grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) }), ]) - if (!settled) await this.startTeardown() + if (!settled) await this.throwAfterTeardown(error) } finally { grace[Symbol.dispose]() } @@ -283,6 +293,16 @@ export class LspInstance { return this.teardownPromise } + /** Preserve an operation failure when teardown also fails. */ + private async throwAfterTeardown(error: unknown): Promise { + try { + await this.startTeardown() + } catch (teardownError: unknown) { + throw new AggregateError([error, teardownError], 'LSP operation and teardown failed') + } + throw error + } + private async tearDown(): Promise { const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 9efc091919..bb4fc15f85 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -97,6 +97,24 @@ function scriptInstance(script: string, overrides: Partial = {}): return instance } +interface TestConnection { + readonly closed: Promise + waitForProcessTreeExit(signal?: AbortSignal): Promise +} + +/** Make the instance's final managed-range observation fail deterministically. */ +function rejectProcessTreeWait(instance: LspInstance, failure: Error): TestConnection { + const connection = (instance as unknown as { connection: TestConnection }).connection + vi.spyOn(connection, 'waitForProcessTreeExit').mockRejectedValue(failure) + return connection +} + +/** A failed teardown cannot be disposed again; await process close and remove it from afterEach. */ +async function releaseFailedInstance(instance: LspInstance, connection: TestConnection): Promise { + live = live.filter(candidate => candidate !== instance) + await connection.closed +} + /** An inline server that answers initialize + definition and echoes a location. */ const RESPONDING_SERVER = 'let b=Buffer.alloc(0);' @@ -244,6 +262,52 @@ describe('LspInstance query and abort', () => { expect(processAlive(pid)).toBe(false) }) + it('preserves a request write failure with a managed-range teardown failure', async () => { + const operationFailure = new Error('fixture textDocument/definition failure') + const teardownFailure = new Error('managed range observation failed') + const instance = makeInstance({}, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }, failingWriter('textDocument/definition', operationFailure)) + const connection = rejectProcessTreeWait(instance, teardownFailure) + try { + const failure = await run(instance, 'goToDefinition').then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toMatchObject({ + errors: [operationFailure, teardownFailure], + message: 'LSP operation and teardown failed', + }) + } finally { + await releaseFailedInstance(instance, connection) + } + }) + + it('preserves an initialization failure with a managed-range teardown failure', async () => { + const teardownFailure = new Error('managed range observation failed') + const instance = makeInstance({ LSP_FAKE_ENCODING: 'utf-8' }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + const connection = rejectProcessTreeWait(instance, teardownFailure) + try { + const failure = await run(instance, 'goToDefinition').then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + const errors = (failure as AggregateError).errors as unknown[] + expect(errors).toHaveLength(2) + expect(errors[0]).toBeInstanceOf(Error) + expect((errors[0] as Error).message).toContain('unsupported position encoding') + expect(errors[1]).toBe(teardownFailure) + expect((failure as AggregateError).message).toBe('LSP operation and teardown failed') + } finally { + await releaseFailedInstance(instance, connection) + } + }) + it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) @@ -268,6 +332,52 @@ describe('LspInstance query and abort', () => { }) expect(instance.dead).toBe(true) }) + + it('reports didClose and teardown failures after a settled result', async () => { + const closeFailure = new Error('fixture textDocument/didClose failure') + const teardownFailure = new Error('managed range observation failed') + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose', closeFailure)) + const connection = rejectProcessTreeWait(instance, teardownFailure) + try { + const failure = await run(instance, 'goToDefinition').then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toMatchObject({ + errors: [closeFailure, teardownFailure], + message: 'LSP query cleanup failed', + }) + } finally { + await releaseFailedInstance(instance, connection) + } + }) + + it('reports query, didClose, and teardown failures in lifecycle order', async () => { + const closeFailure = new Error('fixture textDocument/didClose failure') + const teardownFailure = new Error('managed range observation failed') + const instance = makeInstance({ + LSP_FAKE_ERROR: '1', + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose', closeFailure)) + const connection = rejectProcessTreeWait(instance, teardownFailure) + try { + const failure = await run(instance, 'goToDefinition').then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + const errors = (failure as AggregateError).errors as unknown[] + expect(errors).toHaveLength(3) + expect(errors[0]).toBeInstanceOf(Error) + expect((errors[0] as Error).message).toContain('server refused') + expect(errors[1]).toBe(closeFailure) + expect(errors[2]).toBe(teardownFailure) + expect((failure as AggregateError).message).toBe('LSP query cleanup failed') + } finally { + await releaseFailedInstance(instance, connection) + } + }) }) describe('LspInstance disposal', () => { @@ -372,10 +482,10 @@ async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise } /** Write normally except for one method whose callback receives a deterministic transport error. */ -function failingWriter(method: string): ConnectionWriter { +function failingWriter(method: string, failure = new Error(`fixture ${method} failure`)): ConnectionWriter { return (stdin, message, done) => { if ((message as { method?: unknown }).method === method) { - queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) }) + queueMicrotask(() => { done(failure) }) return } stdin.write(encodeMessage(message), done) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index ed22ce3a8b..df86978816 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -72,11 +72,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const onHostExit = (): void => { this.terminateForHostExit() } process.prependListener('exit', onHostExit) return async () => { - try { - await this.disposeManagedProcesses() - } finally { - process.off('exit', onHostExit) - } + await this.disposeManagedProcesses() + process.off('exit', onHostExit) } }, 'local subprocess teardown') } @@ -111,18 +108,16 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { pending.push(Promise.all([ handle.done.catch(() => {}), handle.waitForExit(), - ]).then(() => undefined)) + ]).then(() => { this.live.delete(handle) })) } for (const terminal of this.terminals) { - pending.push(terminal.terminate()) + pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) })) } const outcomes = await Promise.allSettled(pending) const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) if (failures.length > 0) this.terminateForHostExit() - this.live.clear() - this.terminals.clear() if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') } @@ -195,6 +190,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { kind: 'ordinary' | 'terminal', ): 'linux-scope' | 'windows-job' | 'fallback' { const platform = this.internals.platform ?? process.platform + let fallbackReason: string | undefined if (platform === 'linux') { const managerAvailable = probeLinuxUserManager() if (managerAvailable && !this.linuxScopeCapabilityConfirmed) { @@ -206,6 +202,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { this.linuxRunnerCapabilityConfirmed = probeLinuxRunner() } if (this.linuxRunnerCapabilityConfirmed) return 'linux-scope' + fallbackReason = 'the private Linux subprocess runner is unavailable' } } if (kind === 'ordinary' && platform === 'win32') { @@ -214,14 +211,18 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } if (this.windowsJobCapabilityConfirmed) return 'windows-job' } - this.warnFallback(platform, kind) + this.warnFallback(platform, kind, fallbackReason) return 'fallback' } - private warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void { + private warnFallback( + platform: NodeJS.Platform, + kind: 'ordinary' | 'terminal', + selectedReason?: string, + ): void { if (this.fallbackWarningIssued) return this.fallbackWarningIssued = true - const reason = platform === 'darwin' + const reason = selectedReason ?? (platform === 'darwin' ? 'macOS has no supported persistent process-range owner' : platform === 'linux' ? 'a modern readable user-systemd scope is unavailable' @@ -229,7 +230,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? kind === 'terminal' ? 'Windows ConPTY remains outside Job containment' : 'the Win32 Job runner is unavailable' - : `platform ${platform} has no native managed range` + : `platform ${platform} has no native managed range`) this.ctx.logger.warn( `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`, ) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 01263abc05..9683052223 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -155,6 +155,7 @@ class SystemdScopeOwner implements BoundProcessOwner { ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) if (signal === 'SIGKILL' && result.error === undefined) this.onForceKillAttempt() if (result.error === undefined && result.status === 0) { + if (signal === 'SIGKILL') this.killFailure = undefined return } if (signal === 'SIGKILL') { @@ -197,7 +198,10 @@ class SystemdScopeOwner implements BoundProcessOwner { this.observation ??= (async () => { while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS) this.stopped = true - })() + })().catch((error: unknown) => { + this.observation = undefined + throw error + }) await this.observation } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 73762bf2dc..af54bb8cb4 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -145,22 +145,6 @@ function parseRunnerEvents(content: string): RunnerEvent[] { return lines.filter(line => line.length > 0).map(parseRunnerEvent) } -/** - * Parse every complete event record currently present. - * @param eventsPath - private event file. - * @returns complete records in append order. - */ -export function readRunnerEvents(eventsPath: string): RunnerEvent[] { - let content: string - try { - content = readFileSync(eventsPath, 'utf8') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw error - } - return parseRunnerEvents(content) -} - /** * Asynchronously parse every complete event record currently present. * @param eventsPath - private event file. diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8aec51bc67..48f83455e0 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -478,9 +478,9 @@ export function bindManagedProcess( let settled = false /** - * Start or reuse the handle's single managed-range exit observer. The first - * confirmed absence is a permanent no-more-signals boundary: it cancels a - * pending escalation before a stale platform identity can be reused. + * Start or reuse the handle's managed-range exit observer. A failed read can + * be retried; the first confirmed absence is the permanent no-more-signals + * boundary and cancels pending escalation before stale identity can be used. */ const observeRangeExit = (): Promise => { rangeExitObservation ??= (async () => { @@ -489,7 +489,10 @@ export function bindManagedProcess( if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined spec.signal?.removeEventListener('abort', onAbort) - })() + })().catch((error: unknown) => { + rangeExitObservation = undefined + throw error + }) return rangeExitObservation } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index c0f451d844..81f059d5b5 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -337,7 +337,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { owner.signal('SIGKILL') if (first.kind === 'failed') { // The observation failure is still authoritative, but force cleanup - // must be attempted before exposing it to the caller. + // and a fresh final observation must be attempted before exposing it. + try { + await owner.waitForExit() + } catch (finalError: unknown) { + throw new AggregateError([first.error, finalError], 'terminal managed-range cleanup failed') + } throw first.error } await observation diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index c0540dea7f..ae25749d10 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -257,25 +257,57 @@ describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) - it('rejects wait when the selected native owner becomes unreadable', async () => { + it('retries wait after the selected native owner becomes readable again', async () => { const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { const separator = args.indexOf('--') return spawn(args[separator + 1] as string, args.slice(separator + 2), options) }) as unknown as typeof spawn - const runSync = vi.fn(() => ({ - status: 1, - stdout: '', - stderr: 'Failed to connect to bus: No such file or directory', - error: undefined, - })) as unknown as typeof spawnSync + const failure = new Error('Failed to connect to bus: No such file or directory') + const query = vi.fn() + .mockRejectedValueOnce(failure) + .mockResolvedValue({ status: 0, stdout: 'inactive\n', stderr: '' }) const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), + spawnSync: vi.fn(() => ({ status: 0, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync, + systemctlQuery: query, runnerInvocation: spawnRunnerInvocation(), }) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus') + await expect(launch.owner.waitForExit()).rejects.toBe(failure) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + expect(query).toHaveBeenCalledTimes(2) + }) + + it('clears a failed KILL result after a later KILL succeeds', async () => { + let killed = false + let killAttempts = 0 + const runSync = vi.fn((_command: string, args: readonly string[]) => { + if (args.includes('--signal=SIGKILL')) { + killAttempts += 1 + if (killAttempts === 1) { + return { status: 1, stdout: '', stderr: 'kill failed', error: undefined } + } + killed = true + } + return { status: 0, stdout: '', stderr: '', error: undefined } + }) as unknown as typeof spawnSync + const query = vi.fn(async () => ({ + status: 0, + stdout: killed ? 'inactive\n' : 'active\n', + stderr: '', + })) + const launch = prepareLinuxTerminalScope(['/bin/sh'], { + spawnSync: runSync, + systemctlQuery: query, + }) + const owner = launch.bindOwner(() => true) + + owner.signal('SIGKILL') + await expect(owner.waitForExit()).rejects.toThrow('kill failed') + owner.signal('SIGKILL') + await expect(owner.waitForExit()).resolves.toBeUndefined() + + expect(query).toHaveBeenCalledTimes(2) }) it('propagates systemctl execution failures and unknown active states', async () => { diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 7eaea1822e..d852b3b101 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -119,7 +119,11 @@ describe('LocalSubprocessRuntime', () => { expect(terminate).toHaveBeenCalledOnce() expect(terminateForHostExit).toHaveBeenCalledOnce() expect(disposalErrors).toEqual([rangeFailure]) - expect(process.listeners('exit')).not.toContain(listener) + expect(live.size).toBe(1) + expect(process.listeners('exit')).toContain(listener) + listener?.(0) + expect(terminateForHostExit).toHaveBeenCalledTimes(2) + if (listener !== undefined) process.off('exit', listener) }) it('contains each host-exit termination failure and continues with the other targets', async () => { @@ -237,8 +241,11 @@ describe('LocalSubprocessRuntime', () => { }) it('waits for every terminal cleanup and aggregates teardown failures', async () => { + const before = new Set(process.listeners('exit')) const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') const service = ctx.subprocess const firstFailure = new Error('first cleanup failure') const secondFailure = new Error('second cleanup failure') @@ -276,20 +283,25 @@ describe('LocalSubprocessRuntime', () => { expect(disposed).toBe(false) finishCleanup() await disposing - expect(terminals.size).toBe(0) + expect(terminals).toEqual(new Set([failedTerminal, secondFailedTerminal])) expect(disposalErrors).toHaveLength(1) expect(disposalErrors[0]).toMatchObject({ errors: [firstFailure, secondFailure], message: 'local subprocess teardown failed', }) + expect(process.listeners('exit')).toContain(listener) + if (listener !== undefined) process.off('exit', listener) }) it('reports one cleanup failure without wrapping it', async () => { + const before = new Set(process.listeners('exit')) const ctx = new Context() const failure = new Error('single cleanup failure') 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)) + expect(listener).toBeTypeOf('function') const service = ctx.subprocess const terminal: SubprocessTerminalHandle = { pid: 1, @@ -306,9 +318,12 @@ describe('LocalSubprocessRuntime', () => { await fiber.dispose() expect(disposalErrors).toEqual([failure]) + expect(terminals.has(terminal)).toBe(true) + expect(process.listeners('exit')).toContain(listener) + if (listener !== undefined) process.off('exit', listener) }) - it('force-terminates remaining targets before releasing a failed disposal', async () => { + it('force-terminates and retains failed disposal targets for host exit', async () => { const before = new Set(process.listeners('exit')) const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) @@ -328,8 +343,11 @@ describe('LocalSubprocessRuntime', () => { await fiber.dispose() expect(terminateForHostExit).toHaveBeenCalledOnce() - expect(terminals.size).toBe(0) - expect(process.listeners('exit')).not.toContain(listener) + expect(terminals.size).toBe(1) + expect(process.listeners('exit')).toContain(listener) + listener?.(0) + expect(terminateForHostExit).toHaveBeenCalledTimes(2) + if (listener !== undefined) process.off('exit', listener) }) it('releases a terminal after top-level exit reaches quiescence', async () => { @@ -549,21 +567,22 @@ describe('LocalSubprocessRuntime', () => { }) it('reports the platform-specific reason for every fallback mode', async () => { - for (const [platform, kind, reason] of [ - ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner'], - ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable'], - ['win32', 'ordinary', 'the Win32 Job runner is unavailable'], - ['win32', 'terminal', 'Windows ConPTY remains outside Job containment'], - ['freebsd', 'ordinary', 'platform freebsd has no native managed range'], + for (const [platform, kind, reason, selectedReason] of [ + ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner', undefined], + ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable', undefined], + ['linux', 'ordinary', 'the private Linux subprocess runner is unavailable', 'the private Linux subprocess runner is unavailable'], + ['win32', 'ordinary', 'the Win32 Job runner is unavailable', undefined], + ['win32', 'terminal', 'Windows ConPTY remains outside Job containment', undefined], + ['freebsd', 'ordinary', 'platform freebsd has no native managed range', undefined], ] as const) { const ctx = new Context() const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const fiber = await ctx.plugin(LocalSubprocessRuntime) const runtime = ctx.subprocess as unknown as { - warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal'): void + warnFallback(platform: NodeJS.Platform, kind: 'ordinary' | 'terminal', selectedReason?: string): void } try { - runtime.warnFallback(platform, kind) + runtime.warnFallback(platform, kind, selectedReason) expect(warning).toHaveBeenLastCalledWith( expect.stringContaining(reason), ) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index a35f847d7e..083ffdbefa 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -180,23 +180,27 @@ describe('managed process binding', () => { } }) - it('contains background range-observation rejection until waitForExit observes it', async () => { + it('retries after a background range-observation rejection', async () => { const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'pipe'], }) const failure = new Error('range observation failed') + const waitForExit = vi.fn() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined) const handle = bindManagedProcess(spec(), { stdin: wrapper.stdin, stdout: wrapper.stdout, stderr: wrapper.stderr, pid: wrapper.pid, direct: new Promise(() => {}), - owner: { signal: vi.fn(), waitForExit: async () => { throw failure } }, + owner: { signal: vi.fn(), waitForExit }, }) try { handle.terminate() await new Promise(resolve => setImmediate(resolve)) - await expect(handle.waitForExit()).rejects.toBe(failure) + await expect(handle.waitForExit()).resolves.toBe(true) + expect(waitForExit).toHaveBeenCalledTimes(2) } finally { wrapper.kill('SIGKILL') } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index d9819b87ff..b19af88b6b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { cleanupRunnerFiles, createRunnerFiles, readRunnerEvents } from '../src/runner-protocol.ts' +import { cleanupRunnerFiles, createRunnerFiles, readRunnerEventsAsync } from '../src/runner-protocol.ts' const builtEntry = fileURLToPath(new URL( './lib/spawn-runner.js', @@ -11,7 +11,7 @@ const builtEntry = fileURLToPath(new URL( const required = process.env.DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER === '1' describe.skipIf(!existsSync(builtEntry) && !required)('built subprocess runner entry', () => { - it('reports the direct target outcome through the built private entry', () => { + it('reports the direct target outcome through the built private entry', async () => { if (!existsSync(builtEntry)) throw new Error(`required built subprocess runner is missing: ${builtEntry}`) const files = createRunnerFiles({ argv: [process.execPath, '-e', 'process.exit(11)'], @@ -29,7 +29,7 @@ describe.skipIf(!existsSync(builtEntry) && !required)('built subprocess runner e files.eventsPath, ], { encoding: 'utf8', timeout: 10_000 }) expect(result.error).toBeUndefined() - const events = readRunnerEvents(files.eventsPath) + const events = await readRunnerEventsAsync(files.eventsPath) expect(events).toHaveLength(2) expect(events[0]?.type).toBe('started') if (events[0]?.type !== 'started') throw new Error('expected started event') diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 6369357da7..a65bcbed81 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -23,7 +23,6 @@ import { consumeRunnerRequest, createRunnerFiles, deserializeSpawnError, - readRunnerEvents, readRunnerEventsAsync, serializeSpawnError, } from '../src/runner-protocol.ts' @@ -35,6 +34,7 @@ const sourceInvocation = [ 'tsx/esm', fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')), ] + function spec(overrides: Partial = {}): SubprocessSpawnSpec { return { argv: [process.execPath, '-e', ''], @@ -185,7 +185,7 @@ describe('spawn runner transport', () => { '--events', files.eventsPath, ], asRunnerHost(host)) expect(host.exitCode).toBe(12) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ expect.objectContaining({ type: 'started' }), { type: 'exit', exitCode: 12, signal: null }, ]) @@ -208,7 +208,7 @@ describe('spawn runner transport', () => { '--events', files.eventsPath, ], asRunnerHost(host)) expect(host.exitCode).toBe(127) - const [event] = readRunnerEvents(files.eventsPath) + const [event] = await readRunnerEventsAsync(files.eventsPath) expect(event?.type).toBe('spawn-error') if (event?.type !== 'spawn-error') throw new Error('expected spawn error') expect(event.error.code).toBe('ENOENT') @@ -244,7 +244,7 @@ describe('spawn runner transport', () => { detached: true, }) expect(host.exitCode).toBe(127) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 4321 }, { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } }, ]) @@ -272,7 +272,7 @@ describe('spawn runner transport', () => { '--events', files.eventsPath, ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) expect(host.exitCode).toBe(1) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 4321 }, { type: 'exit', exitCode: null, signal: 'SIGTERM' }, ]) @@ -390,7 +390,7 @@ describe('spawn runner transport', () => { ) expect(pollProcessExit).toHaveBeenCalledTimes(2) expect(isJobEmpty).toHaveBeenCalledTimes(2) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'exit', exitCode: 42, signal: null }, ]) @@ -433,7 +433,7 @@ describe('spawn runner transport', () => { expect(internals.terminateJob).toHaveBeenCalledOnce() expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1) expect(host.disconnect).not.toHaveBeenCalled() - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'exit', exitCode: 0, signal: null }, ]) @@ -460,7 +460,7 @@ describe('spawn runner transport', () => { expect(host.exitCode).toBe(127) expect(host.disconnect).toHaveBeenCalledOnce() - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } }, ]) @@ -505,7 +505,7 @@ describe('spawn runner transport', () => { internals, ) expect(host.exitCode).toBeUndefined() - const [event] = readRunnerEvents(files.eventsPath) + const [event] = await readRunnerEventsAsync(files.eventsPath) expect(event?.type).toBe('spawn-error') if (event?.type !== 'spawn-error') throw new Error('expected spawn error') expect(event.error).toMatchObject({ @@ -545,7 +545,7 @@ describe('spawn runner transport', () => { ) expect(chdir).toHaveBeenCalledTimes(2) expect(host.exitCode).toBeUndefined() - const [event] = readRunnerEvents(files.eventsPath) + const [event] = await readRunnerEventsAsync(files.eventsPath) expect(event?.type).toBe('spawn-error') if (event?.type !== 'spawn-error') throw new Error('expected spawn error') expect(event.error).toMatchObject({ @@ -578,7 +578,7 @@ describe('spawn runner transport', () => { fakeRunnerInternals(), ) expect(host.exitCode).toBeUndefined() - const [event] = readRunnerEvents(files.eventsPath) + const [event] = await readRunnerEventsAsync(files.eventsPath) expect(event?.type).toBe('spawn-error') if (event?.type !== 'spawn-error') throw new Error('expected spawn error') expect(typeof event.error.message).toBe('string') @@ -612,7 +612,7 @@ describe('spawn runner transport', () => { '--stdin-pipe', '\\\\.\\pipe\\stdin', ]), asRunnerHost(host), internals) expect(host.exitCode).toBe(127) - const [event] = readRunnerEvents(files.eventsPath) + const [event] = await readRunnerEventsAsync(files.eventsPath) expect(event?.type).toBe('runner-error') if (event?.type !== 'runner-error') throw new Error('expected runner error') expect(event.error.name).toBe(name) @@ -638,7 +638,7 @@ describe('spawn runner transport', () => { await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ '--stdin-pipe', '\\\\.\\pipe\\stdin', ]), asRunnerHost(new FakeRunnerHost()), internals) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'runner-error', @@ -675,7 +675,7 @@ describe('spawn runner transport', () => { '--stdin-pipe', '\\\\.\\pipe\\stdin', '--stdout-pipe', '\\\\.\\pipe\\stdout', ]), asRunnerHost(new FakeRunnerHost()), internals) - expect(readRunnerEvents(files.eventsPath)).toContainEqual({ + expect(await readRunnerEventsAsync(files.eventsPath)).toContainEqual({ type: 'runner-error', error: { name: 'Error', message: 'first close failure' }, }) @@ -720,7 +720,7 @@ describe('spawn runner transport', () => { await vi.advanceTimersByTimeAsync(10) await running - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }], { type: 'runner-error', error: { name: 'Error', message } }, @@ -755,7 +755,7 @@ describe('spawn runner transport', () => { await vi.advanceTimersByTimeAsync(10) await running - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'exit', exitCode: 0, signal: null }, { @@ -786,7 +786,7 @@ describe('spawn runner transport', () => { ) expect(chdir).toHaveBeenCalledTimes(2) expect(host.exitCode).toBe(127) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'started', pid: 1234 }, { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } }, ]) @@ -809,7 +809,7 @@ describe('spawn runner transport', () => { internals, )).rejects.toThrow('binding setup failed') expect(host.disconnect).toHaveBeenCalledOnce() - expect(readRunnerEvents(files.eventsPath)).toEqual([]) + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([]) } finally { cleanupRunnerFiles(files) } @@ -824,7 +824,7 @@ describe('spawn runner transport', () => { await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message) }) - it('reports only failures whose arguments identify an event transport', () => { + it('reports only failures whose arguments identify an event transport', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { reportSpawnRunnerFailure([ @@ -834,7 +834,7 @@ describe('spawn runner transport', () => { ], new Error('runner main failed')) reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure')) reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure')) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } }, ]) } finally { @@ -917,7 +917,7 @@ describe('spawn runner transport', () => { it('reads only complete known event records and propagates file errors', async () => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { - expect(readRunnerEvents(files.eventsPath)).toEqual([]) + await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([]) await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([]) appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 }) appendRunnerEvent(files.eventsPath, { @@ -937,7 +937,7 @@ describe('spawn runner transport', () => { spawnargs: ['argument'], }, }) - expect(readRunnerEvents(files.eventsPath)).toEqual([ + await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([ { type: 'started', pid: 123 }, { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } }, { type: 'exit', exitCode: null, signal: 'SIGTERM' }, @@ -954,18 +954,14 @@ describe('spawn runner transport', () => { }, }, ]) - await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual(readRunnerEvents(files.eventsPath)) - writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"') - expect(readRunnerEvents(files.eventsPath)).toEqual([{ type: 'started', pid: 123 }]) + await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([{ type: 'started', pid: 123 }]) for (const event of [null, []]) { writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) - expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event') + await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event') } writeFileSync(files.eventsPath, '{"type":"unknown"}\n') - expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted unknown event') await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event') - expect(() => readRunnerEvents(files.directory)).toThrow() await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow() } finally { cleanupRunnerFiles(files) @@ -992,11 +988,11 @@ describe('spawn runner transport', () => { ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }], ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }], ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }], - ])('rejects an invalid event payload: %s', (_label, event) => { + ])('rejects an invalid event payload: %s', async (_label, event) => { const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) try { writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) - expect(() => readRunnerEvents(files.eventsPath)).toThrow('emitted invalid event') + await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event') } finally { cleanupRunnerFiles(files) } @@ -1110,7 +1106,7 @@ describe('spawn runner transport', () => { readRunnerEventsAsync: vi.fn(async (eventsPath: string) => { readCount += 1 if (readCount === 1) return staleRead.promise - return actual.readRunnerEvents(eventsPath) + return actual.readRunnerEventsAsync(eventsPath) }), } }) @@ -1192,7 +1188,7 @@ describe('spawn runner transport', () => { expect(existsSync(files.directory)).toBe(false) }) - it('reports the direct target pid and exit outcome from the source entry', () => { + it('reports the direct target pid and exit outcome from the source entry', async () => { const files = createRunnerFiles({ argv: [process.execPath, '-e', 'process.exit(7)'], cwd: process.cwd(), @@ -1201,7 +1197,7 @@ describe('spawn runner transport', () => { try { const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) expect(result.error).toBeUndefined() - const events = readRunnerEvents(files.eventsPath) + const events = await readRunnerEventsAsync(files.eventsPath) expect(events).toHaveLength(2) expect(events[0]?.type).toBe('started') if (events[0]?.type !== 'started') throw new Error('expected started event') @@ -1236,7 +1232,7 @@ describe('spawn runner transport', () => { } }) - it('reports target spawn failure without executing a fallback command', () => { + it('reports target spawn failure without executing a fallback command', async () => { const files = createRunnerFiles({ argv: [`missing-dsh-runner-${Date.now()}`], cwd: process.cwd(), @@ -1245,7 +1241,7 @@ describe('spawn runner transport', () => { try { const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) expect(result.error).toBeUndefined() - const events = readRunnerEvents(files.eventsPath) + const events = await readRunnerEventsAsync(files.eventsPath) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } }) } finally { diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 5d9bbedbca..40b1a7f211 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -144,18 +144,42 @@ describe('LocalTerminalHandle', () => { expect(signals).toEqual(['SIGTERM', 'SIGKILL']) }) - it('force-kills a managed range when observation rejects and preserves that failure', async () => { + it('force-kills and retries a managed range when observation first rejects', async () => { const pty = new FakePty() const failure = new Error('scope became unreadable') const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const waitForExit = vi.fn() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined) const owner: BoundProcessOwner = { signal: (signal) => { signals.push(signal) }, - waitForExit: async () => { throw failure }, + waitForExit, } const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) await expect(handle.terminate()).rejects.toBe(failure) expect(signals).toEqual(['SIGTERM', 'SIGKILL']) + expect(waitForExit).toHaveBeenCalledTimes(2) + }) + + it('preserves both failed managed-range observations after force-kill', async () => { + const pty = new FakePty() + const firstFailure = new Error('scope became unreadable') + const finalFailure = new Error('scope stayed unreadable') + const signals: Array<'SIGTERM' | 'SIGKILL'> = [] + const owner: BoundProcessOwner = { + signal: (signal) => { signals.push(signal) }, + waitForExit: vi.fn() + .mockRejectedValueOnce(firstFailure) + .mockRejectedValueOnce(finalFailure), + } + const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) + + await expect(handle.terminate()).rejects.toMatchObject({ + errors: [firstFailure, finalFailure], + message: 'terminal managed-range cleanup failed', + }) + expect(signals).toEqual(['SIGTERM', 'SIGKILL']) }) it('routes managed terminal host exit directly to KILL', () => { diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index ed68088e09..b5e000f5d7 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -81,7 +81,6 @@ describe('Typert-backed Cordis catalog', () => { } } const runtimeApi = projector.renderRuntimeApi(model) - expect(runtimeApi).toContain('const pattern = new RegExp(`\\\\b${entry.name}\\\\b`)') expect(runtimeApi).toBe( expected('packages/extensions/tool-cordis/src/api-catalog.ts'), ) From 07408ff0f127baddfc2236100ec17c68d15c0e91 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 26 Aug 2026 23:43:15 +0800 Subject: [PATCH 068/110] fix(subprocess): preserve failed teardown state --- ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 4 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 4 +- packages/lsp/lsp-stdio/src/index.ts | 40 ++++--- packages/lsp/lsp-stdio/src/instance.ts | 40 ++----- packages/lsp/lsp-stdio/tests/instance.spec.ts | 111 +----------------- .../lsp/lsp-stdio/tests/lifecycle.spec.ts | 95 +++++++++++++++ .../subprocess/subprocess-local/src/index.ts | 7 +- .../subprocess/subprocess-local/src/spawn.ts | 5 +- .../tests/managed-spawn.spec.ts | 46 +++++--- .../tests/process-exit.spec.ts | 9 +- 11 files changed, 184 insertions(+), 181 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 759d994677..2d351c09e4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 086bb2f3763af513b07e0c20910ef80116695ca4 -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 524fa6b0d4e4f5012c44e108fd0cecc5d7012802 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 1a6664c03ce0ae90b94d210645ac1401e078fae0 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 4c08847b24422d58025b6563785579d84c3ba6a7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 086bb2f376..1a6664c03c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -12,7 +12,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d ## Decision -`LocalSubprocessRuntime` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener. +`LocalSubprocessRuntime` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal succeeds. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. Disposal releases each successfully stopped target individually; failed targets and the listener remain owned so a later host exit can invoke the same synchronous final operations. The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: @@ -46,6 +46,6 @@ Unit evidence pins synchronous native-owner and fallback delivery, native termin ## Consequences -Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. +Each active local subprocess service contributes one process-global exit listener. Successful disposal removes it with the service effect; failed disposal retains it with the targets that still require final termination. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. The listener cannot cover failures that do not execute JavaScript. Supported Linux terminals signal the scope described by the [containment decision](2026-08-20-subprocess-native-containment.md); fallback terminals still cannot discover a descendant that escaped before the provider observed it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 524fa6b0d4..4c08847b24 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`LocalSubprocessRuntime`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。 +`LocalSubprocessRuntime`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose成功后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。dispose会逐个释放已经成功停稳的目标;失败的目标与 listener继续由服务拥有,使后续宿主退出仍能调用同一组同步最终操作。 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: @@ -46,6 +46,6 @@ Status: implemented ## Consequences -每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 +每个有效的本地 subprocess service都会贡献一个进程全局 exit listener。成功的 dispose会随服务 effect移除它;失败的 dispose会让它与仍需最终终止的目标一起保留。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 listener 无法覆盖不执行 JavaScript 的故障。受支持的 Linux terminal 会向[containment decision](2026-08-20-subprocess-native-containment.zh.md)所述的 scope 发出信号;fallback terminal 仍无法发现 provider 首次观察前已经逃逸的后代。 diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index ebc54702da..bfe1974e38 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -281,23 +281,35 @@ class LocalLspProvider implements LspProvider { // synchronous get-or-create so every spawned process remains owned by teardown. this.assertActive(querySignal) let instance = this.instanceFor(workspaceKey, workspace) - try { - return await instance.query(request, source, querySignal) - } catch (error) { - // A selected child can have died while idle or fail during the next write. Queries are - // read-only, so replace that transport once and retry transparently. - if (!instance.isTransportFailure(error)) throw error - await instance.dispose() - this.evictIfCurrent(workspaceKey, instance) - this.assertActive(querySignal) - instance = this.instanceFor(workspaceKey, workspace) - return await instance.query(request, source, querySignal) - } finally { - // Reach quiescence before dropping a dead slot; a replacement must survive this ownership check. + let canRetryTransport = true + for (;;) { + const [queryOutcome] = await Promise.allSettled([ + instance.query(request, source, querySignal), + ]) + let teardownOutcome: PromiseSettledResult | undefined if (instance.dead) { - await instance.dispose() + ;[teardownOutcome] = await Promise.allSettled([instance.dispose()]) + // A dead instance is never reusable, even when its final quiescence observation fails. this.evictIfCurrent(workspaceKey, instance) } + if (teardownOutcome?.status === 'rejected') { + if (queryOutcome.status === 'rejected') { + throw new AggregateError( + [queryOutcome.reason, teardownOutcome.reason], + 'LSP operation and teardown failed', + ) + } + throw teardownOutcome.reason + } + if (queryOutcome.status === 'fulfilled') return queryOutcome.value + // A selected child can have died while idle or fail during the next write. Queries are + // read-only, so replace that transport once and retry transparently after clean disposal. + if (!canRetryTransport || !instance.isTransportFailure(queryOutcome.reason)) { + throw queryOutcome.reason + } + canRetryTransport = false + this.assertActive(querySignal) + instance = this.instanceFor(workspaceKey, workspace) } }) } diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index d9ff83279f..84053df118 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -97,7 +97,7 @@ export class LspInstance { const run = abortable(this.queue, signal) .then(() => this.runQuery(request, source, signal)) .catch(async (error: unknown) => { - if (this.isTransportFailure(error)) await this.throwAfterTeardown(error) + if (this.isTransportFailure(error)) await this.awaitTeardownAttempt() throw error }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The @@ -136,7 +136,7 @@ export class LspInstance { await abortable(this.ready, signal) } catch (error) { if (!this.dead) { - await this.throwAfterTeardown(error) + await this.awaitTeardownAttempt() } throw error } @@ -152,8 +152,6 @@ export class LspInstance { const uri = source.fileUrl let opened = false - let queryFailed = false - let queryFailure: unknown try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) @@ -164,15 +162,12 @@ export class LspInstance { } catch (error) { // A canceled backpressured write or failed stdin leaves the protocol stream unusable before // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. - await this.throwAfterTeardown(error) + await this.awaitTeardownAttempt() + throw error } opened = true const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) - } catch (error: unknown) { - queryFailed = true - queryFailure = error - throw error } finally { // A disposed or closed instance (e.g. an aborted request whose server ignored // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let @@ -180,20 +175,10 @@ export class LspInstance { if (opened && !this.dead) { try { await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) - } catch (closeError: unknown) { + } catch (_closeFailure: unknown) { // A close-write failure does not replace the settled result/error, but the instance can no - // longer be trusted: invalidate it and await bounded process termination. If teardown also - // fails, every failure remains visible in operation, close, cleanup order. - try { - await this.startTeardown() - } catch (teardownError: unknown) { - throw new AggregateError( - queryFailed - ? [queryFailure, closeError, teardownError] - : [closeError, teardownError], - 'LSP query cleanup failed', - ) - } + // longer be trusted. The provider re-awaits this teardown and owns failure reporting. + await this.awaitTeardownAttempt() } } } @@ -242,7 +227,7 @@ export class LspInstance { grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) }), ]) - if (!settled) await this.throwAfterTeardown(error) + if (!settled) await this.awaitTeardownAttempt() } finally { grace[Symbol.dispose]() } @@ -293,14 +278,13 @@ export class LspInstance { return this.teardownPromise } - /** Preserve an operation failure when teardown also fails. */ - private async throwAfterTeardown(error: unknown): Promise { + /** Await teardown while leaving its memoized failure for provider-level finalization. */ + private async awaitTeardownAttempt(): Promise { try { await this.startTeardown() - } catch (teardownError: unknown) { - throw new AggregateError([error, teardownError], 'LSP operation and teardown failed') + } catch (_teardownFailure: unknown) { + // LocalLspProvider re-awaits the same teardown and combines it with the query outcome. } - throw error } private async tearDown(): Promise { diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index bb4fc15f85..ffdd3c74cf 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { readFileSync } from 'node:fs' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -97,24 +97,6 @@ function scriptInstance(script: string, overrides: Partial = {}): return instance } -interface TestConnection { - readonly closed: Promise - waitForProcessTreeExit(signal?: AbortSignal): Promise -} - -/** Make the instance's final managed-range observation fail deterministically. */ -function rejectProcessTreeWait(instance: LspInstance, failure: Error): TestConnection { - const connection = (instance as unknown as { connection: TestConnection }).connection - vi.spyOn(connection, 'waitForProcessTreeExit').mockRejectedValue(failure) - return connection -} - -/** A failed teardown cannot be disposed again; await process close and remove it from afterEach. */ -async function releaseFailedInstance(instance: LspInstance, connection: TestConnection): Promise { - live = live.filter(candidate => candidate !== instance) - await connection.closed -} - /** An inline server that answers initialize + definition and echoes a location. */ const RESPONDING_SERVER = 'let b=Buffer.alloc(0);' @@ -262,52 +244,6 @@ describe('LspInstance query and abort', () => { expect(processAlive(pid)).toBe(false) }) - it('preserves a request write failure with a managed-range teardown failure', async () => { - const operationFailure = new Error('fixture textDocument/definition failure') - const teardownFailure = new Error('managed range observation failed') - const instance = makeInstance({}, { - shutdownTimeoutMs: 100, - killGraceMs: 100, - }, failingWriter('textDocument/definition', operationFailure)) - const connection = rejectProcessTreeWait(instance, teardownFailure) - try { - const failure = await run(instance, 'goToDefinition').then( - () => undefined, - (error: unknown) => error, - ) - expect(failure).toMatchObject({ - errors: [operationFailure, teardownFailure], - message: 'LSP operation and teardown failed', - }) - } finally { - await releaseFailedInstance(instance, connection) - } - }) - - it('preserves an initialization failure with a managed-range teardown failure', async () => { - const teardownFailure = new Error('managed range observation failed') - const instance = makeInstance({ LSP_FAKE_ENCODING: 'utf-8' }, { - shutdownTimeoutMs: 100, - killGraceMs: 100, - }) - const connection = rejectProcessTreeWait(instance, teardownFailure) - try { - const failure = await run(instance, 'goToDefinition').then( - () => undefined, - (error: unknown) => error, - ) - expect(failure).toBeInstanceOf(AggregateError) - const errors = (failure as AggregateError).errors as unknown[] - expect(errors).toHaveLength(2) - expect(errors[0]).toBeInstanceOf(Error) - expect((errors[0] as Error).message).toContain('unsupported position encoding') - expect(errors[1]).toBe(teardownFailure) - expect((failure as AggregateError).message).toBe('LSP operation and teardown failed') - } finally { - await releaseFailedInstance(instance, connection) - } - }) - it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) @@ -333,51 +269,6 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) - it('reports didClose and teardown failures after a settled result', async () => { - const closeFailure = new Error('fixture textDocument/didClose failure') - const teardownFailure = new Error('managed range observation failed') - const instance = makeInstance({ - LSP_FAKE_DEF: 'null', - }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose', closeFailure)) - const connection = rejectProcessTreeWait(instance, teardownFailure) - try { - const failure = await run(instance, 'goToDefinition').then( - () => undefined, - (error: unknown) => error, - ) - expect(failure).toMatchObject({ - errors: [closeFailure, teardownFailure], - message: 'LSP query cleanup failed', - }) - } finally { - await releaseFailedInstance(instance, connection) - } - }) - - it('reports query, didClose, and teardown failures in lifecycle order', async () => { - const closeFailure = new Error('fixture textDocument/didClose failure') - const teardownFailure = new Error('managed range observation failed') - const instance = makeInstance({ - LSP_FAKE_ERROR: '1', - }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose', closeFailure)) - const connection = rejectProcessTreeWait(instance, teardownFailure) - try { - const failure = await run(instance, 'goToDefinition').then( - () => undefined, - (error: unknown) => error, - ) - expect(failure).toBeInstanceOf(AggregateError) - const errors = (failure as AggregateError).errors as unknown[] - expect(errors).toHaveLength(3) - expect(errors[0]).toBeInstanceOf(Error) - expect((errors[0] as Error).message).toContain('server refused') - expect(errors[1]).toBe(closeFailure) - expect(errors[2]).toBe(teardownFailure) - expect((failure as AggregateError).message).toBe('LSP query cleanup failed') - } finally { - await releaseFailedInstance(instance, connection) - } - }) }) describe('LspInstance disposal', () => { diff --git a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts index 55725022f4..e368e2d952 100644 --- a/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-stdio/tests/lifecycle.spec.ts @@ -11,6 +11,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio' +import { LspConnection } from '../src/connection.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -44,10 +45,12 @@ async function mount( fakeEnv: Record = {}, overrides: Partial = {}, captureProvider?: (provider: LspProvider) => void, + configureSubprocess?: (ctx: Context) => void, ): Promise { const ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LocalSubprocessRuntime) + configureSubprocess?.(ctx) await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) const register = ctx.lsp.registerProvider.bind(ctx.lsp) const registrationSpy = captureProvider === undefined @@ -165,6 +168,98 @@ describe('lsp-stdio end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('preserves a query failure with final disposal failure and evicts the instance', async () => { + const teardownFailure = new Error('managed range observation failed') + let provider: LspProvider | undefined + let firstSpawn = true + let restoreFirstWait: (() => void) | undefined + const ctx = await mount( + { LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }, + { shutdownTimeoutMs: 100, killGraceMs: 100 }, + (registered) => { provider = registered }, + (mounted) => { + const spawn = mounted.subprocess.spawn.bind(mounted.subprocess) + vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + if (!firstSpawn) return handle + firstSpawn = false + const waitForExit = handle.waitForExit.bind(handle) + const waitSpy = vi.spyOn(handle, 'waitForExit') + .mockImplementation(async (signal) => { + await waitForExit(signal) + throw teardownFailure + }) + restoreFirstWait = () => { waitSpy.mockRestore() } + return handle + }) + }, + ) + const failure = await ctx.lsp.query(query('goToDefinition')).then( + () => undefined, + (error: unknown) => error, + ) + expect(failure).toBeInstanceOf(AggregateError) + const errors = (failure as AggregateError).errors as unknown[] + expect(errors).toHaveLength(2) + expect(errors[0]).toBeInstanceOf(Error) + expect((errors[0] as Error).message).toContain('unsupported position encoding') + expect(errors[1]).toBe(teardownFailure) + expect((failure as AggregateError).message).toBe('LSP operation and teardown failed') + restoreFirstWait?.() + if (provider === undefined) throw new Error('expected lsp-stdio to register a provider') + const instances = (provider as unknown as { readonly instances: ReadonlyMap }).instances + expect(instances.size).toBe(0) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + expect(instances.size).toBe(0) + await ctx.fiber.dispose() + }) + + it('reports final disposal failure after a settled query and evicts the instance', async () => { + const closeFailure = new Error('fixture textDocument/didClose failure') + const teardownFailure = new Error('managed range observation failed') + const notify = Object.getOwnPropertyDescriptor(LspConnection.prototype, 'notify')?.value as LspConnection['notify'] + const notifySpy = vi.spyOn(LspConnection.prototype, 'notify').mockImplementation(function (this: LspConnection, method, params) { + if (method === 'textDocument/didClose') return Promise.reject(closeFailure) + return notify.call(this, method, params) + }) + let provider: LspProvider | undefined + let restoreFirstWait: (() => void) | undefined + const ctx = await mount( + { LSP_FAKE_DEF: 'null' }, + { shutdownTimeoutMs: 100, killGraceMs: 100 }, + (registered) => { provider = registered }, + (mounted) => { + const spawn = mounted.subprocess.spawn.bind(mounted.subprocess) + let firstSpawn = true + vi.spyOn(mounted.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + if (!firstSpawn) return handle + firstSpawn = false + const waitForExit = handle.waitForExit.bind(handle) + const waitSpy = vi.spyOn(handle, 'waitForExit').mockImplementation(async (signal) => { + await waitForExit(signal) + throw teardownFailure + }) + restoreFirstWait = () => { waitSpy.mockRestore() } + return handle + }) + }, + ) + try { + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toBe(teardownFailure) + if (provider === undefined) throw new Error('expected lsp-stdio to register a provider') + const instances = (provider as unknown as { readonly instances: ReadonlyMap }).instances + expect(instances.size).toBe(0) + restoreFirstWait?.() + notifySpy.mockRestore() + await expect(ctx.lsp.query(query('goToDefinition'))).resolves.toMatchObject({ kind: 'locations' }) + } finally { + restoreFirstWait?.() + notifySpy.mockRestore() + await ctx.fiber.dispose() + } + }) + it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index df86978816..5d3116a436 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -114,9 +114,10 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) })) } const outcomes = await Promise.allSettled(pending) - const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' - ? [outcome.reason as unknown] - : []) + const failures: unknown[] = [] + for (const outcome of outcomes) { + if (outcome.status === 'rejected') failures.push(outcome.reason) + } if (failures.length > 0) this.terminateForHostExit() if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 48f83455e0..62ed6d24f2 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -507,7 +507,10 @@ export function bindManagedProcess( // leaking an unhandled rejection when a caller only invokes terminate(). void observeRangeExit().catch(() => {}) kill('SIGTERM') - graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) + graceTimer = setTimeout(() => { + graceTimer = undefined + kill('SIGKILL') + }, spec.graceMs) } const terminateForHostExit = (): void => { diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts index 083ffdbefa..ed5e299f44 100644 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts @@ -180,29 +180,45 @@ describe('managed process binding', () => { } }) - it('retries after a background range-observation rejection', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) + it('retries termination after an expired escalation and range-observation rejection', async () => { + vi.useFakeTimers() const failure = new Error('range observation failed') + const firstObservation = Promise.withResolvers() + const secondObservation = Promise.withResolvers() const waitForExit = vi.fn() - .mockRejectedValueOnce(failure) - .mockResolvedValue(undefined) - const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: wrapper.pid, + .mockImplementationOnce(() => firstObservation.promise) + .mockImplementationOnce(() => secondObservation.promise) + const signal = vi.fn() + const handle = bindManagedProcess({ + ...spec(), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }, { + stdin: null, + stdout: null, + stderr: null, + pid: 4242, direct: new Promise(() => {}), - owner: { signal: vi.fn(), waitForExit }, + owner: { signal, waitForExit }, }) try { handle.terminate() - await new Promise(resolve => setImmediate(resolve)) - await expect(handle.waitForExit()).resolves.toBe(true) + const firstWait = handle.waitForExit() + expect(signal.mock.calls).toEqual([['SIGTERM']]) + await vi.advanceTimersByTimeAsync(30) + expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) + firstObservation.reject(failure) + await expect(firstWait).rejects.toBe(failure) + + handle.terminate() + const secondWait = handle.waitForExit() + expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']]) + await vi.advanceTimersByTimeAsync(30) + expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM'], ['SIGKILL']]) + secondObservation.resolve(undefined) + await expect(secondWait).resolves.toBe(true) expect(waitForExit).toHaveBeenCalledTimes(2) } finally { - wrapper.kill('SIGKILL') + vi.useRealTimers() } }) diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index cfea99f12a..18885ce5de 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -15,7 +15,8 @@ interface TreeState { root: number; descendant: number } const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url)) -const scenarioTimeoutMs = 30_000 +const scenarioTimeoutMs = process.platform === 'win32' ? 60_000 : 30_000 +const testTimeoutMs = scenarioTimeoutMs + 15_000 function processExists(pid: number): boolean { try { @@ -141,7 +142,7 @@ describe('synchronous cleanup on host exit', () => { { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined }, { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' }, { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' }, - ])('removes an ordinary managed tree after $trigger', { timeout: 45_000 }, async ({ + ])('removes an ordinary managed tree after $trigger', { timeout: testTimeoutMs }, async ({ trigger, expectedCode, diagnostic, @@ -154,7 +155,7 @@ describe('synchronous cleanup on host exit', () => { it.skipIf(process.platform === 'win32')( 'removes a terminal root and descendant after direct exit', - { timeout: 45_000 }, + { timeout: testTimeoutMs }, async () => { const { outcome } = await runScenario('terminal', 'direct') expect(outcome.exitCode).toBe(23) @@ -162,7 +163,7 @@ describe('synchronous cleanup on host exit', () => { }, ) - it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: 45_000 }, async () => { + it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: testTimeoutMs }, async () => { const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') expect(outcome.exitCode).toBe(0) expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) From 81257defd2f014489c09f7aa5e3680a3b497fe96 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 27 Aug 2026 00:41:59 +0800 Subject: [PATCH 069/110] fix(subprocess): preserve clean Windows runner close --- .../subprocess-local/src/windows-job.ts | 15 +++--- .../tests/windows-job.spec.ts | 50 +++++++++++++++---- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 7a1c96b8f1..715581cb11 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -70,16 +70,15 @@ class WindowsJobOwner implements BoundProcessOwner { signal(_signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped || this.runnerClosed || this.runner.pid === undefined) return + // The runner handles an IPC disconnect as termination, and only disconnects + // itself after proving the Job empty. Wait for its close instead of killing it. + if (!this.runner.connected) return try { - if (this.runner.connected) { - this.runner.send({ type: 'terminate' }, (error) => { - if (error !== null) this.runner.kill() - }) - } else { - this.runner.kill() - } + this.runner.send({ type: 'terminate' }, (error) => { + if (error !== null && this.runner.connected) this.runner.kill() + }) } catch { - this.runner.kill() + if (this.runner.connected) this.runner.kill() } } diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 850f0daf24..68850d04c4 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -127,18 +127,44 @@ describe('Windows Job runner adapter', () => { await expect(launch.owner.waitForExit()).resolves.toBeUndefined() }) - it('falls back to killing the runner when IPC delivery is unavailable or fails', async () => { - for (const mode of ['callback-error', 'disconnected', 'throw'] as const) { + it('waits for a runner that disconnects before its clean close', async () => { + const child = new EventEmitter() as ChildProcess + const kill = vi.fn(() => true) + const send = vi.fn() + Object.assign(child, { pid: 321, connected: false, kill, send }) + let eventsPath = '' + const run = vi.fn((_command: string, args: readonly string[]) => { + eventsPath = args[args.indexOf('--events') + 1] as string + appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) + return child + }) as unknown as typeof spawn + const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) + + launch.owner.signal('SIGTERM') + expect(send).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + + appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) + child.emit('close', 0, null) + await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + }) + + it('kills the runner only when IPC delivery fails while still connected', async () => { + for (const mode of ['callback-error', 'callback-disconnect', 'throw', 'throw-disconnect'] as const) { const child = new EventEmitter() as ChildProcess const kill = vi.fn(() => true) const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { - if (mode === 'throw') throw new Error('send threw') - callback(mode === 'callback-error' ? new Error('send failed') : null) + if (mode === 'callback-disconnect' || mode === 'throw-disconnect') { + Object.assign(child, { connected: false }) + } + if (mode === 'throw' || mode === 'throw-disconnect') throw new Error('send threw') + callback(new Error('send failed')) return true }) Object.assign(child, { pid: 321, - connected: mode !== 'disconnected', + connected: true, kill, send, }) @@ -151,15 +177,17 @@ describe('Windows Job runner adapter', () => { const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) launch.owner.signal('SIGTERM') - if (mode === 'callback-error' || mode === 'throw' || mode === 'disconnected') { - expect(kill).toHaveBeenCalledOnce() - } - if (mode === 'disconnected') expect(send).not.toHaveBeenCalled() + const shouldKill = mode === 'callback-error' || mode === 'throw' + expect(kill).toHaveBeenCalledTimes(shouldKill ? 1 : 0) appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', null, 'SIGTERM') + child.emit('close', shouldKill ? null : 0, shouldKill ? 'SIGTERM' : null) await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') + if (shouldKill) { + await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') + } else { + await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + } const sends = send.mock.calls.length const kills = kill.mock.calls.length launch.owner.signal('SIGKILL') From 1250b41054fb7916f076e22885fcb1386c40d76d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 27 Aug 2026 00:57:21 +0800 Subject: [PATCH 070/110] docs(subprocess): clarify Windows runner disconnect --- packages/subprocess/subprocess-local/src/windows-job.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 715581cb11..bcb726113b 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -70,8 +70,8 @@ class WindowsJobOwner implements BoundProcessOwner { signal(_signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped || this.runnerClosed || this.runner.pid === undefined) return - // The runner handles an IPC disconnect as termination, and only disconnects - // itself after proving the Job empty. Wait for its close instead of killing it. + // The runner handles an IPC disconnect as termination and disconnects itself + // after its Win32 cleanup path. Its close status reports whether the Job is empty. if (!this.runner.connected) return try { this.runner.send({ type: 'terminate' }, (error) => { From ef0815db7f33f9da6dddc75a249ad91c1bde5a8f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 27 Aug 2026 01:33:06 +0800 Subject: [PATCH 071/110] chore(subprocess): document disconnect recheck --- packages/subprocess/subprocess-local/src/windows-job.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index bcb726113b..965d035e22 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -78,6 +78,7 @@ class WindowsJobOwner implements BoundProcessOwner { if (error !== null && this.runner.connected) this.runner.kill() }) } catch { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- ChildProcess.send() may synchronously disconnect before throwing. if (this.runner.connected) this.runner.kill() } } From c302bedc3a1d313add3a3127b46438fcab6ec1ce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 27 Aug 2026 02:37:00 +0800 Subject: [PATCH 072/110] test(subprocess): defer fake runner disconnect --- .../subprocess-local/tests/fixtures/fake-job-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts index 2dd5451f03..88d9e282e3 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts @@ -21,7 +21,9 @@ if (Number.isSafeInteger(configuredExit)) { terminated = true appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) clearInterval(hold) - if (process.connected) process.disconnect() + setImmediate(() => { + if (process.connected) process.disconnect() + }) process.exitCode = 0 } process.on('message', (message: unknown) => { From a95f0b368f782e20b54df4e9880b589d9bc10ae1 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 18:15:49 +0800 Subject: [PATCH 073/110] fix(subprocess): contain escaped descendants with native owners --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.md | 4 +- .../2026-07-26-subprocess-seam.zh.md | 4 +- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 8 +- ...8-19-shared-win32-process-primitives.zh.md | 8 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- ...8-subprocess-native-containment.i18n.yaml} | 6 +- ...026-08-28-subprocess-native-containment.md | 80 + ...-08-28-subprocess-native-containment.zh.md | 80 + ...chronous-subprocess-exit-cleanup.i18n.yaml | 4 +- ...-11-synchronous-subprocess-exit-cleanup.md | 4 +- ...-synchronous-subprocess-exit-cleanup.zh.md | 4 +- ...026-08-20-subprocess-native-containment.md | 43 - ...-08-20-subprocess-native-containment.zh.md | 43 - ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 10 +- .../2026-07-16-persistent-pty-sessions.zh.md | 10 +- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +- ...2026-07-22-cross-platform-test-fixtures.md | 4 +- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 +- apps/cli/src/bin.ts | 59 +- apps/cli/src/runtime-bootstrap.ts | 17 + apps/cli/tsdown.config.ts | 10 +- apps/web/tests/hmr-live.e2e.ts | 2 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 12 +- docs/subsystems/subprocess.zh.md | 12 +- knip.json | 6 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 9 +- packages/e2b/subprocess-e2b/README.zh.md | 9 +- packages/e2b/subprocess-e2b/src/process.ts | 5 - .../subprocess-e2b/tests/subprocess.spec.ts | 24 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- packages/lsp/lsp-stdio/src/connection.ts | 5 - .../lsp/lsp-stdio/tests/connection.spec.ts | 33 +- packages/lsp/lsp-stdio/tests/instance.spec.ts | 6 +- .../shell/bash-sandbox/tests/sandbox.spec.ts | 1 - .../shell/pwsh-local/tests/executor.spec.ts | 1 - .../subagent-acp/tests/subagent-acp.spec.ts | 28 +- .../tests/subagent-claude-code.spec.ts | 10 +- .../tests/subagent-codex.spec.ts | 6 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 11 +- .../subprocess/subprocess-local/README.zh.md | 11 +- .../subprocess/subprocess-local/package.json | 8 +- .../subprocess/subprocess-local/src/bin.ts | 39 +- .../subprocess/subprocess-local/src/index.ts | 71 +- .../subprocess-local/src/linux-scope.ts | 374 ++-- .../subprocess-local/src/managed-owner.ts | 52 +- .../subprocess-local/src/runner-launch.ts | 245 +-- .../subprocess-local/src/runner-protocol.ts | 334 +-- .../subprocess-local/src/spawn-runner.ts | 663 +++--- .../subprocess/subprocess-local/src/spawn.ts | 51 +- .../subprocess-local/src/terminal.ts | 26 +- .../subprocess-local/src/windows-job.ts | 267 ++- .../subprocess-local/src/windows-stdio.ts | 143 -- .../tests/fixtures/fake-job-runner.ts | 33 - .../tests/linux-scope.spec.ts | 890 ++++---- .../subprocess-local/tests/local.spec.ts | 161 +- .../tests/managed-spawn.spec.ts | 279 --- .../tests/native-containment.spec.ts | 8 +- .../tests/native-windows.spec.ts | 63 +- .../tests/spawn-runner-built.e2e.ts | 97 +- .../tests/spawn-runner.spec.ts | 1848 ++++++----------- .../subprocess-local/tests/spawn.spec.ts | 169 +- .../subprocess-local/tests/terminal.spec.ts | 42 +- .../tests/windows-job.spec.ts | 553 +++-- .../tests/windows-stdio.spec.ts | 112 - .../subprocess-local/tsdown.config.ts | 2 +- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 6 +- packages/subprocess/subprocess/README.zh.md | 6 +- packages/subprocess/subprocess/src/index.ts | 9 +- packages/subprocess/subprocess/src/types.ts | 6 +- .../subprocess/tests/service.spec.ts | 18 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 6 +- .../subprocess/win32-process/README.zh.md | 6 +- packages/subprocess/win32-process/src/abi.ts | 6 - packages/subprocess/win32-process/src/ffi.ts | 12 - .../subprocess/win32-process/src/index.ts | 4 +- .../subprocess/win32-process/src/process.ts | 83 +- .../tests/ordinary-process.spec.ts | 130 +- .../win32-process/verify/abi-probe.cpp | 6 - scripts/build-exe-for-python-sdk.ts | 2 +- scripts/check-workspace-constraints.ts | 6 +- scripts/run-gates.spec.ts | 4 - scripts/run-gates.ts | 5 +- scripts/smoke-python-runtime.py | 132 +- scripts/verify-application-entrypoints.ts | 1 + .../cordis-inspect-jsdoc/session.jsonl | 2 +- 98 files changed, 3450 insertions(+), 4183 deletions(-) rename .agents/notes/implemented/{bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml => architecture/2026-08-28-subprocess-native-containment.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md create mode 100644 .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md create mode 100644 apps/cli/src/runtime-bootstrap.ts delete mode 100644 packages/subprocess/subprocess-local/src/windows-stdio.ts delete mode 100644 packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts delete mode 100644 packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts delete mode 100644 packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 0fe306f43a..83638635ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 6fbf4ba0c303f8d39a11dfe91ef3ee77f0f31904 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d027d7e015a831d63402de3bfd02694fa542cb90 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 6fbf4ba0c3..c152345772 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -28,7 +28,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t The deterministic serving surface is a plugin selected by the packaged `dsh` application: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`apps/cli`](../../../../apps/cli/README.md) (`@deepseek-ai/dsh`): the packaged application entry; its `sdk` profile mounts `dsh-sdk-jsonrpc-server`, and the CLI owns environment layering, profile composition, stdin/signal shutdown, and process exit. Before normal argument parsing, the packaged CLI recognizes one reserved private runner argument and dispatches it to `dsh-subprocess-local`, allowing native subprocess containment to re-enter the same executable without assuming `process.execPath` is a general Node binary. +- [`apps/cli`](../../../../apps/cli/README.md) (`@deepseek-ai/dsh`): the packaged application entry; its `sdk` profile mounts `dsh-sdk-jsonrpc-server`, and the CLI owns environment layering, profile composition, stdin/signal shutdown, and process exit. The Python client supplies an explicit Harness home and selects the `sdk` profile plus ordered patch files. A missing home, profile, bundle, or server row fails loudly; there is no external complete-config fallback. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this application surface. @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC; that set invokes the private subprocess runner through the installed single-file executable before exercising application profiles. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index d027d7e015..ea5e457afd 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -28,7 +28,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 确定性服务接口由打包后的 `dsh` 应用选择为插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`apps/cli`](../../../../apps/cli/README.zh.md)(`@deepseek-ai/dsh`):打包后的应用入口;其 `sdk` profile 挂载 `dsh-sdk-jsonrpc-server`,CLI 负责环境分层、profile 组合、stdin/signal 关闭与进程退出。在正常参数解析之前,打包后的 CLI 会识别一个保留的私有 runner 参数并转入 `dsh-subprocess-local`,使 native subprocess containment 可以重新进入同一个可执行文件,而不假定 `process.execPath` 是通用 Node 二进制。 +- [`apps/cli`](../../../../apps/cli/README.zh.md)(`@deepseek-ai/dsh`):打包后的应用入口;其 `sdk` profile 挂载 `dsh-sdk-jsonrpc-server`,CLI 负责环境分层、profile 组合、stdin/signal 关闭与进程退出。 Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 patch 文件。缺失 home、profile、bundle 或 server 配置项都会明确失败;不存在外部完整配置回退。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该应用接口。 @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景;该场景集会先通过安装后的单文件可执行程序调用私有 subprocess runner,再验证应用 profile。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 166541f1f2..b31d6baf7e 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: 92d36bf6d522ab939ef6c0de1063e0accea2946f -2026-07-26-subprocess-seam.zh.md: d271ad95ce84bb34256d3bf2ee6d793e21623d2b +2026-07-26-subprocess-seam.md: b892c43a4027815692dcc8082d4a2cc9feea18c7 +2026-07-26-subprocess-seam.zh.md: 78e290a14bc0cdf50434462dd854bdc1355d4938 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 92d36bf6d5..b892c43a40 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -12,8 +12,8 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) A new `subprocess/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: -- **`@deepseek-ai/dsh-subprocess` (Service Definition)** — the abstract `SubprocessRuntime` owning `ctx.subprocess`: executable lookup, fully explicit ordinary spawns, and the terminal primitive added by the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md). Each stdio stream independently selects `'pipe'`, `'inherit'`, or bounded collection `{ maxBytes, spill? }`; stdin selects `'ignore'`, `'pipe'`, or `{ data }`. `SubprocessOutcome` carries exit facts with deliberately no timeout/cancel classification, while collected output remains on the handle after settlement. The Service Definition also owns process and terminal handles, the shared scrub, and `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`; `argv` is never shell-interpreted. -- **`@deepseek-ai/dsh-subprocess-local` (Service Provider)** — `LocalSubprocessRuntime` over the former `run.ts` plumbing (`spawn.ts`) plus `node-pty`: bounded collection and private spill files, executable lookup, foreground/session inspection, and disposal that terminates and joins every managed process. Ordinary Linux commands use a user-systemd scope when the host can preserve literal argv and read scope state; ordinary Windows commands start suspended in a kill-on-close Job. `terminate()` and `waitForExit()` use that same OS range, while `.done` remains the direct command result. Unsupported hosts retain the disclosed PGID or `taskkill /T` fallback. Ordinary and terminal spawns apply the Service Definition's case-insensitive `KEY`/`PASSWORD`/`SECRET`/`TOKEN` scrub before explicit env. The provider has no config; every limit arrives on the spec, while Bash and PTY presentation environment overrides stay in their Consumers. +- **`@deepseek-ai/dsh-subprocess` (Service Definition)** — the abstract `SubprocessRuntime` owning `ctx.subprocess`: executable lookup, fully explicit ordinary spawns, and the terminal primitive added by the [portable execution-world decision](2026-07-28-portable-execution-world-consumers.md). Each stdio stream independently selects `'pipe'`, `'inherit'`, or bounded collection `{ maxBytes, spill? }`; stdin selects `'ignore'`, `'pipe'`, or `{ data }`. `SubprocessOutcome` carries exit facts with deliberately no timeout/cancel classification, while collected output remains on the handle after settlement. An ordinary handle exposes streams, collection, `.done`, `terminate()`, and `waitForExit()` without publishing a PID; the terminal handle retains its stable PID. The Service Definition also owns the shared scrub and `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`; `argv` is never shell-interpreted. +- **`@deepseek-ai/dsh-subprocess-local` (Service Provider)** — `LocalSubprocessRuntime` over the former `run.ts` plumbing (`spawn.ts`) plus `node-pty`: bounded collection and private spill files, executable lookup, foreground/session inspection, and disposal that terminates and joins every managed process. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns its current platform mechanism: eligible Linux ordinary and PTY launches use user-systemd scopes, eligible Windows ordinary launches use a private runner-owned kill-on-close Job, and unsupported hosts retain the disclosed PGID, `taskkill /T`, or PTY-observation fallback. `terminate()` and `waitForExit()` use the selected range, while `.done` remains the direct command result. Ordinary and terminal spawns apply the Service Definition's case-insensitive `KEY`/`PASSWORD`/`SECRET`/`TOKEN` scrub before explicit env. The provider has no config; every limit arrives on the spec, while Bash and PTY presentation environment overrides stay in their Consumers. - **`dsh-bash-local` (Consumer)** — `inject: ['subprocess']`; maps each resolved `ShellExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path. - **`dsh-shell` (Service Definition)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash Consumer changes an import; `ShellExecRequest`/`ShellExecSpec`/`ShellProcess` and the sandbox facts remain bash-owned. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index d271ad95ce..78e290a14b 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -12,8 +12,8 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-subprocess`(Service Definition)**——拥有 `ctx.subprocess` 的抽象 `SubprocessRuntime`:可执行文件查找、完全显式的普通 spawn,以及[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)新增的终端原语。每条 stdio 流独立选择 `'pipe'`、`'inherit'` 或有界收集 `{ maxBytes, spill? }`;stdin 选择 `'ignore'`、`'pipe'` 或 `{ data }`。`SubprocessOutcome` 只承载刻意不含超时/取消分类的退出事实,收集输出在结算后仍留在句柄上。该 Service Definition 还拥有进程与终端句柄、共享凭据清除,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`;`argv` 绝不经过 shell 解释。 -- **`@deepseek-ai/dsh-subprocess-local`(Service Provider)**——`LocalSubprocessRuntime` 构建在原 `run.ts` 管道(现为 `spawn.ts`)与 `node-pty` 之上:有界收集与私有 spill 文件、可执行文件查找、前台/会话检查,以及终止每个受管进程并等待其退出的 dispose。普通 Linux 命令在宿主能保留 literal argv 并读取 scope 状态时使用 user-systemd scope;普通 Windows 命令以 suspended 状态进入 kill-on-close Job。`terminate()` 与 `waitForExit()` 使用同一 OS range,而 `.done` 仍是 direct command result。不支持的宿主保留已披露的 PGID 或 `taskkill /T` fallback。普通与终端 spawn 都先应用 Service Definition 对 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 不区分大小写的清除,再合并显式 env。该 Service Provider 没有配置;每项限制都随 spec 到达,Bash 与 PTY 的呈现环境覆盖仍归各自 Consumer 所有。 +- **`@deepseek-ai/dsh-subprocess`(Service Definition)**——拥有 `ctx.subprocess` 的抽象 `SubprocessRuntime`:可执行文件查找、完全显式的普通 spawn,以及[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)新增的终端原语。每条 stdio 流独立选择 `'pipe'`、`'inherit'` 或有界收集 `{ maxBytes, spill? }`;stdin 选择 `'ignore'`、`'pipe'` 或 `{ data }`。`SubprocessOutcome` 只承载刻意不含超时/取消分类的退出事实,收集输出在结算后仍留在句柄上。普通句柄公开流、收集结果、`.done`、`terminate()` 与 `waitForExit()`,但不发布 PID;终端句柄保留稳定 PID。该 Service Definition 还拥有共享凭据清除,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput`;`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-subprocess-local`(Service Provider)**——`LocalSubprocessRuntime` 构建在原 `run.ts` 管道(现为 `spawn.ts`)与 `node-pty` 之上:有界收集与私有 spill 文件、可执行文件查找、前台/会话检查,以及终止每个受管进程并等待其退出的 dispose。[原生收容决策](2026-08-28-subprocess-native-containment.zh.md)拥有当前平台机制:符合条件的 Linux 普通命令与 PTY 使用 user-systemd scope,符合条件的 Windows 普通命令使用由私有 runner 拥有的 kill-on-close Job,不支持的宿主保留已披露的 PGID、`taskkill /T` 或 PTY 观察式 fallback。`terminate()` 与 `waitForExit()` 使用所选 range,而 `.done` 仍是 direct command result。普通与终端 spawn 都先应用 Service Definition 对 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 不区分大小写的清除,再合并显式 env。该 Service Provider 没有配置;每项限制都随 spec 到达,Bash 与 PTY 的呈现环境覆盖仍归各自 Consumer 所有。 - **`dsh-bash-local`(Consumer)**——`inject: ['subprocess']`;把每个解析后的 `ShellExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 - **`dsh-shell`(Service Definition)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash Consumer 需要改动导入;`ShellExecRequest`/`ShellExecSpec`/`ShellProcess` 与沙箱事实仍归 bash 所有。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 221d51c183..643453c53b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: f9d876acdc385b0dfae8319091655722b24d4981 -2026-08-19-shared-win32-process-primitives.zh.md: 37ba5cae7f2d194f6e641164b72478fefab6428e +2026-08-19-shared-win32-process-primitives.md: 7de80e15ef8b42e72187af40e5ee0646e80f071d +2026-08-19-shared-win32-process-primitives.zh.md: ebd9c437d617761cb7faef873f952443eca3f6ad diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index f9d876acdc..7de80e15ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -10,17 +10,17 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p ## Decision -`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked anonymous/named-pipe, Job, wait, polling, termination, and handle operations. +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations consumed by `sandbox-windows-acl` and the ordinary subprocess Job runner. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW` or `CreateProcessW`, and exposes checked anonymous-pipe, inherited-stdio, Job, wait, polling, termination, and handle operations. The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful anonymous-pipe creation returns the process plus stdout/stderr read handles to the sandbox. The ordinary runner opens target-side named-pipe handles supplied by its parent and closes those handles after target creation. Restricted and ordinary creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the ordinary runner retains the original direct-process handle and unnamed Job, polls direct exit, and closes the Job only after it is empty. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful anonymous-pipe creation returns the process plus stdout/stderr read handles to the sandbox. The ordinary runner temporarily restores inheritability on its own standard handles, passes those exact handles through `STARTF_USESTDHANDLES`, then closes its copies after target creation so target exit can produce EOF at the parent. Restricted and ordinary creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the [native-containment runner](2026-08-28-subprocess-native-containment.md) uniquely retains the ordinary direct-process handle and unnamed Job, polls direct exit and active-process count, and closes the Job only after it is empty. -The package exports only operations used by the two production consumers. Exact `applicationName`, parent-owned Node streams, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. +The current-token API is named `CurrentTokenProcessSpawnOptions` and `spawnCurrentTokenJobProcess`; no `Ordinary*` or `Unrestricted*` aliases preserve ambiguous semantics. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-owned Node streams and IPC, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification -The shared suite covers x64 ABI values, command-line quoting, binding extension, anonymous-pipe EOF and drain allocation reuse, stream-specific named-pipe opens, explicit ordinary stdio handles, restricted and ordinary process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. +The shared suite covers x64 ABI values, command-line quoting, binding extension, anonymous-pipe EOF and drain allocation reuse, inherited ordinary standard handles, restricted and current-token process creation, suspended creation followed by Job assignment and resume, blocking and zero-time exit reads, Job-empty probes and termination, native allocation release, and acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the native paths; Wine supplies the emulated Windows package and composition signal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index 37ba5cae7f..ebd9c437d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -10,17 +10,17 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p ## Decision -`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 anonymous/named-pipe、Job、wait、polling、termination 与 handle 操作。 +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 与 ordinary subprocess Job runner 消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 或 `CreateProcessW` 引用 argv,并提供带检查的 anonymous pipe、继承 stdio、Job、wait、polling、termination 与 handle 操作。 Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。anonymous pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。ordinary runner 打开 parent 提供的 target-side named-pipe handle,并在 target 创建后关闭这些 handle。restricted 与 ordinary 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;ordinary runner 保留原始 direct-process handle 与 unnamed Job,轮询 direct exit,并只在 Job 为空后关闭它。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。anonymous pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。ordinary runner 临时恢复自身标准句柄的可继承位,通过 `STARTF_USESTDHANDLES` 原样传递这些句柄,并在目标创建后关闭自身副本,使目标退出可以让 parent 观察到 EOF。restricted 与 ordinary 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;[原生收容 runner](2026-08-28-subprocess-native-containment.zh.md)唯一保留 ordinary direct-process handle 与 unnamed Job,轮询 direct exit 和 active-process count,并只在 Job 为空后关闭它。 -该包只导出两个生产 consumer 已使用的操作。精确 `applicationName`、parent-owned Node stream、公共 process handle 与 backend selection 仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 +current-token API 直接命名为 `CurrentTokenProcessSpawnOptions` 与 `spawnCurrentTokenJobProcess`;不保留语义含糊的 `Ordinary*` 或 `Unrestricted*` 别名。该包只导出两个生产消费方已使用的操作。精确 `applicationName`、parent 自有的 Node stream 与 IPC、公共 process handle 以及后端选择仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification -shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、anonymous-pipe EOF 与 drain allocation 复用、按流划分 access 的 named-pipe open、显式 ordinary stdio handle、restricted 与 ordinary process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、anonymous-pipe EOF 与 drain allocation 复用、继承的 ordinary 标准句柄、restricted 与 current-token process 创建、suspended 创建后的 Job 分配与恢复、blocking 与 zero-time exit 读取、Job-empty probe 与 termination、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖 native 路径;Wine 提供模拟 Windows package 与组合信号。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index e02c19ea8c..d649a92344 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 4af7812db6818b65c754a43ec1a7f973d1cbcbf9 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 155e7d2ae0b0ba3e4163dd85a31de90bef9d588a +2026-08-23-python-sdk-dsh-profile-runtime.md: e6dbe4c5a81093201edfb476a8f1b9594a923903 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 888fcb097d592d295df88c56f11cca3e4dec81c2 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 4af7812db6..e6dbe4c5a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe ### Executable packaging -The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. +The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. That packaging-owned bootstrap imports the ordinary public CLI when no private selection is present; for a selected subprocess runner it consumes the one private environment value and enters `@deepseek-ai/dsh-subprocess-local/runner` without parsing a hidden CLI argument or adding a second Node executable. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch and runner protocol. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 155e7d2ae0..888fcb097d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 可执行程序打包 -零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 +零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。没有私有选择值时,这个由打包层拥有的 bootstrap 会导入普通公共 CLI;选择了 subprocess runner 时,它会消费唯一的私有环境值并进入 `@deepseek-ai/dsh-subprocess-local/runner`,不解析隐藏 CLI 参数,也不增加第二个 Node 可执行程序。[原生收容决策](2026-08-28-subprocess-native-containment.zh.md)拥有这项私有分派与 runner 协议。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml similarity index 56% rename from .agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index ef14983ff5..21f604876d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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: 4ed116f5db247278771575549e216b1aa91c97fb -2026-08-20-subprocess-native-containment.zh.md: 95fdd1c73c1b7d5f53360389e8aef31c5b4ca979 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +2026-08-28-subprocess-native-containment.md: 4423494f784218f01005034dd0305098823ee7fe +2026-08-28-subprocess-native-containment.zh.md: 724c017a681a35f4c8d0ddecea46c28cb5301a76 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md new file mode 100644 index 0000000000..4423494f78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -0,0 +1,80 @@ +# Agent Note: Native owners contain escaped subprocess descendants + +Status: implemented + +English | [中文](2026-08-28-subprocess-native-containment.zh.md) + +## Problem + +Detached POSIX process groups, Windows direct-parent traversal, and PTY descendant scans describe only members that remain observable through one process relationship. A child can call `setsid`, reparent, or outlive its direct parent and leave those ranges, so terminating the apparent tree can return while work, ports, or files remain active. A direct target result also does not prove that every descendant has stopped. + +The ordinary subprocess handle cannot solve this gap by publishing a PID. Linux scope setup and the Windows Job runner establish target identity asynchronously, a PID does not name the complete managed range, and consumers would be forced to infer whether startup committed. The public result, range ownership, private startup protocol, and packaged entry therefore need separate owners. + +## Decision + +`LocalSubprocessRuntime` selects one provider-private managed-range owner before a target can execute. Eligible Linux ordinary and PTY launches enter a transient user-systemd scope; eligible Windows ordinary launches enter an unnamed kill-on-close Job owned by a private runner. Unsupported hosts use the existing weaker fallback with one provider-lifetime warning. The provider never replays a target after a selected native path may have executed it. + +An ordinary `SubprocessHandle` has no PID or public startup state. `.done` reports the direct target result or startup/provider failure, `terminate()` signals the selected range, and `waitForExit()` succeeds only after that same range is proven empty. `SubprocessTerminalHandle.pid` remains part of the terminal contract because PTY identity and foreground inspection require it. + +### Linux scope and one-shot bootstrap + +Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, `process.execve()`, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback. + +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, and calls `execve()` with the original argv. The bootstrap becomes the target in place; it does not remain as a supervisor. + +Request consumption or a manager observation of the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. After establishment, an inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. 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. + +### Windows runner and Job + +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. Target stdin, stdout, and stderr remain real Node-created standard handles: the runner temporarily makes its inherited handles inheritable, creates the target with `STARTF_USESTDHANDLES`, and closes its own copies after target creation. User bytes never pass through IPC. + +The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. + +The parent settles `.done` only after a strict direct-result message and the existing stdout/stderr close or bounded-drain barrier. A later Job query or range-settlement failure rejects only `waitForExit()`. IPC loss before that `.done` barrier rejects `.done` as runner infrastructure failure; IPC loss afterward leaves the completed direct result unchanged but still rejects range settlement. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof. + +### Private dispatch and protocol + +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, terminate, `target-exit`, `spawn-error`, `runner-error`, and `start-cancelled` messages. Errors carry only bounded Node-shaped fields. 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 + +Linux falls back before target execution when the exact bootstrap, modern readable user-systemd manager, or literal-argv scope is unavailable. Windows ordinary launch falls back when the runner entry, Win32 bindings, or current-token Job probe is unavailable. macOS ordinary launch, Windows ConPTY, and other unsupported hosts retain their existing PGID, `taskkill /T`, or identity-fenced PTY observation. The warning states that descendants escaping those observable relationships are not guaranteed to terminate or delay `waitForExit()`. + +Normal Cordis disposal starts direct-result and range observation independently, requests termination, and waits for every owned range. Consumer teardown does not inspect an ordinary PID; it retains the original operation or startup error while attempting terminate and final wait, and preserves cleanup failures in the consumer's existing error order. A confirmed empty range permanently disables later signalling against stale identities. + +During a JavaScript-observable host exit, `LocalSubprocessRuntime` synchronously force-terminates every still-live handle without promises or timers. Linux sends the existing direct fallback kill and the exact scope kill; Windows kills the runner so its only Job handle closes; PTY fallback scans remain best effort. Per-handle failures are contained and do not change the host's exit result. Termination modes in which JavaScript cannot run remain outside this listener's guarantee. + +## Existing decisions and supersession + +This note owns the current native-containment mechanism. It partially updates the provider and no-PID facts in the [subprocess seam](2026-07-26-subprocess-seam.md), the Linux teardown facts in [persistent PTY sessions](../feature/2026-07-16-persistent-pty-sessions.md), the native targets used by [synchronous host-exit cleanup](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md), the ordinary consumer of [shared Win32 process primitives](2026-08-19-shared-win32-process-primitives.md), and the private entry selected by the [Python SDK profile runtime](2026-08-23-python-sdk-dsh-profile-runtime.md). Each note retains its other decision and remains active. + +## Verification + +- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, 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. + +## Alternatives considered + +**Keep PID, make it optional, or add a public `started` promise.** Rejected because each representation exposes an asynchronous provider identity that does not name the managed range and invites consumers to infer startup or quiescence from the wrong fact. + +**Extend process-group, session, parent-tree, or PID scanning.** Rejected because a process can leave each observed relationship; broader SID signalling can also reach unrelated processes when a PTY helper shares a launcher session. Native OS membership is persistent and independently queryable. + +**Let the parent own or reopen the Windows Job.** Rejected because copied handles, named Jobs, `OpenJobObject`, process-handle handoff, and completion ports create multiple lifecycle owners without improving the direct-result contract. One runner can own target creation, Job membership, result production, and final handle closure. + +**Carry control or results through target stdio or files on Windows.** Rejected because user bytes and EOF must remain authoritative to existing Node streams, while result files or polling introduce a second result owner. One IPC channel separates control from target stdio. + +**Parse a hidden runner argument in the public CLI or ship another Node executable.** Rejected because either choice expands the public application grammar or distribution surface. A packaging-only bootstrap keeps one physical executable and two private logical entries. + +**Cache successful native probes or recover a failed native launch by replaying the command.** Rejected because user-manager, entry, and Job availability can change between spawns, while replay can execute a command twice after an ambiguous failure. + +## Consequences + +Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement. + +Fallback hosts continue to run commands but carry a visible weaker guarantee. Windows ConPTY, macOS native containment, active breakaway descendants, old or absent user-systemd environments, target replay, persistent runner recovery, and termination paths where JavaScript cannot execute remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md new file mode 100644 index 0000000000..724c017a68 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -0,0 +1,80 @@ +# Agent Note: 原生 owner 收容逃逸的 subprocess 后代 + +Status: implemented + +[English](2026-08-28-subprocess-native-containment.md) | 中文 + +## Problem + +detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只能描述仍可通过某种进程关系观察到的成员。子进程可以调用 `setsid`、发生 reparent,或比 direct parent 存活更久并离开这些 range,因此终止表面进程树后,工作、端口或文件仍可能保持活跃。direct target result 也不能证明全部后代已经停止。 + +普通 subprocess 句柄无法通过发布 PID 解决这个缺口。Linux scope setup 与 Windows Job runner 会异步建立 target identity,PID 不表示完整 managed range,消费方也会被迫推断 startup 是否已经提交。因此,公共结果、range ownership、私有启动协议与打包入口需要各自明确的 owner。 + +## Decision + +`LocalSubprocessRuntime` 会在 target 执行前选择一个 provider 私有的 managed-range owner。符合条件的 Linux 普通命令与 PTY 进入临时 user-systemd scope;符合条件的 Windows 普通命令进入由私有 runner 拥有的 unnamed kill-on-close Job。不支持的宿主使用既有较弱 fallback,并在 provider 生命周期内只警告一次。选定的 native 路径一旦可能已经执行 target,provider 绝不重放 target。 + +普通 `SubprocessHandle` 没有 PID 或公共 startup 状态。`.done` 报告 direct target result 或 startup/provider failure,`terminate()` 向所选 range 发送信号,`waitForExit()` 只有在同一 range 被证明为空后才成功。`SubprocessTerminalHandle.pid` 继续属于终端约定,因为 PTY identity 与前台检查需要它。 + +### Linux scope 与 one-shot bootstrap + +每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、`process.execve()`、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。 + +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 规则解析裸可执行文件,并使用原始 argv 调用 `execve()`。bootstrap 会原地成为 target,不作为常驻 supervisor。 + +request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。建立之后,inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `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 后代。 + +### Windows runner 与 Job + +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。target stdin、stdout 与 stderr 继续使用 Node 创建的真实标准句柄:runner 临时把继承的句柄设为可继承,通过 `STARTF_USESTDHANDLES` 原样传递,并在 target 创建后关闭自身副本。用户字节绝不经过 IPC。 + +runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 + +parent 只有在收到严格 direct-result message,并且既有 stdout/stderr close 或有界 drain barrier 完成后才结算 `.done`。后续 Job query 或 range settlement failure 只会使 `waitForExit()` reject。在该 `.done` barrier 之前发生 IPC loss 会使 `.done` 以 runner infrastructure failure reject;之后发生 IPC loss 会保留已经完成的 direct result,但仍使 range settlement reject。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。 + +### 私有分派与协议 + +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、`target-exit`、`spawn-error`、`runner-error` 与 `start-cancelled` 消息。错误只携带有界的 Node-shaped 字段。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。 + +### Fallback 与 cleanup + +准确 bootstrap、现代且可读的 user-systemd manager,或保留 literal argv 的 scope 不可用时,Linux 会在 target 执行前进入 fallback。runner 入口、Win32 bindings 或 current-token Job probe 不可用时,Windows 普通启动会进入 fallback。macOS 普通启动、Windows ConPTY 与其他不受支持的宿主保留既有 PGID、`taskkill /T` 或带身份围栏的 PTY 观察机制。warning 会明确说明:逃离这些可观察关系的后代不保证被终止,也不保证延迟 `waitForExit()`。 + +正常 Cordis dispose 会独立启动 direct-result 与 range observation、请求终止,并等待每个自有 range。消费方 teardown 不检查普通 PID;它会保留原始 operation 或 startup error,同时尝试 terminate 与 final wait,并按消费方既有错误顺序保留 cleanup failure。range 一旦被确认为空,就会永久禁止后续向陈旧 identity 发送信号。 + +在 JavaScript 可观察的 host exit 期间,`LocalSubprocessRuntime` 会同步强制终止每个仍存活的句柄,不使用 Promise 或 timer。Linux 会发送既有 direct fallback kill 与准确 scope kill;Windows 会终止 runner,使其唯一 Job handle 关闭;PTY fallback 扫描仍是 best effort。每个句柄的失败相互隔离,也不改变宿主退出结果。JavaScript 无法运行的终止形态不属于该 listener 的保证。 + +## Existing decisions and supersession + +本 Note 拥有当前 native containment 机制。它局部更新了[subprocess seam](2026-07-26-subprocess-seam.zh.md)中的 provider 与 no-PID 事实、[持久化 PTY 会话](../feature/2026-07-16-persistent-pty-sessions.zh.md)中的 Linux teardown 事实、[宿主退出同步清理](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)使用的 native target、[共享 Win32 process primitives](2026-08-19-shared-win32-process-primitives.zh.md)的 ordinary 消费方,以及[Python SDK profile 运行时](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)选择的私有入口。每份 Note 都保留其余决策并继续处于 active 状态。 + +## Verification + +- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、stdio settlement、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 保留。 + +## Alternatives considered + +**保留 PID、把它改成可选值,或增加公共 `started` Promise。**不予采用,因为这些表示都会暴露不表示 managed range 的异步 provider identity,并诱导消费方从错误事实推断 startup 或完全停稳。 + +**扩展进程组、session、parent tree 或 PID 扫描。**不予采用,因为进程可以离开每一种观察关系;当 PTY helper 与 launcher 共用 session 时,更宽的 SID signalling 还可能命中无关进程。native OS membership 会持续存在,并且可以独立查询。 + +**让 parent 拥有或重新打开 Windows Job。**不予采用,因为复制 handle、named Job、`OpenJobObject`、process-handle handoff 与 completion port 会制造多个 lifecycle owner,却不能改善 direct-result 约定。一个 runner 可以统一拥有 target creation、Job membership、result production 与最终 handle closure。 + +**在 Windows 上通过 target stdio 或文件传递 control/result。**不予采用,因为用户字节与 EOF 必须继续以既有 Node stream 为权威,而 result file 或 polling 会制造第二个 result owner。一条 IPC channel 可以把 control 与 target stdio 分开。 + +**在公共 CLI 中解析隐藏 runner 参数,或发布另一个 Node 可执行程序。**不予采用,因为前者扩张公共应用语法,后者扩张分发面。packaging-only bootstrap 保留一个物理可执行程序与两个私有逻辑入口。 + +**缓存成功的 native probe,或在 native launch 失败后重放命令。**不予采用,因为 user-manager、入口与 Job availability 可以在两次 spawn 之间变化,而一次含糊 failure 之后的 replay 可能执行命令两次。 + +## Consequences + +受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一个 Linux scope/request 或 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。 + +fallback 宿主继续运行命令,但携带可见的较弱保证。Windows ConPTY、macOS native containment、active breakaway 后代、旧版或缺失的 user-systemd 环境、target replay、持久 runner recovery,以及 JavaScript 无法执行的终止路径均不属于本决策。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml index 2d351c09e4..126b306b78 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-11-synchronous-subprocess-exit-cleanup.md -2026-08-11-synchronous-subprocess-exit-cleanup.md: 1a6664c03ce0ae90b94d210645ac1401e078fae0 -2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 4c08847b24422d58025b6563785579d84c3ba6a7 +2026-08-11-synchronous-subprocess-exit-cleanup.md: 0914fdd51434f1f818e02484e652ab56236efb84 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 45f46fab6800db7b49be03b71419e1935459ae6e diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md index 1a6664c03c..0914fdd514 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -17,7 +17,7 @@ The public subprocess seam correctly promises awaited quiescence during normal d The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: - An ordinary handle synchronously signals its bound native scope or Job runner when available; the disclosed fallback sends SIGKILL to its detached POSIX process group or runs `taskkill /PID /T /F` on Windows. -- A terminal handle with a native Linux owner synchronously signals that scope with SIGKILL. A fallback terminal instead signals every captured and currently observable descendant, kills the PTY root, then rescans once for members that became observable during that boundary. +- A terminal handle synchronously signals every captured and currently observable descendant, kills the PTY root, then rescans once for members that became observable during that boundary. A native Linux handle then also signals its exact scope with SIGKILL; a fallback terminal ends after the observational sequence. - The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: POSIX ordinary ranges receive TERM, the configured grace, then KILL; Windows ordinary ranges terminate immediately; and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS range is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. @@ -48,4 +48,4 @@ Unit evidence pins synchronous native-owner and fallback delivery, native termin Each active local subprocess service contributes one process-global exit listener. Successful disposal removes it with the service effect; failed disposal retains it with the targets that still require final termination. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. -The listener cannot cover failures that do not execute JavaScript. Supported Linux terminals signal the scope described by the [containment decision](2026-08-20-subprocess-native-containment.md); fallback terminals still cannot discover a descendant that escaped before the provider observed it. +The listener cannot cover failures that do not execute JavaScript. Supported Linux terminals signal the scope described by the [native-containment decision](../architecture/2026-08-28-subprocess-native-containment.md); fallback terminals still cannot discover a descendant that escaped before the provider observed it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md index 4c08847b24..45f46fab68 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -17,7 +17,7 @@ Status: implemented 该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: - 普通 handle在可用时同步向绑定的 native scope 或 Job runner 发信号;已披露的 fallback 会向 detached POSIX进程组发送 SIGKILL,或在 Windows运行 `taskkill /PID /T /F`。 -- 具有 native Linux owner 的 terminal handle 会同步向该 scope 发送 SIGKILL。fallback terminal 则向全部已捕获及当前可观察的后代发送信号,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- terminal handle 会同步向全部已捕获及当前可观察的后代发送信号,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。具有 native Linux owner 的 handle 随后还会向其准确 scope 发送 SIGKILL;fallback terminal 则在观察序列后结束。 - 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.zh.md)的先终止再等待退出路径:POSIX ordinary range 先接收 TERM,经过配置的宽限期后再接收 KILL;Windows ordinary range 立即终止;每个 ordinary 或 terminal 清理都会等待完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS range 已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 @@ -48,4 +48,4 @@ Status: implemented 每个有效的本地 subprocess service都会贡献一个进程全局 exit listener。成功的 dispose会随服务 effect移除它;失败的 dispose会让它与仍需最终终止的目标一起保留。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 -listener 无法覆盖不执行 JavaScript 的故障。受支持的 Linux terminal 会向[containment decision](2026-08-20-subprocess-native-containment.zh.md)所述的 scope 发出信号;fallback terminal 仍无法发现 provider 首次观察前已经逃逸的后代。 +listener 无法覆盖不执行 JavaScript 的故障。受支持的 Linux terminal 会向[原生收容决策](../architecture/2026-08-28-subprocess-native-containment.zh.md)所述的 scope 发出信号;fallback terminal 仍无法发现 provider 首次观察前已经逃逸的后代。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md deleted file mode 100644 index 4ed116f5db..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note: Local subprocesses use native managed ranges where supported - -Status: implemented - -English | [中文](2026-08-20-subprocess-native-containment.zh.md) - -## Problem - -The local subprocess provider treated a POSIX process group or a Windows direct-parent tree as the managed range. A descendant could call `setsid`, double-fork, or outlive the direct parent, so `terminate()` could miss work that `waitForExit()` had already declared gone. The direct command result and the complete managed range are different lifecycle facts and must not be collapsed into one wrapper exit code. - -## Decision - -`LocalSubprocessRuntime` selects containment before every eligible ordinary or terminal user command. Linux rechecks the live user manager for every launch; successful stable systemd-scope and ordinary-runner probes are cached for the provider lifetime, failed probes are retried, and terminal selection never probes the ordinary runner. Windows likewise caches only a successful Job-runner probe. The weaker-path warning is emitted at most once per provider. Linux uses a transient user-systemd scope only when the user manager is readable and `systemd-run` supports `--expand-environment=no`. Windows ordinary launch uses a local runner backed by `@deepseek-ai/dsh-win32-process`; it creates the target suspended, assigns it to a kill-on-close Job, and resumes it only after assignment. Each native launch binds a package-private owner with only `signal()` and `waitForExit()` responsibilities. - -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 ordinary user argv never enters the `systemd-run` command line. The runner consumes it from the private request, spawns the target with the exact cwd and scrubbed-plus-explicit environment, and reports the direct result. The packaged carrier re-enters its executable through the private dispatch owned by the [single-file runtime](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md), and the Linux capability probe invokes that same runner entry before selecting native mode. Scope TERM leaves the runner alive long enough to report a TERM-trapping target. If scope KILL prevents a final target event, the Linux launch reports `SIGKILL` only after that KILL was attempted and the owner proves the scope empty; an unrelated runner or manager failure still rejects. - -Linux terminal launch passes `systemd-run --user --scope --quiet --collect --expand-environment=no -- ` directly to `node-pty`; `systemd-run --scope` replaces itself with the target, so node-pty continues to observe the target PID, session leader, process group, controlling terminal, foreground input wait, and prompt readiness. The terminal handle binds the same scope owner for normal termination and host-exit KILL, so a descendant that reparents or creates a new session remains in the managed range without a second PTY runner or a continuous process-table monitor. - -On Windows the parent creates private named-pipe endpoints for non-inherited streams, while the runner opens only the target-side handles. That runner creates the target suspended, assigns it to its unnamed kill-on-close Job, resumes it, appends the target identity to the private event file, and closes its pipe handles before processing control messages. The parent returns the handle immediately with `pid` undefined and publishes that identity when its asynchronous event reader observes the record. The runner retains the original target process handle and Job until it has reported direct exit and `QueryInformationJobObject` reports zero active members. The parent never opens the target process or Job; IPC termination and disconnect remain the only control path into the runner. - -When native capability is unavailable before target execution, the provider warns once and uses the existing PGID or `taskkill /T` fallback. macOS always takes that path because it has no supported public persistent process owner. After native launch is selected, any runner, manager, or result-transport failure is reported; the user command is never replayed through fallback. - -## Verification - -Linux native evidence on Ubuntu 24.04 x86_64 with systemd 255.4 runs separate ordinary and node-pty `setsid`/reparenting scenarios plus Node-shaped spawn failures without replay. The PTY scenario pins the node-pty PID, process group, session leader, controlling terminal, `/dev/tty` input, foreground `inputWaiting`, and termination of the escaped descendant. Windows native evidence covers one default-inheritance descendant scenario plus raw stdin, direct stdout/stderr EOF, direct result versus Job quiescence, and target spawn failures. Shared tests pin literal argv, one-time fallback warnings, unreadable-owner rejection, no post-stop signals, abort and host-exit routing, and source, built, and packaged-executable runner entries. - -## Alternatives considered - -**Scan the process table for escaped descendants.** Rejected because parent and PID snapshots do not provide a persistent ownership fact and can follow PID reuse. - -**Expose a public backend selector or generic launch framework.** Rejected because callers need one subprocess contract, while systemd and Job creation have different launch mechanics. Only the signal/wait owner is common. - -**Move the Windows Job or direct-process observation into the parent.** Rejected because a named Job, cross-process open, release handshake, or second process handle would duplicate runner-owned lifecycle facts without producing a second user result. The parent owns only public stdio endpoints and runner control. - -**Support legacy systemd argument expansion.** Rejected because shell-style expansion can change user argv. Hosts without the literal-argument option use the disclosed fallback. - -**Use private macOS coalition APIs.** Rejected because no supported public owner gives the required membership and settlement contract. - -## Consequences - -Supported Linux and Windows hosts retain descendants after session changes or reparenting, and termination and settlement read one OS-owned range. Linux pays one live-manager probe before every eligible ordinary or terminal target; stable scope and ordinary-runner probes stop after their first success, retry after failure, and carry a 5-second bound per command. Terminal launch never runs the ordinary-runner probe. Windows repeats its bounded Job-runner probe only until the first success. A native ordinary handle has no per-launch target-publication handshake or timeout: it returns with `pid` undefined, and the asynchronous 100 ms event-file poll publishes the PID or settles `.done`. A runner that remains alive without a terminal event therefore leaves those facts pending until it exits or the range is terminated. Each native ordinary range retains one runner process until settlement; Linux PTY launch adds no runner. Windows also creates private per-spawn named-pipe endpoints, but no named Job or parent target-process handle. Systemd state reads use asynchronous 200 ms polling rather than blocking the host event loop. Windows managed ranges terminate immediately; `graceMs` still bounds collected-pipe draining. The private runner adds one built entry and short-lived private files but no public configuration or durable format. Windows breakaway descendants remain outside the guarantee, and external termination in the narrow CreateProcess-to-Job-assignment interval can leave a suspended target. Fallback hosts remain usable with an explicit weaker guarantee. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md deleted file mode 100644 index 95fdd1c73c..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-20-subprocess-native-containment.zh.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note: Local subprocesses use native managed ranges where supported - -Status: implemented - -[English](2026-08-20-subprocess-native-containment.md) | 中文 - -## Problem - -本地 subprocess provider 把 POSIX 进程组或 Windows direct-parent tree 当作 managed range。descendant 可以调用 `setsid`、double-fork 或活得比 direct parent 更久,导致 `terminate()` 漏掉的工作已经被 `waitForExit()` 宣布消失。direct command result 与完整 managed range 是不同的生命周期事实,不能压成一个 wrapper exit code。 - -## Decision - -`LocalSubprocessRuntime` 会在每次符合条件的 ordinary 或 terminal 用户命令前选择 containment。Linux 会在每次 launch 时重查 live user manager;稳定的 systemd scope 与 ordinary runner 探测只在成功后按 provider 生命周期缓存,失败探测会重试,而且 terminal 选择绝不会探测 ordinary runner。Windows 同样只缓存成功的 Job runner 探测。较弱路径的告警由每个 provider 至多发出一次。Linux 只在 user manager 可读且 `systemd-run` 支持 `--expand-environment=no` 时使用 transient user-systemd scope。Windows ordinary launch 使用由 `@deepseek-ai/dsh-win32-process` 支撑的本地 runner;它以 suspended 状态创建目标,把目标分配给 kill-on-close Job,并只在分配后恢复。每次 native launch 只绑定一个提供 `signal()` 与 `waitForExit()` 职责的 package-private owner。 - -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 ordinary user argv 从不进入 `systemd-run` 命令行。runner 从 private request 消费 argv,以精确 cwd 和 scrubbed-plus-explicit environment 启动目标,并报告 direct result。打包载体通过[单文件运行时](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)拥有的 private dispatch 重新进入自身 executable;Linux capability probe 在选择 native mode 前调用同一个 runner entry。scope TERM 会让 runner 存活足够久,以便报告 trap TERM 的目标。若 scope KILL 阻止最终 target event,Linux launch 只会在该 KILL 已尝试且 owner 证明 scope 为空后报告 `SIGKILL`;无关的 runner 或 manager failure 仍会拒绝。 - -Linux terminal launch 会把 `systemd-run --user --scope --quiet --collect --expand-environment=no -- <原始 argv>` 直接交给 `node-pty`;`systemd-run --scope` 会以 target 替换自身,因此 node-pty 继续观察 target PID、session leader、process group、控制终端、前台 input wait 与 prompt readiness。terminal handle 会为正常终止与 host-exit KILL 绑定同一个 scope owner,因此已 reparent 或新建 session 的 descendant 仍留在 managed range 内,无需第二个 PTY runner 或持续进程表 monitor。 - -Windows parent 为非继承流创建 private named-pipe endpoint,runner 只打开 target 侧 handle。该 runner 以 suspended 状态创建目标,把目标分配给自身 unnamed kill-on-close Job,恢复目标,把 target identity 追加到 private event file,并在处理 control message 前关闭自身 pipe handle。parent 会立即返回 `pid` 为 `undefined` 的 handle,并在异步 event reader 观察到该记录后发布 identity。runner 会保留原始 target process handle 与 Job,直到报告 direct exit 且 `QueryInformationJobObject` 报告 active member 归零。parent 不打开 target process 或 Job;IPC termination 与 disconnect 是进入 runner 的唯一控制路径。 - -native capability 在目标执行前不可用时,provider 只告警一次并使用既有 PGID 或 `taskkill /T` fallback。macOS 因没有受支持的公开 persistent process owner,始终进入该路径。native launch 一旦被选择,runner、manager 或 result transport 的任何失败都会直接报告;用户命令绝不会经 fallback 重放。 - -## Verification - -Linux native 证据在 Ubuntu 24.04 x86_64、systemd 255.4 环境分别运行 ordinary 与 node-pty `setsid`/reparenting 场景,并覆盖不重放的 Node-shaped spawn failure。PTY 场景固定 node-pty PID、process group、session leader、控制终端、`/dev/tty` 输入、前台 `inputWaiting`,以及 escaped descendant 的终止。Windows native 证据运行一个默认继承 descendant 场景,并覆盖 raw stdin、direct stdout/stderr EOF、direct result 与 Job quiescence 的区别,以及 target spawn failure。shared tests 固定 literal argv、一次性 fallback warning、owner 不可读时拒绝、停稳后不再发 signal、abort 与 host-exit 路由,以及 source、built 和 packaged-executable runner entry。 - -## Alternatives considered - -**扫描进程表寻找 escaped descendant。** 拒绝,因为 parent 与 PID snapshot 不提供持续所有权事实,还可能跟随 PID reuse。 - -**暴露公共 backend selector 或通用 launch framework。** 拒绝,因为调用方只需要一个 subprocess contract,而 systemd 与 Job creation 具有不同 launch mechanics;只有 signal/wait owner 是共同部分。 - -**把 Windows Job 或 direct-process observation 移到 parent。** 拒绝,因为 named Job、cross-process open、release handshake 或第二个 process handle 会重复 runner 已拥有的生命周期事实,却不会产生第二个用户结果。parent 只拥有公共 stdio endpoint 与 runner control。 - -**支持 legacy systemd argument expansion。** 拒绝,因为 shell-style expansion 会改变 user argv;缺少 literal-argument option 的宿主使用已披露的 fallback。 - -**使用 private macOS coalition API。** 拒绝,因为没有受支持的公开 owner 能提供所需 membership 与 settlement contract。 - -## Consequences - -受支持的 Linux 与 Windows 宿主会在 session 变化或 reparent 后继续拥有 descendant,termination 与 settlement 读取同一个 OS-owned range。Linux 会在每个符合条件的 ordinary 或 terminal target 前执行一次 live manager 探测;稳定的 scope 与 ordinary runner 探测会在首次成功后停止,失败后则重试,每条命令的上限为 5 秒。terminal launch 绝不会运行 ordinary runner 探测。Windows 的有界 Job runner 探测也只重复到首次成功。native ordinary handle 没有每次 launch 的 target publication 握手或超时:它以 `pid` 为 `undefined` 的状态返回,再由每 100 ms 异步读取一次的 event file 发布 PID 或结算 `.done`。runner 如果保持存活却始终没有 terminal event,这些事实会保持待定,直到 runner 退出或该范围被终止。每个 native ordinary range 会保留一个 runner process 直到 settlement;Linux PTY launch 不增加 runner。Windows 还会创建 private per-spawn named-pipe endpoint,但不会创建 named Job 或 parent target-process handle。systemd state 每 200 ms 异步读取一次,不会阻塞宿主事件循环。Windows managed range 会立即终止;`graceMs` 仍用于限制 collected-pipe 排空。private runner 增加一个 built entry 和短期 private files,但不增加公共配置或 durable format。Windows breakaway descendant 仍不在保证范围;runner 在 CreateProcess 到 Job assignment 的极窄区间遭外力终止时可能留下 suspended target。fallback 宿主继续可用,但保证会被明确削弱。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index d4e5b38da7..18130686dc 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 08ccf64586034b5a0524889f33da89213ac7275d -2026-07-16-persistent-pty-sessions.zh.md: 5225d781fc8eba1fb4a6c7f4d3b5d4d87547b71d +2026-07-16-persistent-pty-sessions.md: 8128e4466c0845ee9749c8bff7d8e982a9780133 +2026-07-16-persistent-pty-sessions.zh.md: ad7ac3c4a51bd01fc20408197fdf857cac036e33 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 08ccf64586..8128e4466c 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -45,7 +45,7 @@ A registered `shell` backend constrains how a terminal starts; it does not const Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. -The local subprocess terminal primitive uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors below that primitive derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns this process/consumer split. +The local subprocess terminal primitive uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. On supported Linux hosts the [native-containment owner](../architecture/2026-08-28-subprocess-native-containment.md) starts that same PTY command inside a user-systemd scope without changing its PID, session, controlling terminal, foreground-group, or readiness semantics. Platform process inspectors below the primitive still derive foreground process groups and fallback parent/child identity from `/proc` on Linux and `ps` on macOS. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns this process/consumer split. ### Six model-facing tools @@ -92,7 +92,9 @@ Background sends use the existing task completion notice and `job_output` result ### Process-tree teardown -The subprocess terminal handle owns the top-level terminal process and its session. On close it snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation. +On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Close sends `SIGTERM` to the direct PTY and the scope, waits for the manager to prove that range empty, and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome. + +Fallback hosts retain observational process-session cleanup. The handle snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation. Teardown reports top-level exit and survivor cleanup independently. The PTY session does not claim success merely because the shell exited: it calls `SubprocessTerminalHandle.terminate()` and awaits whole-session quiescence, propagating a cleanup failure that names survivors. A failed close is not cached forever: the registry and local session clear the fence only when it still names that failed attempt, so a later explicit or lifecycle close retries without disturbing a newer concurrent attempt. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails. @@ -158,7 +160,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. -- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. +- Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence. The Linux native smoke keeps the PTY PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while a reparented `setsid` descendant remains owned by the scope; fallback suites retain identity-fenced observational cleanup coverage. - A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. The SDK minimal snapshot pins that output through the persistent Bash tool; ACP and headless snapshots pin the six terminal schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. @@ -172,7 +174,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. -**A daemonized descendant can leave the local provider's captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The local terminal primitive accepts that cleanup gap instead of risking SID-wide signals to unrelated processes. +**Native Linux ownership closes the process-tree observation gap; fallback ownership does not.** A supported user-systemd scope retains a daemonized or reparented descendant as a member until the scope becomes empty. On macOS, Windows ConPTY, and Linux hosts that cannot establish the scope, a process that escapes before observational teardown can still evade the captured tree; the fallback accepts that gap instead of risking SID-wide signals to unrelated processes. **A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 5225d781fc..ad7ac3c4a5 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -45,7 +45,7 @@ agent scope dispose(资源释放)时先撤销注册,再等待全部所属 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 -本地子进程终端原语只使用 `node-pty` 的公开能力:子进程 PID、`data` 与 `exit` 通知、`write` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。该原语下的平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种进程/消费方拆分。 +本地子进程终端原语只使用 `node-pty` 的公开能力:子进程 PID、`data` 与 `exit` 通知、`write` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。在受支持的 Linux 宿主上,[原生收容 owner](../architecture/2026-08-28-subprocess-native-containment.zh.md)会在 user-systemd scope 内启动同一条 PTY 命令,同时保持 PID、session、控制终端、前台进程组与就绪语义。该原语下的平台进程检查器仍在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和 fallback 父子进程身份。[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种进程/消费方拆分。 ### 6 个面向模型的工具 @@ -92,7 +92,9 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 ### 进程树 teardown -子进程终端句柄拥有顶层终端进程及其会话。关闭时,它按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 +在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。close 会向 direct PTY 与 scope 发送 `SIGTERM`,等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。 + +fallback 宿主保留观察式进程 session 清理。句柄会按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 teardown 独立报告顶层进程退出与存活进程清理。PTY 会话不会只因 shell 退出就声称成功:它会调用 `SubprocessTerminalHandle.terminate()` 并等待整个会话完全停稳,若清理失败则向外传播并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。 @@ -158,7 +160,7 @@ plugins: - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 -- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 +- 真实 `node-pty` 与 PTY 消费方测试共同覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。Linux native 冒烟测试会在一个 reparent 的 `setsid` 后代仍由 scope 拥有时,保持 PTY PID、session leader、控制终端、前台 `inputWaiting` 与 readiness;fallback 测试套件继续覆盖带身份围栏的观察式清理。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。SDK minimal 快照通过持久 Bash 工具固定该输出;ACP 与 headless 快照通过 opt-in overlay 固定 6 个终端 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 @@ -172,7 +174,7 @@ plugins: **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何提示词都无法让状态持久化变成确定行为。 -**daemonized 后代进程可能离开本地提供方捕获的进程树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。本地终端原语接受这个清理缺口,不冒险按 SID 向无关进程发送信号。 +**Linux native ownership 消除了进程树观察缺口,fallback ownership 没有。**受支持的 user-systemd scope 会持续保有 daemonized 或 reparent 后代,直到 scope 为空。在 macOS、Windows ConPTY,以及无法建立 scope 的 Linux 宿主上,观察式 teardown 开始前已经逃逸的进程仍可能避开捕获树;fallback 接受这个缺口,不冒险按 SID 向无关进程发送信号。 **Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index 491c5f953f..dcc666c622 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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/testing/2026-07-22-cross-platform-test-fixtures.md -2026-07-22-cross-platform-test-fixtures.md: 36df88153cf9419f6eb5ce58195d2c62fd56cc71 -2026-07-22-cross-platform-test-fixtures.zh.md: 8785ef6bbc4e7715dcaf25fe0a241e596e1159d1 +2026-07-22-cross-platform-test-fixtures.md: 9114481543d6cae1661cbed70868eddb2faa09fc +2026-07-22-cross-platform-test-fixtures.zh.md: 710ec5887f02b5c3c71d69f16ab726323501ac92 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 36df88153c..9114481543 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown delegates to the subprocess provider's managed range: supported local Linux uses a user-systemd scope and Windows uses a kill-on-close Job, while explicit fallbacks use a negative process-group id or synchronous `taskkill /T /F`. See the [ordinary subprocess native-containment decision](../bug-fix/2026-08-20-subprocess-native-containment.md). Windows fallback treats every taskkill result as best-effort and ignores command, permission, absent-tree, and other status failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Supported native Windows hosts use Job ownership; fallback Windows teardown makes one synchronous best-effort `taskkill` call after graceful protocol shutdown has failed. Its result is ignored, so the fallback neither reports taskkill failure nor proves descendant exit before cleanup returns. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 8785ef6bbc..710ec5887f 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会委托给 subprocess provider 的 managed range:受支持的本地 Linux 使用 user-systemd scope,Windows 使用 kill-on-close Job;明确的 fallback 才使用负数进程组 ID 或同步 `taskkill /T /F`。参见[普通子进程 native containment 决策](../bug-fix/2026-08-20-subprocess-native-containment.zh.md)。Windows fallback 把所有 taskkill 结果都视为 best-effort,并忽略命令、权限、进程树不存在及其他状态失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受支持的原生 Windows 宿主使用 Job 所有权;fallback Windows 的资源清理则在协议级优雅关停失败后同步发出一次 best-effort `taskkill`。该调用的结果会被忽略,因此 fallback 既不报告 taskkill 失败,也不证明清理返回前后代进程已经退出。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 0758c8d32f..321849f2d9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -11,8 +11,6 @@ import { fileURLToPath } from 'node:url' import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' -const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' - // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the checked-in manifest resolves with the // same relative hop from either artifact. @@ -23,39 +21,30 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -if (process.argv[2] === PACKAGED_RUNNER_ARG) { - process.argv.splice(2, 1) - const runnerEntry = new URL( - './lib/spawn-runner.js', - import.meta.resolve('@deepseek-ai/dsh-subprocess-local/package.json'), - ) - await import(runnerEntry.href) -} else { - const invocation = parseDshArgs(process.argv.slice(2), readVersion()) +const invocation = parseDshArgs(process.argv.slice(2), readVersion()) - switch (invocation.mode) { - case 'profile': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - args: invocation.args, - }) - break - } - case 'plugin': { - const { runPlugin } = await import('./plugin.ts') - process.exit(runPlugin(invocation.profile, invocation.args)) - break - } - case 'dump-config': { - const { runDumpConfig } = await import('./dump-config.ts') - runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) - break - } - default: - invocation satisfies never - throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) +switch (invocation.mode) { + case 'profile': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + args: invocation.args, + }) + break } + case 'plugin': { + const { runPlugin } = await import('./plugin.ts') + process.exit(runPlugin(invocation.profile, invocation.args)) + break + } + case 'dump-config': { + const { runDumpConfig } = await import('./dump-config.ts') + runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + break + } + default: + invocation satisfies never + throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) } diff --git a/apps/cli/src/runtime-bootstrap.ts b/apps/cli/src/runtime-bootstrap.ts new file mode 100644 index 0000000000..b45fc89f2e --- /dev/null +++ b/apps/cli/src/runtime-bootstrap.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** Packaging-only entry that keeps private runner dispatch outside the public CLI. */ + +/* v8 ignore file -- packaged-runtime smoke exercises this physical entry. */ + +const selectorName = 'DSH_SUBPROCESS_RUNNER' +const selection = process.env[selectorName] + +export {} + +if (selection === undefined) { + await import('./bin.ts') +} else { + Reflect.deleteProperty(process.env, selectorName) + const { runSelectedSubprocessRunner } = await import('@deepseek-ai/dsh-subprocess-local/runner') + await runSelectedSubprocessRunner(selection) +} diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index 51dec0dc6c..4fb14b0646 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -1,13 +1,15 @@ import { defineConfig } from 'tsdown' /** - * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`. - * The root tsdown builds only `lib/types/index.js`, so this override points at - * `lib/types/bin.js` instead; its reachable mode modules bundle with it. + * The public package bin remains `bin`; `runtime-bootstrap` is selected only + * by the Python single-file packaging pipeline. * Declarations come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ - entry: ['lib/types/bin.js'], + entry: { + bin: 'lib/types/bin.js', + 'runtime-bootstrap': 'lib/types/runtime-bootstrap.js', + }, outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 97fe5d5833..044c44ecd8 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -64,7 +64,7 @@ function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): async function stopTree(child: SubprocessHandle): Promise { child.terminate() const stopped = await child.waitForExit(AbortSignal.timeout(15_000)) - if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`) + if (!stopped) throw new Error('managed process range did not stop after termination escalation') await child.done } diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index dbed038726..7a2b2f777b 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 2ee79c8d9605ebf8c375e7bfad84063f5e1b17b9 -subprocess.zh.md: 01a837fa7613a714c3ce1d5f4ec4c29d3a632a26 +subprocess.md: 0e2adacbc5dfa4c4fa6ac922a47a81388fd75535 +subprocess.zh.md: 87e88344a7e027ae02557b0aa973f9f2ae7bacf0 diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 2ee79c8d96..0e2adacbc5 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and managed-range termination -A spawn returns a live handle synchronously; the provider may publish its process identity later. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` starts the provider's documented procedure, and `waitForExit()` observes the same provider-managed range; staged providers may use `graceMs`, while immediate providers do not delay. Consumers can build their own teardown ladders over those two operations (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). +A spawn returns a live handle synchronously while target and managed-range identities remain provider-private. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. `terminate()` starts the provider's documented procedure, and `waitForExit()` observes the same provider-managed range; staged providers may use `graceMs`, while immediate providers do not delay. Consumers can build their own teardown ladders over those two operations (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -140,12 +140,10 @@ A spawn returns a live handle synchronously; the provider may publish its proces * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Each provider documents the process identity and range it can - * observe. + * range. Each provider documents the range it can observe and its signalling + * and observation limits. */ interface SubprocessHandle { - /** Provider-published target process identifier, or undefined until it is available. */ - readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ @@ -282,9 +280,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. +- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 01a837fa76..87e88344a7 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与 managed-range 终止 -spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 启动 provider 记录的终止过程,`waitForExit()` 观察同一个 provider-managed range;分阶段 provider 可以使用 `graceMs`,立即终止的 provider 不会等待。消费方可以在这两项操作上构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 +spawn 会同步返回活动句柄,目标与受管范围标识则保留在 provider 内部。收集模式的读取器接受全流字节偏移量且从不消费,因此独立读取器不会抢走彼此的增量;管道化的流归调用方所有。`terminate()` 启动 provider 记录的终止过程,`waitForExit()` 观察同一个 provider-managed range;分阶段 provider 可以使用 `graceMs`,立即终止的 provider 不会等待。消费方可以在这两项操作上构建自己的分级清理流程;ACP 后端先关闭 stdin 的 `disposeAcpChild` 是参考实现。 ```ts type-equiv /** @@ -140,12 +140,10 @@ spawn 会同步返回活动句柄;provider 可以稍后发布其进程标识 * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Each provider documents the process identity and range it can - * observe. + * range. Each provider documents the range it can observe and its signalling + * and observation limits. */ interface SubprocessHandle { - /** Provider-published target process identifier, or undefined until it is available. */ - readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ @@ -282,9 +280,9 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: - Executable paths belong to one execution world shared with the mounted filesystem provider. -- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. +- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits. +- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits. - Disposal of the service terminates all still-running managed processes and awaits their exit. - spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits. diff --git a/knip.json b/knip.json index 3a32c320dc..8781df91f4 100644 --- a/knip.json +++ b/knip.json @@ -306,12 +306,7 @@ }, "packages/subprocess/subprocess-local": { "entry": [ - "tests/**/*.spec.ts", "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" ] }, "packages/session/session-telemetry-otel": { @@ -682,6 +677,7 @@ }, "apps/cli": { "entry": [ + "src/runtime-bootstrap.ts", "tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/profiles/**/fixtures/**/*.{ts,mjs}", diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index ae653d10c5..c16a93c533 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.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 packages/e2b/subprocess-e2b/README.md -README.md: 8d2cea1fff678d64b6a05f686be1838996ec837a -README.zh.md: 06d8c263c890802cde94f2ae50311ed8d4fc0b7a +README.md: ec81bc704ec11db85c43d27a15116c22962fb2f8 +README.zh.md: fe59299e7b737493161dbdb623248e8e76cf812a diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 8d2cea1fff..ec81bc704e 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -29,7 +29,7 @@ Use this package when the agent's shell commands and terminals should run inside ### When to choose it -Choose it when a composition already uses the E2B sandbox and you want commands and terminals to run there. Choose the local subprocess package for host execution. Tooling that needs a process id immediately — for example the ACP child backend — cannot use this package. +Choose it when a composition already uses the E2B sandbox and you want commands and terminals to run there. Choose the local subprocess package for host execution. ### Configuration @@ -71,7 +71,7 @@ This section explains the design decisions behind the provider and points at the ### Design philosophy -- **Deferred remote identity.** The synchronous seam never blocks on the network: public `pid` remains `undefined` because E2B exposes a wrapper process-group identity rather than the requested target PID. Private wrapper files publish that group identity, the direct exit code, and spill validity asynchronously. +- **Provider-private remote identity.** The synchronous seam never blocks on the network. Private wrapper files asynchronously publish a process-group identity for stdin, observation, termination, and quiescence checks, together with the direct exit code and spill validity; that identity is not the requested target PID. - **One teardown ladder.** Termination, rollback, and disposal share one process-group signal path — `SIGTERM`, then `SIGKILL` plus the SDK kill fallback — and treat proven quiescence as final. - **Environment is explicit.** Nothing from the host and nothing credential-shaped enters the sandbox implicitly; every ambient value is scrubbed and every `spec.env` entry is an explicit opt-in. @@ -91,9 +91,9 @@ This section explains the design decisions behind the provider and points at the The bootstrap resolves its own tools from the sandbox PATH, refuses any missing or non-executable path, execs through `env -i` and `setsid --wait`, publishes the process-group id and exit code to private files beneath `ctx.e2b.runtimeRoot/processes`, and redirects stdout and stderr through base64 encoders that emit a reserved completion frame; `tee` and `head -c` bound optional spill files. -### Process identity and publication +### Private process identity and publication -The synchronous seam returns a handle immediately while the command starts asynchronously. Public `pid` remains `undefined`; the wrapper publishes a private process-group ID for stdin, observation, termination, and quiescence checks, but that ID is not the requested target PID. A startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. +The synchronous seam returns a handle immediately while the command starts asynchronously. The wrapper publishes a private process-group ID for stdin, observation, termination, and quiescence checks, but that ID is not the requested target PID. A startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean. ### Environment boundary @@ -142,7 +142,6 @@ No direct invalidation: the consumer seams own any request-prefix changes; this These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a task backlog. - **The SDK still retains complete command output in host memory** — E2B `CommandHandle.stdout` and `.stderr` accumulate the base64 transport even when this adapter exposes bounded raw-byte tails, so the subprocess seam's normal host-memory bound is not achieved and transport retention is larger than the source stream. -- **Target PID is unavailable** — the public `pid` is always `undefined`; the private wrapper process-group ID is retained only for containment and is not the requested target PID. Consumers that require a numeric target PID cannot use this provider unchanged. - **Private state lives for the sandbox lifetime** — process directories and valid spill files remain under `.dsh-e2b` until the owner deletes the sandbox; this POC supplies no in-sandbox sweep. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes; real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID input, signalling, and cleanup operations but no atomic identity-bound alternative; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 06d8c263c8..fe59299e7b 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当组合已经使用 E2B 沙箱且希望命令与终端在其中运行时,选择本包。宿主执行请选择本地子进程包。需要立即获得进程 ID 的工具——例如 ACP(Agent Client Protocol)子进程后端——无法使用本包。 +当组合已经使用 E2B 沙箱且希望命令与终端在其中运行时,选择本包。宿主执行请选择本地子进程包。 ### 配置 @@ -71,7 +71,7 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, ### 设计理念 -- **延后的远程身份。** 同步 seam 从不阻塞在网络请求上:公开 `pid` 始终为 `undefined`,因为 E2B 公开的是包装层进程组身份,而不是请求目标的 PID。包装层的私有文件会异步发布该进程组身份、直接退出码与 spill 有效性。 +- **提供方私有的远程身份。** 同步 seam 从不阻塞在网络请求上。包装层的私有文件会异步发布进程组身份,供 stdin、观察、终止与完全停稳检查使用,同时发布直接退出码与 spill 有效性;该身份不是请求目标的 PID。 - **单一终止阶梯。** 终止、回滚与资源释放共享同一条进程组信号路径——先 `SIGTERM`,再 `SIGKILL` 加 SDK kill 回退——并把已证明的完全停稳视为最终状态。 - **环境必须显式。** 宿主内容与形似凭据的内容都不会隐式进入沙箱;每个环境值都会被清理,每个 `spec.env` 条目都是显式选择。 @@ -91,9 +91,9 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, 引导脚本会从沙箱 PATH 解析自身所需的工具,拒绝任何缺失或不可执行的路径,通过 `env -i` 与 `setsid --wait` 执行 exec,把进程组 ID 与退出码发布到 `ctx.e2b.runtimeRoot/processes` 下的私有文件,并把 stdout 与 stderr 重定向到带保留完成帧的 base64 编码器;`tee` 与 `head -c` 约束可选 spill 文件的大小。 -### 进程身份与发布 +### 私有进程身份与发布 -同步 seam 会立即返回句柄,同时命令异步启动。公开 `pid` 始终为 `undefined`;包装层会发布私有进程组 ID,供 stdin、观察、终止与完全停稳检查使用,但该 ID 不是请求目标的 PID。启动信号会在分配前中止环境与私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 +同步 seam 会立即返回句柄,同时命令异步启动。包装层会发布私有进程组 ID,供 stdin、观察、终止与完全停稳检查使用,但该 ID 不是请求目标的 PID。启动信号会在分配前中止环境与私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。 ### 环境边界 @@ -142,7 +142,6 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。 - **SDK 仍会在宿主内存中保留完整命令输出**:即使本适配器公开的是有界原始字节尾部,E2B `CommandHandle.stdout` 与 `.stderr` 仍会累积 base64 传输内容,因此无法达到子进程 seam 通常提供的宿主内存边界,而且传输保留量大于源数据流。 -- **无法取得目标 PID**:公开 `pid` 始终为 `undefined`;私有包装层进程组 ID 只用于 containment,并不是请求目标的 PID。需要数值目标 PID 的消费方无法原样使用本提供方。 - **私有状态随沙箱生命周期存在**:进程目录与有效的 spill 文件会留在 `.dsh-e2b` 下,直到所有者删除沙箱;本 POC 不提供沙箱内清理。 - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开;真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的输入、信号发送与清理操作,却没有与身份原子绑定的替代方案;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案继续延后。 diff --git a/packages/e2b/subprocess-e2b/src/process.ts b/packages/e2b/subprocess-e2b/src/process.ts index 641e61e2ac..ce4e91c268 100644 --- a/packages/e2b/subprocess-e2b/src/process.ts +++ b/packages/e2b/subprocess-e2b/src/process.ts @@ -225,11 +225,6 @@ export class E2BSubprocessHandle implements SubprocessHandle { if (spec.signal?.aborted === true) this.terminate() } - /** E2B does not expose the requested target process identity. */ - get pid(): number | undefined { - return undefined - } - /** @inheritdoc */ terminate(): void { if (this.quiescenceProven || this.terminationAttempt !== undefined) return diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 8af74a8abf..61b3bc44a6 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -402,12 +402,10 @@ describe('E2BSubprocessHandle', () => { KEEP: undefined, }, }), '/workspace/.dsh-e2b/processes/one') - expect(handle.pid).toBeUndefined() handle.stdin!.write('hello') handle.stdin!.end() fake.releaseStart() await flush() - expect(handle.pid).toBeUndefined() expect(fake.handle.sent.map(value => String(value))).toEqual(['hello']) expect(fake.handle.closes).toBe(1) const controlEnvs = fake.startOptions?.envs @@ -1166,7 +1164,6 @@ describe('E2BSubprocessHandle', () => { fake.backgroundError = new Error('start failed') const handle = testHandle(runtime(fake), spec(), '/runtime/fail') await expect(handle.done).rejects.toThrow('start failed') - expect(handle.pid).toBeUndefined() expect(fake.removed).toContain('/runtime/fail/environment') expect(fake.removed).toContain('/runtime/fail') await expect(handle.waitForExit()).resolves.toBe(true) @@ -1407,6 +1404,17 @@ describe('E2BSubprocessHandle', () => { await expect(absent.waitForExit()).resolves.toBe(true) }) + it('keeps polling while a running command has not published its process group yet', async () => { + const fake = new FakeSandbox() + fake.processGroupReads.push('', '4242\n') + const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group-publication', 1) + + await vi.waitFor(() => { expect(fake.processGroupReads).toEqual([]) }) + fake.finish() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).resolves.toBe(true) + }) + it('preserves publication failure and reports cleanup that cannot be verified', async () => { const fake = new FakeSandbox() fake.processGroupId = 'not-a-pid\n' @@ -1449,16 +1457,6 @@ describe('E2BSubprocessHandle', () => { await expect(observed.waitForExit()).resolves.toBe(true) }) - it('keeps the public pid unavailable after delayed private process-group publication', async () => { - const fake = new FakeSandbox() - fake.processGroupReads.push('', '4242\n') - const handle = testHandle(runtime(fake), spec(), '/runtime/delayed-group') - await vi.waitFor(() => { expect(fake.processGroupReads).toHaveLength(0) }) - expect(handle.pid).toBeUndefined() - fake.finish() - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - }) - it('handles output backpressure and contains a stderr sink failure', async () => { const fake = new FakeSandbox() const handle = testHandle(runtime(fake), spec({ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2ae2c2422d..0c7fbe08d3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2123,7 +2123,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'subprocess', summary: 'Abstract subprocess service.', - description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', + description: 'Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command\'s exit facts and may reject for spawn or provider failures.\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another\'s output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\n- SubprocessHandle.terminate (and the spec\'s abort signal) starts the provider\'s documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.', methods: [ { signature: 'abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise', @@ -5182,7 +5182,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number | undefined;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index c3fa006f40..0c70e318bb 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -131,11 +131,6 @@ export class LspConnection { this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } - /** The child's published pid, or undefined while the provider has none available. */ - get pid(): number | undefined { - return this.handle.pid - } - /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */ diff --git a/packages/lsp/lsp-stdio/tests/connection.spec.ts b/packages/lsp/lsp-stdio/tests/connection.spec.ts index 91e4bff909..37a9ef84e6 100644 --- a/packages/lsp/lsp-stdio/tests/connection.spec.ts +++ b/packages/lsp/lsp-stdio/tests/connection.spec.ts @@ -1,10 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -46,39 +44,10 @@ function connect( } describe('LspConnection', () => { - it('completes an initialize request/response round-trip and exposes a pid', async () => { + it('completes an initialize request/response round-trip', async () => { const conn = connect({}) const result = await conn.request('initialize', { capabilities: {} }) expect(result).toMatchObject({ capabilities: { hoverProvider: true } }) - expect(conn.pid).toBeGreaterThan(0) - }) - - it('projects an unavailable subprocess pid as undefined', async () => { - const direct = Promise.withResolvers() - const handle: SubprocessHandle = { - pid: undefined, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: undefined, - collected: {}, - done: direct.promise, - terminate: () => {}, - waitForExit: async () => true, - } - const conn = new LspConnection({ - command: 'language-server', - args: [], - cwd: process.cwd(), - env: {}, - maxMessageBytes: 1_000, - maxStderrBytes: 1_000, - killGraceMs: 100, - configuration: null, - }, () => handle, () => Promise.resolve(null)) - - expect(conn.pid).toBeUndefined() - direct.resolve({ exitCode: 0, signal: null }) - await conn.closed }) it('forwards explicit DSH_* env entries to the child', async () => { diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index ffdd3c74cf..28634053b0 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -233,15 +233,13 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) - it('awaits process exit before rejecting a request write failure', async () => { + it('finishes teardown before rejecting a request write failure', async () => { const instance = makeInstance({}, { shutdownTimeoutMs: 100, killGraceMs: 100, }, failingWriter('textDocument/definition')) - // The pid is observed only to prove the owned subprocess reached quiescence before rejection. - const pid = (instance as unknown as { connection: { pid: number } }).connection.pid await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/) - expect(processAlive(pid)).toBe(false) + expect(instance.dead).toBe(true) }) it('rejects when the server lacks the operation capability', async () => { diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 4fe1a4795f..54b3938f91 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -564,7 +564,6 @@ describe('background sandbox facts', () => { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), } vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ - pid: undefined, stdin: undefined, stdout: undefined, stderr: undefined, diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 51639dd14a..2adf6dddca 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -163,7 +163,6 @@ describe('spawn construction (pure, every platform)', () => { override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { this.specs.push(spec) return { - pid: undefined, stdin: undefined, stdout: undefined, stderr: undefined, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index a362932b49..ad6335a93f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -84,7 +84,6 @@ async function waitForFile(file: string, timeoutMs = 5000): Promise { function rejectFinalExitWait(child: SubprocessHandle, message: string): SubprocessHandle { return { - pid: child.pid, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, @@ -108,7 +107,6 @@ function rejectFinalExitWaitAfterExit(child: SubprocessHandle, message: string): function tapBoundedExitWait(child: SubprocessHandle, onWait: () => void): SubprocessHandle { return { - pid: child.pid, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, @@ -130,7 +128,6 @@ function replaceProtocolStreams( if (child.stdin === undefined) throw new Error('expected piped child stdin') stdin.pipe(child.stdin) return { - pid: child.pid, stdin, stdout, stderr: child.stderr, @@ -168,7 +165,6 @@ function closeProtocolOnPrompt(child: SubprocessHandle, onClose: () => void = () function replaceProcessOutcome(child: SubprocessHandle, outcome: SubprocessOutcome): SubprocessHandle { return { - pid: child.pid, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, @@ -179,19 +175,6 @@ function replaceProcessOutcome(child: SubprocessHandle, outcome: SubprocessOutco } } -function hideProcessPid(child: SubprocessHandle): SubprocessHandle { - return { - pid: undefined, - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, - collected: child.collected, - done: child.done, - terminate: () => { child.terminate() }, - waitForExit: (signal?: AbortSignal) => child.waitForExit(signal), - } -} - describe('acpStopReason', () => { it('maps each ACP stop reason to the harness vocabulary', () => { expect(acpStopReason('end_turn')).toBe('completed') @@ -323,7 +306,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', .mockResolvedValueOnce(true) const terminate = vi.fn() const child: SubprocessHandle = { - pid: undefined, stdin: new PassThrough(), stdout: undefined, stderr: undefined, @@ -345,7 +327,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', .mockRejectedValueOnce(initialFailure) .mockRejectedValueOnce(finalFailure) const child: SubprocessHandle = { - pid: undefined, stdin: new PassThrough(), stdout: undefined, stderr: undefined, @@ -372,7 +353,6 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', const stdin = new PassThrough() const stdout = new PassThrough() const child: SubprocessHandle = { - pid: undefined, stdin, stdout, stderr: undefined, @@ -747,7 +727,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_INITIALIZE: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, - spawn: spec => hideProcessPid(spawnSubprocess(spec)), + spawn: spec => spawnSubprocess(spec), }).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( @@ -764,7 +744,7 @@ describe('dsh-subagent-acp', () => { env: {}, disposeEofGraceMs: 50, disposeGraceMs: 50, - spawn: spec => closeProtocolImmediately(hideProcessPid(spawnSubprocess(spec))), + spawn: spec => closeProtocolImmediately(spawnSubprocess(spec)), }).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( @@ -785,7 +765,6 @@ describe('dsh-subagent-acp', () => { disposeEofGraceMs: 50, disposeGraceMs: 50, spawn: () => ({ - pid: undefined, stdin, stdout, stderr: undefined, @@ -1196,7 +1175,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_TEXT: 'partial answer', MOCK_CRASH_AFTER_CHUNK: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, - spawn: spec => hideProcessPid(spawnSubprocess(spec)), + spawn: spec => spawnSubprocess(spec), }) const result = await run.result expect(result).toEqual({ @@ -1224,7 +1203,6 @@ describe('dsh-subagent-acp', () => { const child = spawnSubprocess(spec) realChild = child return closeProtocolOnPrompt({ - pid: undefined, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 4253308fc0..7e4acd18b0 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -100,7 +100,6 @@ function errorCause(value: unknown): Error | undefined { } interface FakeChildOptions { - readonly pid?: number | undefined readonly exitOnTerminate?: boolean readonly waitForExitError?: Error readonly doneError?: Error @@ -169,7 +168,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) }) const handle: SubprocessHandle = { - pid: Object.hasOwn(options, 'pid') ? options.pid : 1234, stdin, stdout, stderr: undefined, @@ -829,7 +827,7 @@ describe('official spawn projection', () => { }) it('emits spawn errors', async () => { - const child = fakeChild({ pid: undefined }) + const child = fakeChild() const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() const removed = vi.fn() @@ -1480,7 +1478,6 @@ describe('run publication, cancellation, and settlement', () => { { code: 'EACCES', path: '/sdk/claude' }, ) const failedSpawn = fakeChild({ - pid: undefined, doneError: spawnError, }) const failed = fakeRun([], undefined, failedSpawn) @@ -1495,7 +1492,6 @@ describe('run publication, cancellation, and settlement', () => { const failedSpawnAbort = new AbortController() const cancelledFailedSpawn = fakeChild({ - pid: undefined, doneError: spawnError, }) const cancelledFailedClose = vi.fn() @@ -1515,7 +1511,6 @@ describe('run publication, cancellation, and settlement', () => { throw cancelledFailedSpawnCloseError }) const cancelledFailedSpawnWithCloseFailure = fakeChild({ - pid: undefined, doneError: spawnError, }) const failedSpawnAbortWithCloseFailure = new AbortController() @@ -1548,7 +1543,6 @@ describe('run publication, cancellation, and settlement', () => { const failedSpawnCloseError = new Error('query close failed') const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) const failedSpawnWithCloseFailure = fakeChild({ - pid: undefined, doneError: spawnError, }) queryMock.mockImplementationOnce(({ options }) => { @@ -1602,7 +1596,7 @@ describe('run publication, cancellation, and settlement', () => { new Error('spawn /sdk/claude ENOENT'), { code: 'ENOENT', path: '/sdk/claude' }, ) - const child = fakeChild({ pid: undefined }) + const child = fakeChild() const close = vi.fn() queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 57422446ea..c43fd07ed1 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -140,7 +140,6 @@ class ProtocolPeer { } interface FakeChildOptions { - readonly pid?: number | undefined readonly exitOnTerminate?: boolean readonly doneError?: Error readonly waitForExitError?: Error @@ -212,7 +211,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { }) }) const handle: SubprocessHandle = { - pid: Object.hasOwn(options, 'pid') ? options.pid : 1234, stdin: toChild, stdout: fromChild, stderr, @@ -1891,7 +1889,6 @@ describe('run lifecycle and quiescence', () => { await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') const asyncSpawnFailureChild = fakeChild({ - pid: undefined, doneError: new Error('SECRET_TOKEN async spawn failure'), }) const asyncSpawnFailure = startCodexRun( @@ -2277,9 +2274,8 @@ describe('disposeCodexChild', () => { .resolves.toBeUndefined() }) - it('still runs idempotent cleanup when the target pid was never published', async () => { + it('still runs idempotent cleanup when target startup rejects', async () => { const child = fakeChild({ - pid: undefined, doneError: new Error('spawn failed'), }) const wire = defaultWire(child) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 33acf98399..6c586d7e32 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 5e127164840f9d7f222031c16aba8028c93a4d98 -README.zh.md: 768c64b99b819cd610f3c54f3957f6bb86c04c28 +README.md: eb38e2f45bad8e4b985177c656e5cb2762a86654 +README.zh.md: e7fb4d083bc52c27e39750486d98a41b404121dc diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 5e12716484..eb38e2f45b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina ### Shutdown behavior -Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, lets Windows Job ownership close with the runner's parent connection, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. +Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. ### What can go wrong @@ -80,9 +80,8 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd capability checks, scope launch, signalling, and quiescence | | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job capability checks and helper launch | | [`src/runner-launch.ts`](src/runner-launch.ts) | Source, built, and packaged private-runner selection | -| [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux ordinary target runner and Windows Job runner | -| [`src/runner-protocol.ts`](src/runner-protocol.ts) | Private per-spawn launch and result facts | -| [`src/windows-stdio.ts`](src/windows-stdio.ts) | Parent-side named-pipe endpoints for Windows ordinary stdio | +| [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux one-shot exec bootstrap and Windows Job runner | +| [`src/runner-protocol.ts`](src/runner-protocol.ts) | Strict Linux launch/startup files and Windows IPC messages | | [`src/terminal.ts`](src/terminal.ts) | `node-pty` handle: Linux scope attachment, foreground inspection, and fallback cleanup | | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX process-tree and session inspection | | [`src/windows-inspector.ts`](src/windows-inspector.ts) | Windows Toolhelp32 process-table inspection via koffi | @@ -90,7 +89,7 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux ### Main flow -A spawn builds the scrubbed child environment, selects containment before the user command can run, and returns a handle without waiting for native target publication. Linux and Windows ordinary runners publish the real target PID, Node-shaped startup failure, and direct outcome; `pid` remains `undefined` until that target fact exists. `done` settles the direct command after a bounded non-inherited output drain, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty. Linux terminal launch passes scoped argv directly to `node-pty` and adds no runner. +A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment before replacing itself with the target. Windows ordinary launches use one IPC channel for the start request, termination, and strict direct result; the runner creates the target suspended, assigns it to the Job, and only then resumes it. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty. ### Safety invariants @@ -130,7 +129,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native selection has bounded probe and runner costs** — Linux rechecks the live user manager before every eligible ordinary or terminal spawn. Successful stable systemd-scope and ordinary-runner probes are cached for the provider lifetime, failed probes are retried, and terminal selection never probes the ordinary runner. Windows likewise caches only a successful Job-runner probe. Each synchronous probe command has a 5-second bound and completes before the user command can run. A native ordinary handle returns before target publication: `pid` starts as `undefined` and updates from asynchronously polled runner events, while `done` carries target startup failure or direct outcome. There is no target-publication timeout; a runner that remains alive without a terminal event leaves `pid` undefined and `done` pending until it exits or the range is terminated. Each supported native ordinary command keeps one runner process alive until the OS-owned range is empty, and Windows additionally creates private per-spawn named-pipe endpoints. Linux terminal launch passes the scoped argv directly to `node-pty` and adds no runner. Runner events are polled asynchronously every 100 ms and Linux scope state every 200 ms. +- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 768c64b99b..e7fb4d083b 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 关闭行为 -正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,让 Windows Job 所有权随 runner 的父连接关闭,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。 +正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。 ### 可能出错的地方 @@ -80,9 +80,8 @@ kind: "package-reference" | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd 能力检查、scope 启动、信号发送与完全停稳 | | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job 能力检查与 helper 启动 | | [`src/runner-launch.ts`](src/runner-launch.ts) | source、built 与 packaged 私有 runner 选择 | -| [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux 普通命令 target runner 与 Windows Job runner | -| [`src/runner-protocol.ts`](src/runner-protocol.ts) | 每次 spawn 的私有启动与结果事实 | -| [`src/windows-stdio.ts`](src/windows-stdio.ts) | Windows 普通命令 stdio 的 parent 侧 named-pipe endpoint | +| [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux 一次性 exec bootstrap 与 Windows Job runner | +| [`src/runner-protocol.ts`](src/runner-protocol.ts) | 严格的 Linux 启动/错误文件与 Windows IPC 消息 | | [`src/terminal.ts`](src/terminal.ts) | `node-pty` 终端句柄:Linux scope 绑定、前台检查与 fallback 清理 | | [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX 进程树与会话检查 | | [`src/windows-inspector.ts`](src/windows-inspector.ts) | 经 koffi 的 Windows Toolhelp32 进程表检查 | @@ -90,7 +89,7 @@ kind: "package-reference" ### 主流程 -一次 spawn 会构建清理后的子进程环境,在用户命令可能运行前选择 containment,并在无需等待 native target 发布的情况下返回句柄。Linux 与 Windows 普通 runner 会发布真实 target PID、Node 风格启动失败与 direct outcome;在 target 事实出现前,`pid` 保持 `undefined`。`done` 会在有界的非继承输出排空后结算直接命令,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。Linux 终端启动把 scoped argv 直接交给 `node-pty`,不增加 runner。 +一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境,再用目标程序替换自身。Windows 普通命令使用同一条 IPC 通道传递启动请求、终止命令与严格的直接结果;runner 以 suspended 状态创建目标,将其加入 Job 后才恢复运行。`done` 会在直接命令及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。 ### 安全不变式 @@ -130,7 +129,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。 - **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native 选择具有有界的探测与 runner 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查存活的 user manager。稳定的 systemd scope 与普通 runner 探测只在成功后按提供方生命周期缓存,失败探测会重试,而且终端选择绝不会探测普通 runner。Windows 同样只缓存成功的 Job runner 探测。每条同步探测命令的上限为 5 秒,并在用户命令可能运行前完成。native 普通句柄会在 target 发布前返回:`pid` 起初为 `undefined`,随后由异步轮询的 runner 事件更新;`done` 则承载 target 启动失败或 direct outcome。target 发布没有单独的超时;runner 如果保持存活却始终没有终态事件,`pid` 会保持为 `undefined`,`done` 也会保持待定,直到 runner 退出或该范围被终止。每条受支持的 native 普通命令都会保留一个 runner 进程,直到 OS 所有的范围为空;Windows 还会创建私有的每次 spawn named-pipe endpoint。Linux 终端启动会把 scoped argv 直接交给 `node-pty`,不增加 runner。runner 事件每 100 ms、Linux scope 状态每 200 ms 异步轮询。 +- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 - **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。 - **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index d207c90f50..d5d181abbb 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -22,14 +22,18 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./runner": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/runner.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/spawn-runner.js", - "lib/runner-protocol-*.js", + "lib/runner.js", + "lib/runner-*.js", "scripts/ensure-spawn-helper.mjs", "lib/types/**/*.d.ts" ], diff --git a/packages/subprocess/subprocess-local/src/bin.ts b/packages/subprocess/subprocess-local/src/bin.ts index 0b7e50243b..02393125f8 100644 --- a/packages/subprocess/subprocess-local/src/bin.ts +++ b/packages/subprocess/subprocess-local/src/bin.ts @@ -1,11 +1,36 @@ -/** Thin process entry for the ordinary subprocess native runner. */ +/** Thin executable/importable entry for the provider-private runner core. */ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { consumeRunnerSelection } from './runner-launch.ts' import { reportSpawnRunnerFailure, runSpawnRunner } from './spawn-runner.ts' -const argv = process.argv.slice(2) -try { - await runSpawnRunner(argv) -} catch (error: unknown) { - reportSpawnRunnerFailure(argv, error) - process.exitCode = 127 +/** + * Run a selector already removed by a packaging bootstrap. + * @param selection - private runner selector or Linux launch-request locator. + * @param argv - private runner arguments beginning with the target delimiter. + */ +export async function runSelectedSubprocessRunner( + selection: string, + argv: readonly string[] = process.argv.slice(2), +): Promise { + try { + await runSpawnRunner(selection, argv) + } catch (error) { + await reportSpawnRunnerFailure(selection, error) + } +} + +function isExecutedEntry(): boolean { + const entry = process.argv[1] + return entry !== undefined && pathToFileURL(resolve(entry)).href === import.meta.url +} + +if (isExecutedEntry()) { + const selection = consumeRunnerSelection() + if (selection === undefined) { + process.exitCode = 127 + } else { + await runSelectedSubprocessRunner(selection) + } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5d3116a436..39f7f561b7 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -32,11 +32,10 @@ import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { launchLinuxScope, prepareLinuxTerminalScope, - probeLinuxRunner, - probeLinuxScope, - probeLinuxUserManager, + probeLinuxNative, } from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' +import { targetEnvironment, validateTerminalTarget } from './runner-launch.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -57,12 +56,6 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { internals: SpawnInternals = {} /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */ private fallbackWarningIssued = false - /** Stable Linux scope features, cached only after a successful probe. */ - private linuxScopeCapabilityConfirmed = false - /** Stable ordinary-runner availability, cached only after a successful probe. */ - private linuxRunnerCapabilityConfirmed = false - /** Stable Windows Job support, cached only after a successful probe. */ - private windowsJobCapabilityConfirmed = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -167,13 +160,16 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { validateSubprocessSpec(spec) + const env = targetEnvironment(spec) const containmentMode = this.selectContainmentMode('ordinary') let handle: LocalSubprocessHandle if (containmentMode === 'fallback') { handle = spawnSubprocess(spec, this.internals) } else { const binding = prepareManagedProcessBinding(this.internals) - const launch = containmentMode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec) + const launch = containmentMode === 'linux-scope' + ? launchLinuxScope(spec, env) + : launchWindowsJob(spec, env) handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) @@ -193,24 +189,13 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const platform = this.internals.platform ?? process.platform let fallbackReason: string | undefined if (platform === 'linux') { - const managerAvailable = probeLinuxUserManager() - if (managerAvailable && !this.linuxScopeCapabilityConfirmed) { - this.linuxScopeCapabilityConfirmed = probeLinuxScope() - } - if (managerAvailable && this.linuxScopeCapabilityConfirmed) { - if (kind === 'terminal') return 'linux-scope' - if (!this.linuxRunnerCapabilityConfirmed) { - this.linuxRunnerCapabilityConfirmed = probeLinuxRunner() - } - if (this.linuxRunnerCapabilityConfirmed) return 'linux-scope' - fallbackReason = 'the private Linux subprocess runner is unavailable' - } + const available = this.internals.linuxNativeAvailable?.() ?? probeLinuxNative() + if (available) return 'linux-scope' + fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable' } if (kind === 'ordinary' && platform === 'win32') { - if (!this.windowsJobCapabilityConfirmed) { - this.windowsJobCapabilityConfirmed = probeWindowsJob() - } - if (this.windowsJobCapabilityConfirmed) return 'windows-job' + const available = this.internals.windowsNativeAvailable?.() ?? probeWindowsJob() + if (available) return 'windows-job' } this.warnFallback(platform, kind, fallbackReason) return 'fallback' @@ -245,32 +230,50 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { throw new Error('subprocess-local: terminal argv must contain a program') } spec.signal?.throwIfAborted() + const env = validateTerminalTarget(spec) const options: IPtyForkOptions = { name: 'dumb', rows: spec.rows, cols: spec.cols, cwd: spec.cwd, - env: childEnv(spec.env), + env, } const inspector = this.terminalInspector ?? createProcessInspector() const containmentMode = this.selectContainmentMode('terminal') const scope = containmentMode === 'linux-scope' - ? prepareLinuxTerminalScope(spec.argv) + ? prepareLinuxTerminalScope(spec, env) : undefined - const terminal = nodePty.spawn( - scope?.command ?? file, - scope?.args ?? [...spec.argv.slice(1)], - options, - ) + if (scope !== undefined) { + options.cwd = scope.cwd + options.env = scope.env + } + let terminal: nodePty.IPty + try { + terminal = nodePty.spawn( + scope?.command ?? file, + scope?.args ?? [...spec.argv.slice(1)], + options, + ) + } catch (error) { + scope?.cleanup() + throw error + } // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published. let handle: LocalTerminalHandle | undefined - const owner = scope?.bindOwner(() => handle?.running ?? true) + const owner = scope?.bindOwner({ + running: () => handle?.running ?? true, + signal: (signal) => { + try { terminal.kill(signal) } catch { /* Direct process already exited. */ } + }, + }) handle = new LocalTerminalHandle( terminal, inspector, spec.graceMs, this.internals.platform ?? process.platform, owner, + scope?.resolveOutcome, + scope?.cleanup, ) this.terminals.add(handle) const release = async (): Promise => { diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 9683052223..b53a38cad9 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -1,21 +1,30 @@ /** Linux user-systemd scope launch and managed-range ownership. */ -import { randomBytes } from 'node:crypto' import { execFile, spawn, spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { existsSync } from 'node:fs' import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessOutcome, + SubprocessSpawnSpec, + SubprocessTerminalSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { DirectResultUnavailableError, observeChildLifecycle } from './managed-owner.ts' -import { childEnv } from './spawn.ts' import { - cleanupAfterRunner, - type RunnerInvocation, - runnerDirectResult, - runnerFiles, + cleanupLinuxLaunchFiles, + createLinuxLaunchFiles, + deserializeRunnerError, + readLinuxStartupError, +} from './runner-protocol.ts' +import type { LinuxLaunchFiles } from './runner-protocol.ts' +import { + runnerEnvironment, + runnerInvocationAvailable, runnerStdio, spawnRunnerInvocation, } from './runner-launch.ts' -import { cleanupRunnerFiles } from './runner-protocol.ts' +import type { RunnerInvocation } from './runner-launch.ts' +import { childEnv } from './spawn.ts' /** Test seams for systemd command execution. */ export interface LinuxScopeInternals { @@ -25,6 +34,9 @@ export interface LinuxScopeInternals { systemdRun?: string systemctl?: string runnerInvocation?: RunnerInvocation + resolveRunnerInvocation?: () => RunnerInvocation + runnerAvailable?: (invocation: RunnerInvocation) => boolean + execveAvailable?: boolean } interface SystemctlResult { @@ -35,22 +47,22 @@ interface SystemctlResult { } const SYSTEMCTL_TIMEOUT_MS = 5_000 -const SCOPE_POLL_INTERVAL_MS = 200 +const SCOPE_POLL_INTERVAL_MS = 50 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu function systemctlEnv(): NodeJS.ProcessEnv { - return childEnv({ LC_ALL: 'C' }) + return childEnv({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' }) } function querySystemctl(command: string, args: readonly string[]): Promise { - return new Promise((resolve) => { + return new Promise((resolveResult) => { execFile(command, [...args], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS, }, (error, stdout, stderr) => { const code = error === null ? 0 : (error as Error & { code?: string | number }).code - resolve({ + resolveResult({ status: typeof code === 'number' ? code : null, stdout, stderr, @@ -61,54 +73,47 @@ function querySystemctl(command: string, args: readonly string[]): Promise | undefined private killFailure: Error | undefined constructor( private readonly unit: string, + private readonly files: LinuxLaunchFiles, + private readonly direct: DirectRange, private readonly systemctl: string, private readonly runSync: typeof spawnSync, private readonly query: (command: string, args: readonly string[]) => Promise, - private readonly launcherRunning: () => boolean, - private readonly onForceKillAttempt: () => void, ) {} signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return + this.direct.signal(signal) const result = this.runSync(this.systemctl, [ '--user', 'kill', @@ -153,20 +172,38 @@ class SystemdScopeOwner implements BoundProcessOwner { `--signal=${signal}`, this.unit, ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) - if (signal === 'SIGKILL' && result.error === undefined) this.onForceKillAttempt() if (result.error === undefined && result.status === 0) { if (signal === 'SIGKILL') this.killFailure = undefined return } if (signal === 'SIGKILL') { const output = `${result.stdout}\n${result.stderr}` - this.killFailure = result.error ?? new Error( - `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`, - ) + if (!MISSING_UNIT.test(output)) { + this.killFailure = result.error ?? new Error( + `systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`, + ) + } } } - private async active(): Promise { + terminateForHostExit(): void { + if (this.stopped) return + try { this.direct.signal('SIGKILL') } catch { /* Continue with the native owner. */ } + try { + this.runSync(this.systemctl, [ + '--user', + 'kill', + '--kill-whom=all', + '--signal=SIGKILL', + this.unit, + ], { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) + } catch { + // Host exit cannot report one range; the runtime continues with the rest. + } + } + + private async rangeActive(): Promise { + if (!existsSync(this.files.requestPath)) this.established = true const result = await this.query(this.systemctl, [ '--user', 'show', @@ -175,19 +212,23 @@ class SystemdScopeOwner implements BoundProcessOwner { '--value', ]) const output = `${result.stdout}\n${result.stderr}` - if (result.status !== 0) { - if (MISSING_UNIT.test(output)) { - if (!this.launcherRunning()) return false - } else { - if (result.error !== undefined) throw result.error - throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) - } - } else { + if (result.status === 0) { + this.established = true const state = result.stdout.trim() if (state === 'inactive' || state === 'failed') return false if (state !== 'active' && state !== 'activating' && state !== 'deactivating') { throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) } + if (this.killFailure !== undefined) throw this.killFailure + return true + } + if (!MISSING_UNIT.test(output)) { + if (result.error !== undefined) throw result.error + throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) + } + if (this.established) return false + if (!this.direct.running() && existsSync(this.files.requestPath)) { + throw new Error(`subprocess scope ${this.unit} ended before consuming its launch request`) } if (this.killFailure !== undefined) throw this.killFailure return true @@ -196,7 +237,7 @@ class SystemdScopeOwner implements BoundProcessOwner { async waitForExit(): Promise { if (this.stopped) return this.observation ??= (async () => { - while (await this.active()) await sleepMs(SCOPE_POLL_INTERVAL_MS) + while (await this.rangeActive()) await sleepMs(SCOPE_POLL_INTERVAL_MS) this.stopped = true })().catch((error: unknown) => { this.observation = undefined @@ -204,119 +245,168 @@ class SystemdScopeOwner implements BoundProcessOwner { }) await this.observation } + + cleanup(): void { + cleanupLinuxLaunchFiles(this.files) + } } -/** Prepared node-pty argv plus the owner for the exact transient scope it enters. */ +function scopeArgs(unitBase: string, invocation: RunnerInvocation, argv: readonly string[]): string[] { + return [ + '--user', + '--scope', + '--quiet', + '--collect', + '--expand-environment=no', + `--unit=${unitBase}`, + '--', + ...invocation, + '--', + ...argv, + ] +} + +function directOutcome( + child: ReturnType, + files: LinuxLaunchFiles, +): Promise { + return new Promise((resolveOutcome, rejectOutcome) => { + let settled = false + child.once('error', (error) => { + if (settled) return + settled = true + rejectOutcome(error) + }) + child.once('exit', (exitCode, signal) => { + if (settled) return + settled = true + try { + const startup = readLinuxStartupError(files.startupErrorPath) + if (startup !== undefined) { + rejectOutcome(deserializeRunnerError(startup.error)) + return + } + if (existsSync(files.requestPath)) { + rejectOutcome(new Error('subprocess scope exited before its bootstrap consumed the launch request')) + return + } + resolveOutcome({ exitCode, signal }) + } catch (error) { + /* v8 ignore next -- Node filesystem operations throw Error instances. */ + const failure = error instanceof Error ? error : new Error(String(error)) + rejectOutcome(failure) + } + }) + }) +} + +function signalChildGroup(child: ReturnType, signal: 'SIGTERM' | 'SIGKILL'): void { + if (child.pid === undefined) return + try { + process.kill(-child.pid, signal) + } catch { + try { child.kill(signal) } catch { /* The direct process already exited. */ } + } +} + +/** Linux PTY invocation and owner for the exact one-shot scope/bootstrap. */ export interface LinuxTerminalScopeLaunch { command: string args: string[] - bindOwner(launcherRunning: () => boolean): BoundProcessOwner + cwd: string + env: NodeJS.ProcessEnv + bindOwner: (direct: DirectRange) => BoundProcessOwner + resolveOutcome: (outcome: SubprocessOutcome) => SubprocessOutcome + cleanup: () => void } /** - * Wrap one terminal argv directly in a transient user-systemd scope. - * @param argv - original terminal command and arguments. - * @param internals - injected systemd commands used by tests. - * @returns the node-pty command, literal arguments, and owner binding for the same unit. + * Prepare one Linux PTY scope using the same launch request and bootstrap core. + * @param spec - terminal target request. + * @param targetEnv - validated complete target environment. + * @param internals - optional runner and systemd seams used by tests. + * @returns invocation facts and ownership callbacks for node-pty. */ export function prepareLinuxTerminalScope( - argv: readonly string[], + spec: SubprocessTerminalSpawnSpec, + targetEnv: Record, internals: LinuxScopeInternals = {}, ): LinuxTerminalScopeLaunch { - const runSync = internals.spawnSync ?? spawnSync - const query = internals.systemctlQuery ?? querySystemctl - const systemdRun = internals.systemdRun ?? 'systemd-run' - const systemctl = internals.systemctl ?? 'systemctl' + const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() + const files = createLinuxLaunchFiles({ cwd: spec.cwd, env: targetEnv }) const unitBase = unitStem('dsh-terminal') return { - command: systemdRun, - args: [ - '--user', - '--scope', - '--quiet', - '--collect', - '--expand-environment=no', - `--unit=${unitBase}`, - '--', - ...argv, - ], - bindOwner: launcherRunning => new SystemdScopeOwner( + command: internals.systemdRun ?? 'systemd-run', + args: scopeArgs(unitBase, invocation, spec.argv), + cwd: process.cwd(), + env: runnerEnvironment(files.requestPath), + bindOwner: direct => new SystemdScopeOwner( `${unitBase}.scope`, - systemctl, - runSync, - query, - launcherRunning, - () => {}, + files, + direct, + internals.systemctl ?? 'systemctl', + internals.spawnSync ?? spawnSync, + internals.systemctlQuery ?? querySystemctl, ), + resolveOutcome: (outcome) => { + const startup = readLinuxStartupError(files.startupErrorPath) + if (startup !== undefined) throw deserializeRunnerError(startup.error) + if (existsSync(files.requestPath)) { + throw new Error('terminal scope exited before its bootstrap consumed the launch request') + } + return outcome + }, + cleanup: () => { cleanupLinuxLaunchFiles(files) }, } } /** - * Launch one direct command inside a transient user scope. - * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. - * @param internals - injected command runners used by platform tests. - * @returns wrapper streams, target outcome, and the bound scope owner. + * Launch one ordinary target inside a transient user scope. + * @param spec - ordinary target request. + * @param targetEnv - validated complete target environment. + * @param internals - optional runner and systemd seams used by tests. + * @returns direct streams, result, and managed-scope owner. */ export function launchLinuxScope( spec: SubprocessSpawnSpec, + targetEnv: Record, internals: LinuxScopeInternals = {}, ): ManagedProcessLaunch { - const run = internals.spawn ?? spawn - const runSync = internals.spawnSync ?? spawnSync - const query = internals.systemctlQuery ?? querySystemctl - const systemdRun = internals.systemdRun ?? 'systemd-run' - const systemctl = internals.systemctl ?? 'systemctl' const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() - const files = runnerFiles(spec) + const files = createLinuxLaunchFiles({ cwd: spec.cwd, env: targetEnv }) const unitBase = unitStem('dsh-subprocess') let child: ReturnType try { - child = run(systemdRun, [ - '--user', - '--scope', - '--quiet', - '--collect', - '--expand-environment=no', - `--unit=${unitBase}`, - '--', - ...invocation, - '--mode', - 'node', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ], { - env: childEnv(), - stdio: runnerStdio(spec), + child = (internals.spawn ?? spawn)(internals.systemdRun ?? 'systemd-run', scopeArgs( + unitBase, + invocation, + spec.argv, + ), { + cwd: process.cwd(), + env: runnerEnvironment(files.requestPath), + stdio: runnerStdio(spec, false), + detached: true, }) } catch (error) { - cleanupRunnerFiles(files) + cleanupLinuxLaunchFiles(files) throw error } - const lifecycle = observeChildLifecycle(child) - let forceKillAttempted = false const owner = new SystemdScopeOwner( `${unitBase}.scope`, - systemctl, - runSync, - query, - () => child.pid !== undefined && child.exitCode === null && child.signalCode === null, - () => { forceKillAttempted = true }, + files, + { + running: () => child.pid !== undefined && child.exitCode === null && child.signalCode === null, + signal: (signal) => { signalChildGroup(child, signal) }, + }, + internals.systemctl ?? 'systemctl', + internals.spawnSync ?? spawnSync, + internals.systemctlQuery ?? querySystemctl, ) - const result = runnerDirectResult(child, files, lifecycle.exited) - const direct = result.direct.catch(async (error: unknown): Promise => { - if (!forceKillAttempted || !(error instanceof DirectResultUnavailableError)) throw error - await owner.waitForExit() - return { exitCode: null, signal: 'SIGKILL' } - }) - cleanupAfterRunner(files, direct, lifecycle.closed) return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, - get pid() { return result.pid }, - direct, + direct: directOutcome(child, files), owner, } } diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index 5d5bd0b58f..da438a37c8 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -1,20 +1,18 @@ /** Minimal managed-range ownership bound to one ordinary subprocess handle. */ -import type { ChildProcess } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' -/** Direct target started, but its runner closed before publishing an exit event. */ -export class DirectResultUnavailableError extends Error { - override name = 'DirectResultUnavailableError' -} - /** Platform owner used by termination and whole-range settlement. */ export interface BoundProcessOwner { - /** Signal the established managed range; a confirmed-stopped owner stays inert. */ - signal(signal: 'SIGTERM' | 'SIGKILL'): void - /** Wait for the same managed range to become empty; reject when its owner cannot be observed. */ + /** Signal the managed range; `cancellationReason` is used only before Windows target commit. */ + signal(signal: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void + /** Wait for the same managed range to become empty; reject when it cannot be observed. */ waitForExit(): Promise + /** Synchronously force final termination during JavaScript-observable host exit. */ + terminateForHostExit(): void + /** Release provider-private protocol artifacts after outcome and range settlement. */ + cleanup?(): void } /** Platform launch facts consumed by the common stdio and result lifecycle. */ @@ -22,45 +20,21 @@ export interface ManagedProcessLaunch { stdin: Writable | null stdout: Readable | null stderr: Readable | null - pid: number | undefined direct: Promise owner: BoundProcessOwner -} - -/** - * Observe runner exit separately from inherited stdio closure. - * @param child - native wrapper process. - * @returns promises for wrapper exit/error and full stdio closure. - */ -export function observeChildLifecycle(child: ChildProcess): { - exited: Promise - closed: Promise -} { - const exited = Promise.withResolvers() - const closed = Promise.withResolvers() - child.once('error', () => { - // runnerDirectResult reports the wrapper failure through the handle. - exited.resolve() - }) - child.once('exit', () => { exited.resolve() }) - child.once('close', () => { - exited.resolve() - closed.resolve() - }) - return { exited: exited.promise, closed: closed.promise } + /** Rejects if runner ownership is lost before `.done` completes its stdio barrier. */ + infrastructureFailure?: Promise } /** * Apply an optional abort bound to one shared wait promise. - * @param pending - authoritative platform wait. - * @param signal - optional caller bound. - * @returns true on completion, false when the bound aborts first. + * @param pending - managed-range wait shared by all callers. + * @param signal - optional caller cancellation signal. + * @returns whether the managed-range wait completed before cancellation. */ export async function waitWithAbort(pending: Promise, signal?: AbortSignal): Promise { if (signal?.aborted) { - void pending.catch(() => { - // This caller declined the wait; a later caller still observes the cached rejection. - }) + void pending.catch(() => {}) return false } if (signal === undefined) { diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 3ab9f0abdd..699c4ec85c 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,148 +1,151 @@ -/** Parent-side launch and direct-result transport for native runners. */ +/** Parent-side invocation and bootstrap state for the private native runner. */ -import type { ChildProcess, StdioOptions } from 'node:child_process' -import { extname } from 'node:path' +import type { StdioOptions } from 'node:child_process' +import { accessSync, constants as fsConstants } from 'node:fs' +import { extname, isAbsolute } from 'node:path' +import { inspect } from 'node:util' import { fileURLToPath } from 'node:url' -import { setTimeout as sleepMs } from 'node:timers/promises' -import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { - cleanupRunnerFiles, - createRunnerFiles, - deserializeSpawnError, - readRunnerEventsAsync, -} from './runner-protocol.ts' -import type { RunnerEvent, RunnerFiles, RunnerRequest } from './runner-protocol.ts' -import { DirectResultUnavailableError } from './managed-owner.ts' +import type { SubprocessSpawnSpec, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { childEnv } from './spawn.ts' -const RUNNER_EVENT_POLL_MS = 100 -const PACKAGED_RUNNER_ARG = '--dsh-internal-subprocess-runner' +/** The one private environment variable consumed before target state is restored. */ +export const SUBPROCESS_RUNNER_ENV = 'DSH_SUBPROCESS_RUNNER' as const -/** Non-empty command tuple used to launch the private native runner. */ +/** Sentinel used by the packaged bootstrap for the Windows IPC runner. */ +export const WINDOWS_RUNNER_SELECTION = 'windows' as const + +/** Non-empty command tuple used to launch the private runner entry. */ export type RunnerInvocation = [string, ...string[]] /** - * Resolve the runner entry from the current module's source or built plane. - * @returns Node executable and runner argv prefix. + * Resolve the source, built, or packaged entry that calls the same runner core. + * @returns executable and arguments for the active runtime form. */ export function spawnRunnerInvocation(): RunnerInvocation { - if ('pkg' in process) return [process.execPath, PACKAGED_RUNNER_ARG] - /* v8 ignore start -- source-plane coverage cannot execute the bundled module; - the required built-runner smoke executes its private built entry. */ + if ('pkg' in process) return [process.execPath] + /* v8 ignore next -- built-artifact smoke imports the emitted JavaScript runner entry; + * source-unit coverage cannot change import.meta.url. */ if (extname(fileURLToPath(import.meta.url)) !== '.ts') { - const builtEntry = fileURLToPath(new URL('./spawn-runner.js', import.meta.url)) - return [process.execPath, builtEntry] + return [process.execPath, fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner'))] } - /* v8 ignore stop */ - const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')) - return [process.execPath, '--import', 'tsx/esm', sourceEntry] -} - -/** - * Build wrapper stdio corresponding to the public target dispositions. - * @param spec - target stdio request. - * @returns child-process stdio configuration. - */ -export function runnerStdio(spec: SubprocessSpawnSpec): StdioOptions { return [ - spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', - spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', - spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', + process.execPath, + '--import', + 'tsx/esm', + fileURLToPath(new URL('./bin.ts', import.meta.url)), ] } /** - * Materialize the exact target request without undefined environment tombstones. - * @param spec - target argv, cwd, and explicit environment. - * @returns private request and event paths. + * Check the concrete runner executable and entry paths without executing a probe mode. + * @param invocation - resolved executable and runner-entry arguments. + * @returns whether every concrete executable or entry path is accessible. */ -export function runnerFiles(spec: SubprocessSpawnSpec): RunnerFiles { +export function runnerInvocationAvailable(invocation: RunnerInvocation = spawnRunnerInvocation()): boolean { + try { + if (isAbsolute(invocation[0])) accessSync(invocation[0], fsConstants.X_OK) + const entry = invocation.at(-1) + if (entry !== undefined && entry !== invocation[0] && isAbsolute(entry)) { + accessSync(entry, fsConstants.R_OK) + } + return true + } catch { + return false + } +} + +/** + * Build the bootstrap-safe environment; target overrides arrive through request/IPC. + * @param selection - private runner selector or Linux launch-request locator. + * @returns environment for the runner before target state is restored. + */ +export function runnerEnvironment(selection: string): NodeJS.ProcessEnv { + return childEnv({ + [SUBPROCESS_RUNNER_ENV]: selection, + SYSTEMD_LOG_TARGET: 'null', + }) +} + +/** + * Read and delete the private selector before importing or restoring target state. + * @param env - mutable environment containing the private selector. + * @returns the consumed selector, or undefined when no runner was requested. + */ +export function consumeRunnerSelection(env: NodeJS.ProcessEnv = process.env): string | undefined { + const selection = env[SUBPROCESS_RUNNER_ENV] + Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV) + return selection +} + +/** + * Require the private argv delimiter and at least one target argv entry. + * @param argv - private runner arguments. + * @returns copied target argv after the private delimiter. + */ +export function parseRunnerTargetArgv(argv: readonly string[]): string[] { + if (argv[0] !== '--' || argv.length < 2) { + throw new Error('subprocess runner requires target argv after a private -- delimiter') + } + return [...argv.slice(1)] +} + +/** + * Build the stdio inherited unchanged by the target, optionally with Node IPC on fd 3. + * @param spec - ordinary subprocess request whose stdio modes are preserved. + * @param ipc - whether to append the private Node IPC descriptor. + * @returns child-process stdio options for the runner. + */ +export function runnerStdio( + spec: SubprocessSpawnSpec, + ipc: boolean, +): StdioOptions { + const stdio: StdioOptions = [ + spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', + spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', + spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', + ] + if (ipc) (stdio as Array).push('ipc') + return stdio +} + +function throwNullByteError(property: string, value: string, argument: boolean): never { + const subject = argument ? `The argument '${property}'` : `The property '${property}'` + const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`) + Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' }) + throw error +} + +function validateNoNullByte(property: string, value: string, argument = false): void { + if (value.includes('\0')) throwNullByteError(property, value, argument) +} + +/** + * Materialize and synchronously validate the final target environment. + * @param spec - final target argv, cwd, and environment overrides. + * @returns complete target environment after Node-equivalent validation. + */ +export function targetEnvironment( + spec: Pick, +): Record { + spec.argv.forEach((value, index) => { + validateNoNullByte(index === 0 ? 'file' : `args[${String(index - 1)}]`, value, true) + }) + validateNoNullByte('options.cwd', spec.cwd) const env = Object.fromEntries( Object.entries(childEnv(spec.env)).filter((entry): entry is [string, string] => entry[1] !== undefined), ) - const request: RunnerRequest = { argv: [...spec.argv], cwd: spec.cwd, env } - return createRunnerFiles(request) -} - -function directTerminalResult( - events: readonly RunnerEvent[], -): { outcome: SubprocessOutcome } | { error: Error } | undefined { - for (const event of events) { - if (event.type === 'exit') { - return { outcome: { exitCode: event.exitCode, signal: event.signal } } - } - if (event.type === 'spawn-error' || event.type === 'runner-error') { - return { error: deserializeSpawnError(event.error) } - } - } - return undefined -} - -async function waitForDirectResult( - child: ChildProcess, - files: RunnerFiles, - exited: Promise, - publishPid: (pid: number) => void, -): Promise { - let seen = 0 - const wrapperState = { exited: false } - void exited.then(() => { wrapperState.exited = true }) - for (;;) { - // A read started before exit may return a stale snapshot after exit has - // become visible. Only a read started after exit can prove no terminal - // event was written before the runner exited. - const exitedBeforeRead = wrapperState.exited - const events = await readRunnerEventsAsync(files.eventsPath) - const added = events.slice(seen) - for (const event of added) { - if (event.type === 'started') publishPid(event.pid) - } - const terminal = directTerminalResult(added) - if (terminal !== undefined) { - if ('error' in terminal) throw terminal.error - return terminal.outcome - } - seen = Math.max(seen, events.length) - if (exitedBeforeRead) { - if (child.pid === undefined) throw new Error('native subprocess runner failed to start') - throw new DirectResultUnavailableError('native subprocess runner exited without a direct-command result') - } - await sleepMs(RUNNER_EVENT_POLL_MS) + for (const [key, value] of Object.entries(env)) { + validateNoNullByte(`options.env['${key}']`, key) + validateNoNullByte(`options.env['${key}']`, value) } + return env } /** - * Bind asynchronous runner events into one direct result and target-pid getter. - * @param child - native wrapper process. - * @param files - private request and result paths. - * @param exited - wrapper exit/error observation attached before event polling. - * @returns a live target-pid view plus the direct result. + * Validate Linux PTY target strings before creating its request or terminal. + * @param spec - terminal subprocess request to validate. + * @returns complete validated target environment. */ -export function runnerDirectResult( - child: ChildProcess, - files: RunnerFiles, - exited: Promise, -): { - readonly pid: number | undefined - direct: Promise -} { - let pid: number | undefined - return { - get pid() { return pid }, - direct: waitForDirectResult(child, files, exited, (published) => { pid = published }), - } -} - -/** - * Remove request/result files after their reader settles and writer closes. - * @param files - private request and result paths. - * @param direct - target result promise. - * @param closed - runner close observation. - */ -export function cleanupAfterRunner( - files: RunnerFiles, - direct: Promise, - closed: Promise, -): void { - void Promise.allSettled([direct, closed]).then(() => { cleanupRunnerFiles(files) }) +export function validateTerminalTarget(spec: SubprocessTerminalSpawnSpec): Record { + return targetEnvironment(spec) } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index af54bb8cb4..606bea11e8 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -1,29 +1,32 @@ -/** Private request and result transport shared by native subprocess runners. */ +/** Closed private transports shared by the native subprocess runner. */ import { - appendFileSync, + chmodSync, + existsSync, lstatSync, mkdtempSync, readFileSync, + renameSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs' -import { readFile } from 'node:fs/promises' import { constants as osConstants, tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, dirname, isAbsolute, join } from 'node:path' -/** One direct command request consumed exactly once by the runner. */ -export interface RunnerRequest { - argv: string[] +const STARTUP_ERROR_TEMPORARY = '.startup-error.tmp' + +/** Target state restored by the Linux bootstrap after systemd establishes the scope. */ +export interface LinuxLaunchRequest { cwd: string env: Record } -/** Spawn-error fields preserved across the runner process boundary. */ -export interface SerializedSpawnError { +/** Bounded Node-shaped error fields allowed across a private runner boundary. */ +export interface SerializedRunnerError { name: string message: string + stack?: string code?: string errno?: number syscall?: string @@ -31,191 +34,266 @@ export interface SerializedSpawnError { spawnargs?: string[] } -/** Append-only direct-command facts emitted by the runner. */ -export type RunnerEvent = - | { type: 'started'; pid: number } - | { type: 'exit'; exitCode: number | null; signal: NodeJS.Signals | null } - | { type: 'spawn-error'; error: SerializedSpawnError } - | { type: 'runner-error'; error: SerializedSpawnError } +/** A Linux pre-exec failure published atomically beside its consumed request. */ +export type LinuxStartupError = + | { type: 'spawn-error'; error: SerializedRunnerError } + | { type: 'runner-error'; error: SerializedRunnerError } -/** Private per-spawn files; their directory is created with the host default private mkdtemp mode. */ -export interface RunnerFiles { +/** The only parent-to-runner start message on Windows. */ +export interface WindowsStartRequest { + type: 'start' + cwd: string + env: Record +} + +/** The only parent-to-runner control message on Windows. */ +export interface WindowsTerminateRequest { + type: 'terminate' +} + +/** Exactly one direct-result branch is sent by a connected Windows runner. */ +export type WindowsRunnerResult = + | { type: 'target-exit'; exitCode: number | null; signal: NodeJS.Signals | null } + | { type: 'spawn-error'; error: SerializedRunnerError } + | { type: 'runner-error'; error: SerializedRunnerError } + | { type: 'start-cancelled' } + +/** Private paths owned by one Linux ordinary or PTY spawn. */ +export interface LinuxLaunchFiles { directory: string requestPath: string - eventsPath: string + startupErrorPath: string } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === 'string' +function hasExactKeys(value: Record, required: readonly string[], optional: readonly string[] = []): boolean { + const allowed = new Set([...required, ...optional]) + return required.every(key => Object.hasOwn(value, key)) + && Object.keys(value).every(key => allowed.has(key)) } -function isSerializedSpawnError(value: unknown): value is SerializedSpawnError { - return isRecord(value) - && typeof value.name === 'string' +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every(entry => typeof entry === 'string') +} + +function isSerializedRunnerError(value: unknown): value is SerializedRunnerError { + if (!isRecord(value) || !hasExactKeys( + value, + ['name', 'message'], + ['stack', 'code', 'errno', 'syscall', 'path', 'spawnargs'], + )) return false + return typeof value.name === 'string' && typeof value.message === 'string' - && isOptionalString(value.code) + && (value.stack === undefined || typeof value.stack === 'string') + && (value.code === undefined || typeof value.code === 'string') && (value.errno === undefined || typeof value.errno === 'number') - && isOptionalString(value.syscall) - && isOptionalString(value.path) + && (value.syscall === undefined || typeof value.syscall === 'string') + && (value.path === undefined || typeof value.path === 'string') && (value.spawnargs === undefined - || (Array.isArray(value.spawnargs) && value.spawnargs.every(item => typeof item === 'string'))) + || (Array.isArray(value.spawnargs) && value.spawnargs.every(entry => typeof entry === 'string'))) } -function parseRunnerEvent(line: string): RunnerEvent { - const event: unknown = JSON.parse(line) - if (!isRecord(event)) throw new Error(`subprocess runner emitted invalid event: ${line}`) - if (event.type === 'started') { - if (typeof event.pid !== 'number' || !Number.isSafeInteger(event.pid) || event.pid <= 0) { - throw new Error(`subprocess runner emitted invalid event: ${line}`) - } - return { type: 'started', pid: event.pid } +function parseErrorResult(value: Record): LinuxStartupError { + if (!hasExactKeys(value, ['type', 'error']) || !isSerializedRunnerError(value.error)) { + throw new Error('subprocess runner emitted an invalid error result') } - if (event.type === 'exit') { - const validExitCode = event.exitCode === null - || (typeof event.exitCode === 'number' && Number.isSafeInteger(event.exitCode) && event.exitCode >= 0) - const validSignal = event.signal === null - || (typeof event.signal === 'string' && Object.hasOwn(osConstants.signals, event.signal)) - if (!validExitCode || !validSignal) throw new Error(`subprocess runner emitted invalid event: ${line}`) - return { - type: 'exit', - exitCode: event.exitCode as number | null, - signal: event.signal as NodeJS.Signals | null, - } + if (value.type !== 'spawn-error' && value.type !== 'runner-error') { + throw new Error('subprocess runner emitted an unknown error result') } - if (event.type === 'spawn-error' || event.type === 'runner-error') { - if (!isSerializedSpawnError(event.error)) throw new Error(`subprocess runner emitted invalid event: ${line}`) - return { type: event.type, error: event.error } - } - throw new Error(`subprocess runner emitted unknown event: ${line}`) + return { type: value.type, error: value.error } } /** - * Materialize one private runner request. - * @param request - exact target argv, cwd, and environment. - * @returns request and event paths owned by this spawn. + * Create a private 0700 directory and one complete 0600 launch request. + * @param request - target cwd and complete environment for the bootstrap. + * @returns private paths owned by this launch. */ -export function createRunnerFiles(request: RunnerRequest): RunnerFiles { - const directory = mkdtempSync(join(tmpdir(), 'dsh-subprocess-runner-')) - const requestPath = join(directory, 'request.json') - const eventsPath = join(directory, 'events.ndjson') - writeFileSync(requestPath, JSON.stringify(request), { flag: 'wx', mode: 0o600 }) - return { directory, requestPath, eventsPath } -} - -/** - * Read and remove the single-use request before target execution. - * @param requestPath - private request file. - * @returns parsed runner request. - */ -export function consumeRunnerRequest(requestPath: string): RunnerRequest { - const parsed: unknown = JSON.parse(readFileSync(requestPath, 'utf8')) - unlinkSync(requestPath) - if (!isRecord(parsed) || !Array.isArray(parsed.argv) || parsed.argv.length === 0 - || !parsed.argv.every(value => typeof value === 'string')) { - throw new Error('subprocess runner request has no executable') +export function createLinuxLaunchFiles(request: LinuxLaunchRequest): LinuxLaunchFiles { + const directory = mkdtempSync(join(tmpdir(), 'dsh-subprocess-launch-')) + const files = { + directory, + requestPath: join(directory, 'launch-request.json'), + startupErrorPath: join(directory, 'startup-error.json'), } - if (typeof parsed.cwd !== 'string' || !isRecord(parsed.env) - || !Object.values(parsed.env).every(value => typeof value === 'string')) { - throw new Error('subprocess runner request has invalid cwd or environment') - } - return { - argv: parsed.argv, - cwd: parsed.cwd, - env: parsed.env as Record, - } -} - -/** - * Append one complete event record. - * @param eventsPath - private append-only event file. - * @param event - direct-command fact. - */ -export function appendRunnerEvent(eventsPath: string, event: RunnerEvent): void { - appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { mode: 0o600 }) -} - -/** Parse complete newline-terminated runner records. */ -function parseRunnerEvents(content: string): RunnerEvent[] { - const lines = content.split('\n') - if (lines.at(-1) !== '') lines.pop() - return lines.filter(line => line.length > 0).map(parseRunnerEvent) -} - -/** - * Asynchronously parse every complete event record currently present. - * @param eventsPath - private event file. - * @returns complete records in append order. - */ -export async function readRunnerEventsAsync(eventsPath: string): Promise { - let content: string try { - content = await readFile(eventsPath, 'utf8') + chmodSync(directory, 0o700) + writeFileSync(files.requestPath, JSON.stringify(request), { flag: 'wx', mode: 0o600 }) + return files } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + cleanupLinuxLaunchFiles(files) throw error } - return parseRunnerEvents(content) } /** - * Convert an unknown failure into stable cross-process error fields. - * @param error - failure raised by target or runner launch. - * @returns serializable Node-shaped fields. + * Derive the only permitted startup-error path from an absolute request locator. + * @param requestPath - absolute path to the private launch-request file. + * @returns validated sibling paths for this launch. */ -export function serializeSpawnError(error: unknown): SerializedSpawnError { +export function linuxLaunchFilesFromLocator(requestPath: string): LinuxLaunchFiles { + if (!isAbsolute(requestPath) || basename(requestPath) !== 'launch-request.json') { + throw new Error('subprocess runner received an invalid Linux launch-request locator') + } + const directory = dirname(requestPath) + return { directory, requestPath, startupErrorPath: join(directory, 'startup-error.json') } +} + +/** + * Strictly read and remove a one-shot Linux launch request. + * @param requestPath - private launch-request path to consume. + * @returns validated target cwd and environment. + */ +export function consumeLinuxLaunchRequest(requestPath: string): LinuxLaunchRequest { + const text = readFileSync(requestPath, 'utf8') + unlinkSync(requestPath) + const value: unknown = JSON.parse(text) + if (!isRecord(value) || !hasExactKeys(value, ['cwd', 'env']) + || typeof value.cwd !== 'string' || !isStringRecord(value.env)) { + throw new Error('subprocess runner received an invalid Linux launch request') + } + return { cwd: value.cwd, env: value.env } +} + +/** + * Atomically publish one strict 0600 Linux pre-exec error. + * @param files - private paths for this launch. + * @param error - bounded spawn or runner failure to publish. + */ +export function writeLinuxStartupError(files: LinuxLaunchFiles, error: LinuxStartupError): void { + const temporary = join(files.directory, STARTUP_ERROR_TEMPORARY) + writeFileSync(temporary, JSON.stringify(error), { flag: 'wx', mode: 0o600 }) + renameSync(temporary, files.startupErrorPath) +} + +/** + * Read the Linux pre-exec error, if the bootstrap published one. + * @param path - expected startup-error path. + * @returns the validated failure, or undefined when none was published. + */ +export function readLinuxStartupError(path: string): LinuxStartupError | undefined { + if (!existsSync(path)) return undefined + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!isRecord(value)) throw new Error('subprocess runner emitted an invalid startup error') + return parseErrorResult(value) +} + +/** + * Strictly parse the single Windows start message. + * @param value - untrusted IPC payload. + * @returns validated target start request. + */ +export function parseWindowsStartRequest(value: unknown): WindowsStartRequest { + if (!isRecord(value) || !hasExactKeys(value, ['type', 'cwd', 'env']) + || value.type !== 'start' || typeof value.cwd !== 'string' || !isStringRecord(value.env)) { + throw new Error('subprocess runner received an invalid Windows start request') + } + return { type: 'start', cwd: value.cwd, env: value.env } +} + +/** + * Return true only for the exact, payload-free Windows terminate control. + * @param value - untrusted IPC payload. + * @returns whether the payload is the exact terminate request. + */ +export function isWindowsTerminateRequest(value: unknown): value is WindowsTerminateRequest { + return isRecord(value) && hasExactKeys(value, ['type']) && value.type === 'terminate' +} + +/** + * Strictly parse one of the four Windows direct-result branches. + * @param value - untrusted IPC payload. + * @returns validated direct-result message. + */ +export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult { + if (!isRecord(value) || typeof value.type !== 'string') { + throw new Error('subprocess runner emitted an invalid Windows result') + } + if (value.type === 'start-cancelled') { + if (!hasExactKeys(value, ['type'])) throw new Error('subprocess runner emitted an invalid start-cancelled result') + return { type: 'start-cancelled' } + } + if (value.type === 'spawn-error' || value.type === 'runner-error') return parseErrorResult(value) + if (value.type === 'target-exit') { + const validExitCode = value.exitCode === null + || (typeof value.exitCode === 'number' && Number.isSafeInteger(value.exitCode) && value.exitCode >= 0) + const validSignal = value.signal === null + || (typeof value.signal === 'string' && Object.hasOwn(osConstants.signals, value.signal)) + if (!hasExactKeys(value, ['type', 'exitCode', 'signal']) || !validExitCode || !validSignal) { + throw new Error('subprocess runner emitted an invalid target-exit result') + } + return { + type: 'target-exit', + exitCode: value.exitCode as number | null, + signal: value.signal as NodeJS.Signals | null, + } + } + throw new Error(`subprocess runner emitted an unknown Windows result: ${value.type}`) +} + +/** + * Convert an unknown failure into the bounded cross-process error record. + * @param error - failure caught at the process boundary. + * @returns bounded serializable error fields. + */ +export function serializeRunnerError(error: unknown): SerializedRunnerError { const source = error instanceof Error ? error : new Error(String(error)) const node = source as NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } return { name: source.name, message: source.message, + ...typeof source.stack === 'string' ? { stack: source.stack } : {}, ...typeof node.code === 'string' ? { code: node.code } : {}, ...typeof node.errno === 'number' ? { errno: node.errno } : {}, ...typeof node.syscall === 'string' ? { syscall: node.syscall } : {}, ...typeof node.path === 'string' ? { path: node.path } : {}, - ...Array.isArray(node.spawnargs) ? { spawnargs: [...node.spawnargs] } : {}, + ...Array.isArray(node.spawnargs) && node.spawnargs.every(entry => typeof entry === 'string') + ? { spawnargs: [...node.spawnargs] } + : {}, } } /** - * Reconstruct one Node-shaped spawn error for the public done rejection. - * @param serialized - fields received from the runner. - * @returns error with Node spawn properties restored. + * Rebuild a Node-shaped Error from a strict runner record. + * @param serialized - validated bounded error fields. + * @returns reconstructed Error with supported Node fields. */ -export function deserializeSpawnError(serialized: SerializedSpawnError): Error { +export function deserializeRunnerError(serialized: SerializedRunnerError): Error { const error = new Error(serialized.message) error.name = serialized.name + if (serialized.stack !== undefined) error.stack = serialized.stack return Object.assign(error, { ...serialized.code === undefined ? {} : { code: serialized.code }, ...serialized.errno === undefined ? {} : { errno: serialized.errno }, ...serialized.syscall === undefined ? {} : { syscall: serialized.syscall }, ...serialized.path === undefined ? {} : { path: serialized.path }, - ...serialized.spawnargs === undefined ? {} : { spawnargs: serialized.spawnargs }, + ...serialized.spawnargs === undefined ? {} : { spawnargs: [...serialized.spawnargs] }, }) } /** - * Remove only the private directory created for this spawn. - * @param files - private paths returned by createRunnerFiles. + * Best-effort removal of only the private paths created for this Linux spawn. + * @param files - exact private paths owned by this launch. */ -export function cleanupRunnerFiles(files: RunnerFiles): void { +export function cleanupLinuxLaunchFiles(files: LinuxLaunchFiles): void { try { if (lstatSync(files.directory).isSymbolicLink()) { unlinkSync(files.directory) return } - for (const file of [files.requestPath, files.eventsPath]) { - try { - unlinkSync(file) - } catch (error) { + for (const path of [ + files.requestPath, + files.startupErrorPath, + join(files.directory, STARTUP_ERROR_TEMPORARY), + ]) { + try { unlinkSync(path) } 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. + // Crash residue remains private and no later spawn reuses this directory. } } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 1e122e88b5..967f98cdbd 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,102 +1,72 @@ -/** Native managed-range runner for ordinary local subprocesses. */ +/** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */ -import { spawn } from 'node:child_process' +import { delimiter, resolve } from 'node:path' import { + closeCurrentProcessStandardHandles, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, - openNamedPipeForStdio, pollProcessExit, spawnCurrentTokenJobProcess, terminateJob, - waitForProcessExit, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { ChildStdioHandles, NativePtr } from '@deepseek-ai/dsh-win32-process' +import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import { - appendRunnerEvent, - consumeRunnerRequest, - serializeSpawnError, + consumeLinuxLaunchRequest, + isWindowsTerminateRequest, + linuxLaunchFilesFromLocator, + parseWindowsStartRequest, + serializeRunnerError, + writeLinuxStartupError, } from './runner-protocol.ts' -import type { RunnerRequest, SerializedSpawnError } from './runner-protocol.ts' +import type { + LinuxLaunchFiles, + SerializedRunnerError, + WindowsRunnerResult, + WindowsStartRequest, +} from './runner-protocol.ts' +import { + parseRunnerTargetArgv, + SUBPROCESS_RUNNER_ENV, + WINDOWS_RUNNER_SELECTION, +} from './runner-launch.ts' -type RunnerArgs = - | { mode: 'probe-node' } - | { mode: 'probe-win32' } - | { mode: 'node'; requestPath: string; eventsPath: string } - | { - mode: 'win32' - requestPath: string - eventsPath: string - stdinPipe?: string - stdoutPipe?: string - stderrPipe?: string - } +type RunnerHost = Pick & { + send?: NodeJS.Process['send'] +} -type RunnerHost = Pick< - NodeJS.Process, - 'env' | 'exitCode' | 'connected' | 'cwd' | 'chdir' | 'on' | 'off' | 'disconnect' -> - -interface RunnerInternals { - spawn: typeof spawn - loadWin32ProcessBindings: typeof loadWin32ProcessBindings - openNamedPipeForStdio: typeof openNamedPipeForStdio +/** Injectable operations used by the protocol-owner tests. */ +export interface SpawnRunnerInternals { + execve(file: string, argv: string[], env: Record): never + loadWin32ProcessBindings(): Win32ProcessBindings spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess + closeCurrentProcessStandardHandles: typeof closeCurrentProcessStandardHandles pollProcessExit: typeof pollProcessExit isJobEmpty: typeof isJobEmpty terminateJob: typeof terminateJob - waitForProcessExit: typeof waitForProcessExit closeHandleChecked: typeof closeHandleChecked } -const defaultRunnerInternals: RunnerInternals = { - spawn, +const defaultInternals: SpawnRunnerInternals = { + /* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */ + execve: (file, argv, env) => (process.execve as NonNullable)(file, argv, env), loadWin32ProcessBindings, - openNamedPipeForStdio, spawnCurrentTokenJobProcess, + closeCurrentProcessStandardHandles, pollProcessExit, isJobEmpty, terminateJob, - waitForProcessExit, closeHandleChecked, } -function parseArgs(argv: string[]): RunnerArgs { - let mode: string | undefined - let requestPath: string | undefined - let eventsPath: string | undefined - let stdinPipe: string | undefined - let stdoutPipe: string | undefined - let stderrPipe: string | undefined - for (let index = 0; index < argv.length; index += 2) { - const key = argv[index] - const value = argv[index + 1] - if (value === undefined) throw new Error(`subprocess runner missing value after ${String(key)}`) - if (key === '--mode') mode = value - else if (key === '--request') requestPath = value - else if (key === '--events') eventsPath = value - else if (key === '--stdin-pipe') stdinPipe = value - else if (key === '--stdout-pipe') stdoutPipe = value - else if (key === '--stderr-pipe') stderrPipe = value - else throw new Error(`subprocess runner unknown argument: ${String(key)}`) - } - if (mode === 'probe-node' || mode === 'probe-win32') return { mode } - if (mode !== 'node' && mode !== 'win32') throw new Error(`subprocess runner unknown mode: ${String(mode)}`) - if (requestPath === undefined || eventsPath === undefined) throw new Error('subprocess runner requires request and event paths') - if (mode === 'node') return { mode, requestPath, eventsPath } - return { - mode, - requestPath, - eventsPath, - ...stdinPipe === undefined ? {} : { stdinPipe }, - ...stdoutPipe === undefined ? {} : { stdoutPipe }, - ...stderrPipe === undefined ? {} : { stderrPipe }, - } +function replaceEnvironment(target: NodeJS.ProcessEnv, env: Record): void { + for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) + Object.assign(target, env) } -function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpawnError { - const serialized = serializeSpawnError(error) +function asSpawnError(error: unknown, program: string, args: readonly string[]): SerializedRunnerError { + const serialized = serializeRunnerError(error) const code = error instanceof Win32Error ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 ? 'ENOENT' @@ -107,264 +77,353 @@ function win32SpawnError(error: unknown, request: RunnerRequest): SerializedSpaw : 'UNKNOWN' : serialized.code if (code === undefined) return serialized - const program = request.argv[0] as string return { ...serialized, message: `spawn ${program} ${code}: ${serialized.message}`, code, syscall: `spawn ${program}`, path: program, - spawnargs: request.argv.slice(1), + spawnargs: [...args], } } -async function runNode( - request: RunnerRequest, - eventsPath: string, - host: RunnerHost, - internals: RunnerInternals, -): Promise { - const ignoreScopeSignal = (): void => { /* The target receives the scope signal; the runner reports its outcome. */ } - for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { - host.on(signal, ignoreScopeSignal) - } - const [program, ...args] = request.argv - const child = internals.spawn(program as string, args, { - cwd: request.cwd, - env: request.env, - stdio: 'inherit', - detached: true, +function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { + return Object.assign(new Error(`spawn ${program} ENOENT`), { + code: 'ENOENT', + errno: -2, + syscall: `spawn ${program}`, + path: program, + spawnargs: [] as string[], }) - await new Promise((resolve) => { - let started = false - let failed = false - let settled = false - const finish = (): void => { - if (settled) return - settled = true - for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) host.off(signal, ignoreScopeSignal) - resolve() - } - child.once('spawn', () => { - started = true - appendRunnerEvent(eventsPath, { type: 'started', pid: child.pid as number }) - }) - child.once('error', (error) => { - failed = true - if (!started) appendRunnerEvent(eventsPath, { type: 'spawn-error', error: serializeSpawnError(error) }) - else appendRunnerEvent(eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) - host.exitCode = 127 - finish() - }) - child.once('exit', (exitCode, signal) => { - if (!failed) { - appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal }) - host.exitCode = exitCode ?? 1 +} + +function execLinuxTarget( + request: { cwd: string; env: Record }, + argv: string[], + internals: SpawnRunnerInternals, +): never { + const program = argv[0] as string + if (program.includes('/')) return internals.execve(program, argv, request.env) + const path = request.env.PATH ?? '/usr/bin:/bin' + let permissionFailure: Error | undefined + for (const directory of path.split(delimiter)) { + const candidate = resolve(request.cwd, directory, program) + try { + return internals.execve(candidate, argv, request.env) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EACCES') { + permissionFailure ??= error as Error + continue } - finish() - }) - }) -} - -function replaceEnvironment(target: NodeJS.ProcessEnv, env: Record): void { - for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) - Object.assign(target, env) -} - -function closeStdioHandles( - api: ReturnType, - handles: Array<{ handle: NativePtr; label: string }>, - reportFailure: boolean, - internals: RunnerInternals, -): void { - let failure: Error | undefined - for (const owned of handles.splice(0)) { - try { - internals.closeHandleChecked(api, owned.handle, owned.label) - } catch (error) { - handles.push(owned) - failure ??= error instanceof Error ? error : new Error(serializeSpawnError(error).message) - } - } - if (reportFailure && failure !== undefined) throw failure -} - -async function runWin32( - request: RunnerRequest, - eventsPath: string, - pipes: Pick, 'stdinPipe' | 'stdoutPipe' | 'stderrPipe'>, - host: RunnerHost, - internals: RunnerInternals, -): Promise { - replaceEnvironment(host.env, request.env) - const api = internals.loadWin32ProcessBindings() - let processHandle: NativePtr | undefined - let jobHandle: NativePtr | undefined - const openedStdio: Array<{ handle: NativePtr; label: string }> = [] - try { - const stdio: ChildStdioHandles = {} - for (const [key, path, access] of [ - ['stdin', pipes.stdinPipe, 'read'], - ['stdout', pipes.stdoutPipe, 'write'], - ['stderr', pipes.stderrPipe, 'write'], - ] as const) { - if (path === undefined) continue - const handle = internals.openNamedPipeForStdio(api, path, access) - stdio[key] = handle - openedStdio.push({ handle, label: `ordinary target ${key} pipe` }) - } - // Match Node's cwd-relative executable lookup and spawn-error attribution. - const runnerCwd = host.cwd() - host.chdir(request.cwd) - const [command, ...args] = request.argv - let spawned: ReturnType - try { - spawned = internals.spawnCurrentTokenJobProcess( - api, - { command: command as string, args, cwd: host.cwd() }, - stdio, - ) - } catch (error) { - try { host.chdir(runnerCwd) } catch { /* Preserve the target startup failure. */ } + if (code === 'ENOENT' || code === 'ENOTDIR') continue throw error } - try { - processHandle = spawned.process - jobHandle = spawned.job - appendRunnerEvent(eventsPath, { type: 'started', pid: spawned.pid }) - } finally { - host.chdir(runnerCwd) - } - closeStdioHandles(api, openedStdio, true, internals) - - await new Promise((resolve, reject) => { - let settled = false - let terminationRequested = false - const settle = (error?: unknown): void => { - if (settled) return - settled = true - clearInterval(timer) - host.off('message', onMessage) - host.off('disconnect', onDisconnect) - if (error === undefined) resolve() - else reject(error instanceof Error ? error : new Error(serializeSpawnError(error).message)) - } - const terminate = (): void => { - if (terminationRequested || jobHandle === undefined) return - terminationRequested = true - try { - internals.terminateJob(api, jobHandle, 1) - } catch (error) { - settle(error) - } - } - const onMessage = (message: unknown): void => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') { - terminate() - } - } - const onDisconnect = (): void => { terminate() } - host.on('message', onMessage) - host.on('disconnect', onDisconnect) - const timer = setInterval(() => { - try { - if (processHandle !== undefined) { - const exitCode = internals.pollProcessExit(api, processHandle) - if (exitCode !== undefined) { - appendRunnerEvent(eventsPath, { type: 'exit', exitCode, signal: null }) - internals.closeHandleChecked(api, processHandle, 'ordinary direct process') - processHandle = undefined - } - } - if (processHandle === undefined && jobHandle !== undefined && internals.isJobEmpty(api, jobHandle)) { - internals.closeHandleChecked(api, jobHandle, 'ordinary process Job') - jobHandle = undefined - settle() - } - } catch (error) { - settle(error) - } - }, 10) - }) - } catch (error) { - const targetSpawnFailed = (error instanceof Win32Error && error.api === 'CreateProcessW') - || (processHandle === undefined - && error instanceof Error - && (error as NodeJS.ErrnoException).syscall === 'chdir') - appendRunnerEvent(eventsPath, { - type: targetSpawnFailed ? 'spawn-error' : 'runner-error', - error: targetSpawnFailed ? win32SpawnError(error, request) : serializeSpawnError(error), - }) - if (!targetSpawnFailed) host.exitCode = 127 - } finally { - closeStdioHandles(api, openedStdio, false, internals) - if (processHandle !== undefined) { - try { internals.closeHandleChecked(api, processHandle, 'ordinary direct process cleanup') } catch { /* best effort after reported failure */ } - } - if (jobHandle !== undefined) { - try { internals.closeHandleChecked(api, jobHandle, 'ordinary process Job cleanup') } catch { /* best effort after reported failure */ } - } } + throw permissionFailure ?? linuxPathNotFoundError(program) } -function probeWin32Job(host: RunnerHost, internals: RunnerInternals): void { - const command = host.env.ComSpec ?? host.env.COMSPEC - if (command === undefined) throw new Error('subprocess runner cannot probe a Windows Job without ComSpec') - const api = internals.loadWin32ProcessBindings() - const spawned = internals.spawnCurrentTokenJobProcess(api, { - command, - args: ['/d', '/s', '/c', 'exit 0'], - cwd: host.cwd(), - }) - try { - const exitCode = internals.waitForProcessExit(api, spawned.process) - if (exitCode !== 0) throw new Error(`subprocess Windows Job probe exited with code ${String(exitCode)}`) - } finally { - internals.closeHandleChecked(api, spawned.job, 'subprocess Windows Job probe') - } -} - -/** - * Execute one parsed private-runner request. - * @param argv - runner arguments after the executable and entry path. - * @param host - process operations; tests provide an isolated host facade. - * @param internals - platform operations; tests replace native Win32 calls. - * @returns after the requested probe or target lifecycle completes. - */ -export async function runSpawnRunner( +function runLinux( + locator: string, argv: string[], - host: RunnerHost = process, - internals: RunnerInternals = defaultRunnerInternals, -): Promise { - const args = parseArgs(argv) - if (args.mode === 'probe-node') return - if (args.mode === 'probe-win32') { - probeWin32Job(host, internals) + host: RunnerHost, + internals: SpawnRunnerInternals, +): void { + const files = linuxLaunchFilesFromLocator(locator) + let request: ReturnType + try { + request = consumeLinuxLaunchRequest(files.requestPath) + } catch (error) { + writeLinuxStartupError(files, { type: 'runner-error', error: serializeRunnerError(error) }) + host.exitCode = 127 return } - const request = consumeRunnerRequest(args.requestPath) - if (args.mode === 'node') await runNode(request, args.eventsPath, host, internals) - else { - try { - await runWin32(request, args.eventsPath, args, host, internals) - } finally { - if (host.connected) host.disconnect() + try { + host.chdir(request.cwd) + execLinuxTarget(request, argv, internals) + } catch (error) { + writeLinuxStartupError(files, { + type: 'spawn-error', + error: asSpawnError(error, argv[0] as string, argv.slice(1)), + }) + host.exitCode = 127 + } +} + +function sendMessage(host: RunnerHost, result: WindowsRunnerResult): Promise { + return new Promise((resolve, reject) => { + if (!host.connected || host.send === undefined) { + reject(new Error('subprocess runner IPC is not connected')) + return } + try { + host.send(result, (error) => { + if (error === null) resolve() + else reject(error) + }) + } catch (error) { + /* v8 ignore next -- process.send throws Error instances. */ + const failure = error instanceof Error ? error : new Error(String(error)) + reject(failure) + } + }) +} + +class WindowsJobRunner { + private api: Win32ProcessBindings | undefined + private processHandle: NativePtr | undefined + private jobHandle: NativePtr | undefined + private pollTimer: ReturnType | undefined + private startSeen = false + private committed = false + private terminateRequested = false + private resultStarted = false + private resultDelivered = false + private jobEmpty = false + private finished = false + private readonly completion = Promise.withResolvers() + + constructor( + private readonly argv: string[], + private readonly host: RunnerHost, + private readonly internals: SpawnRunnerInternals, + ) {} + + run(): Promise { + if (!this.host.connected || this.host.send === undefined) { + this.finish(127) + return this.completion.promise + } + this.host.on('message', this.onMessage) + this.host.once('disconnect', this.onDisconnect) + return this.completion.promise + } + + private readonly onMessage = (value: unknown): void => { + if (this.finished) return + if (isWindowsTerminateRequest(value)) { + this.requestTermination() + return + } + if (this.startSeen) { + void this.runnerFailure(new Error('subprocess runner received more than one Windows start request')) + return + } + let request: WindowsStartRequest + try { + request = parseWindowsStartRequest(value) + } catch (error) { + void this.runnerFailure(error) + return + } + this.startSeen = true + void this.start(request) + } + + private readonly onDisconnect = (): void => { + if (this.finished) return + this.releaseOwnedJob() + this.finish(127, false) + } + + private async start(request: WindowsStartRequest): Promise { + if (this.terminateRequested) { + await this.publishTerminalResult({ type: 'start-cancelled' }, 0) + return + } + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + if (this.finished) return + if (this.startCancellationPending()) { + await this.publishTerminalResult({ type: 'start-cancelled' }, 0) + return + } + try { + replaceEnvironment(this.host.env, request.env) + this.api = this.internals.loadWin32ProcessBindings() + const [command, ...args] = this.argv + const spawned = this.internals.spawnCurrentTokenJobProcess(this.api, { + command: command as string, + args, + cwd: request.cwd, + }) + this.processHandle = spawned.process + this.jobHandle = spawned.job + this.committed = true + this.internals.closeCurrentProcessStandardHandles(this.api) + if (this.startCancellationPending()) this.terminateOwnedJob() + this.pollTimer = setInterval(() => { this.poll() }, 10) + this.poll() + } catch (error) { + if (!this.committed && error instanceof Win32Error && error.api === 'CreateProcessW') { + await this.publishTerminalResult({ + type: 'spawn-error', + error: asSpawnError(error, this.argv[0] as string, this.argv.slice(1)), + }, 0) + return + } + await this.runnerFailure(error) + } + } + + private requestTermination(): void { + if (this.terminateRequested) return + this.terminateRequested = true + if (this.committed) { + try { + this.terminateOwnedJob() + } catch (error) { + void this.runnerFailure(error) + } + } + } + + private startCancellationPending(): boolean { + return this.terminateRequested + } + + private terminateOwnedJob(): void { + const job = this.jobHandle + if (job === undefined) return + /* v8 ignore next -- a Job handle is assigned only after the bindings are loaded; + * the guard above is the only reachable empty-owner state. */ + if (this.api === undefined) return + this.internals.terminateJob(this.api, job, 1) + } + + private poll(): void { + if (this.finished) return + /* v8 ignore next -- poll is installed only after start() stores the bindings; retained as a defensive invariant guard. */ + if (this.api === undefined) return + try { + if (this.processHandle !== undefined) { + const exitCode = this.internals.pollProcessExit(this.api, this.processHandle) + if (exitCode !== undefined) { + this.internals.closeHandleChecked(this.api, this.processHandle, 'ordinary direct process') + this.processHandle = undefined + void this.publishTerminalResult({ type: 'target-exit', exitCode, signal: null }) + } + } + if (this.jobHandle !== undefined && this.internals.isJobEmpty(this.api, this.jobHandle)) { + this.internals.closeHandleChecked(this.api, this.jobHandle, 'ordinary process Job') + this.jobHandle = undefined + this.jobEmpty = true + if (this.resultDelivered) this.finish(0) + } + } catch (error) { + void this.runnerFailure(error) + } + } + + private async publishTerminalResult(result: WindowsRunnerResult, exitCode?: number): Promise { + /* v8 ignore next -- each state transition has a single result call site; the guard contains only re-entrant internal defects. */ + if (this.finished || this.resultStarted) return + this.resultStarted = true + try { + await sendMessage(this.host, result) + this.resultDelivered = true + } catch { + this.releaseOwnedJob() + this.finish(127, false) + return + } + if (exitCode !== undefined) { + this.finish(exitCode) + return + } + if (this.jobEmpty) this.finish(0) + } + + private async runnerFailure(error: unknown): Promise { + /* v8 ignore next -- callers stop/detach on finish; this guard contains only an already-queued internal callback. */ + if (this.finished) return + if (!this.resultStarted) { + this.resultStarted = true + try { + await sendMessage(this.host, { type: 'runner-error', error: serializeRunnerError(error) }) + this.resultDelivered = true + } catch { + // The disconnected parent observes runner infrastructure failure. + } + } + this.releaseOwnedJob() + this.finish(127) + } + + private releaseOwnedJob(): void { + if (this.pollTimer !== undefined) clearInterval(this.pollTimer) + this.pollTimer = undefined + const api = this.api + if (api === undefined) return + if (this.jobHandle !== undefined) { + try { this.internals.terminateJob(api, this.jobHandle, 1) } catch { /* Continue to kill-on-close. */ } + try { this.internals.closeHandleChecked(api, this.jobHandle, 'ordinary process Job cleanup') } catch { /* Best effort after failure. */ } + this.jobHandle = undefined + } + if (this.processHandle !== undefined) { + try { this.internals.closeHandleChecked(api, this.processHandle, 'ordinary direct process cleanup') } catch { /* Best effort after failure. */ } + this.processHandle = undefined + } + } + + private finish(exitCode: number, disconnect = true): void { + if (this.finished) return + this.finished = true + if (this.pollTimer !== undefined) clearInterval(this.pollTimer) + this.pollTimer = undefined + this.host.off('message', this.onMessage) + this.host.off('disconnect', this.onDisconnect) + this.host.exitCode = exitCode + if (disconnect && this.host.connected) this.host.disconnect() + this.completion.resolve() } } /** - * Publish an infrastructure failure when runner arguments still identify an event file. - * @param argv - original runner arguments. - * @param error - uncaught runner failure. + * Execute the selected Linux bootstrap or Windows Job runner. + * @param selection - Windows sentinel or Linux launch-request locator. + * @param argv - private runner arguments beginning with the target delimiter. + * @param host - process transport and lifecycle host. + * @param internals - native and filesystem operations used by the runner. */ -export function reportSpawnRunnerFailure(argv: string[], error: unknown): void { - try { - const args = parseArgs(argv) - if (args.mode !== 'probe-node' && args.mode !== 'probe-win32') { - appendRunnerEvent(args.eventsPath, { type: 'runner-error', error: serializeSpawnError(error) }) - } - } catch { - // No trustworthy transport remains; the parent reports the missing result. +export async function runSpawnRunner( + selection: string, + argv: readonly string[], + host: RunnerHost = process, + internals: SpawnRunnerInternals = defaultInternals, +): Promise { + Reflect.deleteProperty(host.env, SUBPROCESS_RUNNER_ENV) + const targetArgv = parseRunnerTargetArgv(argv) + if (selection === WINDOWS_RUNNER_SELECTION) { + await new WindowsJobRunner(targetArgv, host, internals).run() + return } + runLinux(selection, targetArgv, host, internals) +} + +/** + * Best-effort reporting for failures before the selected runner established its owner. + * @param selection - Windows sentinel, Linux launch-request locator, or no selection. + * @param error - failure raised before normal runner settlement. + * @param host - process transport and lifecycle host. + */ +export async function reportSpawnRunnerFailure( + selection: string | undefined, + error: unknown, + host: RunnerHost = process, +): Promise { + if (selection === WINDOWS_RUNNER_SELECTION) { + try { await sendMessage(host, { type: 'runner-error', error: serializeRunnerError(error) }) } catch { /* No transport remains. */ } + host.exitCode = 127 + if (host.connected) host.disconnect() + return + } + if (selection !== undefined) { + try { + const files: LinuxLaunchFiles = linuxLaunchFilesFromLocator(selection) + writeLinuxStartupError(files, { type: 'runner-error', error: serializeRunnerError(error) }) + } catch { + // The parent will report an unconsumed request or missing runner result. + } + } + host.exitCode = 127 } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 62ed6d24f2..deaf77d2e6 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -59,6 +59,10 @@ 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 per-spawn Windows native prerequisite check. */ + windowsNativeAvailable?: () => boolean } /** @@ -416,6 +420,10 @@ function fallbackOwner( })() await observation }, + terminateForHostExit: () => { + if (stopped) return + signalTree(platform, pid, 'SIGKILL', child, taskkill) + }, } } @@ -489,6 +497,9 @@ export function bindManagedProcess( if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined spec.signal?.removeEventListener('abort', onAbort) + if (launch.owner.cleanup !== undefined) { + queueMicrotask(() => { void done.finally(() => { launch.owner.cleanup?.() }).catch(() => {}) }) + } })().catch((error: unknown) => { rangeExitObservation = undefined throw error @@ -496,30 +507,35 @@ export function bindManagedProcess( return rangeExitObservation } - const kill = (sig: 'SIGTERM' | 'SIGKILL'): void => { + const kill = (sig: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void => { if (rangeExitObserved) return - launch.owner.signal(sig) + launch.owner.signal(sig, cancellationReason) } - const terminate = (): void => { + const terminateWithReason = (cancellationReason: unknown): void => { if (rangeExitObserved || graceTimer !== undefined) return // Keep the shared observation rejection available to waitForExit() without // leaking an unhandled rejection when a caller only invokes terminate(). void observeRangeExit().catch(() => {}) - kill('SIGTERM') + kill('SIGTERM', cancellationReason) graceTimer = setTimeout(() => { graceTimer = undefined kill('SIGKILL') }, spec.graceMs) } + const terminate = (): void => { + terminateWithReason(new Error('subprocess terminated before target start')) + } + const terminateForHostExit = (): void => { - kill('SIGKILL') + launch.owner.terminateForHostExit() } // The caller owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { terminate() } + const onAbort = (): void => { terminateWithReason(spec.signal?.reason) } spec.signal?.addEventListener('abort', onAbort, { once: true }) + if (spec.signal?.aborted === true) onAbort() // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. @@ -539,6 +555,17 @@ export function bindManagedProcess( cleanup() resolve(outcome) } + const fail = (error: unknown): void => { + if (settled) return + settled = true + terminate() + stopCollectors() + cleanup() + /* v8 ignore next -- managed launch promises reject with Error instances. */ + const failure = error instanceof Error ? error : new Error(String(error)) + reject(failure) + } + void launch.infrastructureFailure?.catch(fail) launch.direct.then((outcome) => { if (stdoutClosed === undefined && stderrClosed === undefined) { settle(outcome) @@ -546,15 +573,7 @@ export function bindManagedProcess( } pipeDrainTimer = setTimeout(() => { settle(outcome) }, spec.graceMs) void outputStreamsClosed.then(() => { settle(outcome) }) - }, (error: unknown) => { - /* v8 ignore next -- one Promise cannot reject after its fulfillment path has settled this handle. */ - if (settled) return - settled = true - terminate() - stopCollectors() - cleanup() - reject(error instanceof Error ? error : new Error(String(error))) - }) + }, fail) function cleanup(): void { // graceTimer deliberately NOT cleared: forced termination must still // reach range survivors after the spawned command settles. @@ -568,7 +587,6 @@ export function bindManagedProcess( } return { - get pid() { return launch.pid }, /* v8 ignore start -- pipe-mode streams exist on every conforming launch; the null-coalesces guard an internal adapter defect only. */ stdin: stdinMode === 'pipe' ? stdin ?? undefined : undefined, @@ -620,7 +638,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, - pid, direct, owner, }, binding) diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 81f059d5b5..10025f11e4 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -60,6 +60,8 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly graceMs: number, private readonly platform: NodeJS.Platform = process.platform, private readonly managedOwner?: BoundProcessOwner, + private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome, + private readonly cleanupManagedProtocol?: () => void, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -69,10 +71,15 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (this.exited) return this.exited = true this.output.end() - this.outcome.resolve({ + const outcome = { exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null, signal: signalName(exitSignal), - }) + } + try { + this.outcome.resolve(this.resolveManagedOutcome?.(outcome) ?? outcome) + } catch (error) { + this.outcome.reject(error) + } }) } @@ -138,13 +145,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * event. This does not claim quiescence and does not replace terminate(). */ terminateForHostExit(): void { - if (this.managedOwner !== undefined) { - this.managedOwner.signal('SIGKILL') - return - } this.forceStopDescendants() this.forceStopShell() this.forceStopDescendants() + this.managedOwner?.terminateForHostExit() } private forceStopShell(): void { @@ -304,9 +308,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private async closeOnce(): Promise { if (this.managedOwner !== undefined) { - await this.closeManagedRange(this.managedOwner) - this.dataDisposable.dispose() - this.exitDisposable.dispose() + try { + await this.closeManagedRange(this.managedOwner) + this.dataDisposable.dispose() + this.exitDisposable.dispose() + } finally { + void this.done.finally(() => { this.cleanupManagedProtocol?.() }).catch(() => {}) + } return } let survivors = await this.stopDescendants() diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 965d035e22..db6c1094b7 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,152 +1,209 @@ -/** Windows Job runner launch and managed-range ownership. */ +/** Windows parent-side launch and ownership for the private Job runner. */ -import { spawn, spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' -import { observeChildLifecycle } from './managed-owner.ts' -import { childEnv } from './spawn.ts' +import { spawn } from 'node:child_process' +import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { - cleanupAfterRunner, - type RunnerInvocation, - runnerDirectResult, - runnerFiles, + loadWin32ProcessBindings, + probeCurrentTokenJobSupport, +} from '@deepseek-ai/dsh-win32-process' +import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' +import { + deserializeRunnerError, + parseWindowsRunnerResult, +} from './runner-protocol.ts' +import type { WindowsStartRequest } from './runner-protocol.ts' +import { + runnerEnvironment, + runnerInvocationAvailable, + runnerStdio, spawnRunnerInvocation, + WINDOWS_RUNNER_SELECTION, } from './runner-launch.ts' -import { cleanupRunnerFiles } from './runner-protocol.ts' -import { createWindowsStdioBridge } from './windows-stdio.ts' +import type { RunnerInvocation } from './runner-launch.ts' -/** Test seams for the runner process. */ +/** Test seams for runner launch and dynamic capability checks. */ export interface WindowsJobInternals { spawn?: typeof spawn - spawnSync?: typeof spawnSync runnerInvocation?: RunnerInvocation + resolveRunnerInvocation?: () => RunnerInvocation + runnerAvailable?: (invocation: RunnerInvocation) => boolean + loadWin32ProcessBindings?: typeof loadWin32ProcessBindings + probeCurrentTokenJobSupport?: typeof probeCurrentTokenJobSupport +} + +type RunnerProcess = Omit, 'send'> & { + send?: ReturnType['send'] } /** - * Confirm in a separate process that shared Win32 bindings and the runner entry are available. - * @param internals - injected process runners used by tests. - * @returns true when native launch can be selected before a user command. + * Re-check the runner entry, bindings, and current Job capability for every spawn. + * @param internals - optional runner and Win32 capability seams used by tests. + * @returns whether the Windows native containment path is currently available. */ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { - const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() - const [command, ...prefix] = invocation - const result = (internals.spawnSync ?? spawnSync)(command, [...prefix, '--mode', 'probe-win32'], { - env: childEnv(), - stdio: 'ignore', - timeout: 5_000, - }) - return result.error === undefined && result.status === 0 + try { + const invocation = internals.runnerInvocation + ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)() + if (!(internals.runnerAvailable ?? runnerInvocationAvailable)(invocation)) return false + const api = (internals.loadWin32ProcessBindings ?? loadWin32ProcessBindings)() + ;(internals.probeCurrentTokenJobSupport ?? probeCurrentTokenJobSupport)(api) + return true + } catch { + return false + } } class WindowsJobOwner implements BoundProcessOwner { - private stopped = false - private runnerClosed = false - private readonly observation: Promise + private cancellationReason: unknown + private terminationSent = false constructor( - private readonly runner: ReturnType, + private readonly runner: RunnerProcess, + private readonly exited: Promise, + private readonly failInfrastructure: (error: Error) => void, ) { - this.observation = new Promise((resolve, reject) => { - runner.once('close', (exitCode, signal) => { - this.runnerClosed = true - if (this.runner.pid === undefined || (exitCode === 0 && signal === null)) { - this.stopped = true - resolve() - return - } - const status = signal !== null - ? `signal ${signal}` - : exitCode === null - ? 'without an exit status' - : `exit code ${String(exitCode)}` - reject(new Error( - `subprocess-local: Windows Job runner exited with ${status} before proving its managed range empty`, - )) - }) - }) - void this.observation.catch(() => {}) + void this.exited.catch(() => {}) } - signal(_signal: 'SIGTERM' | 'SIGKILL'): void { - if (this.stopped || this.runnerClosed || this.runner.pid === undefined) return - // The runner handles an IPC disconnect as termination and disconnects itself - // after its Win32 cleanup path. Its close status reports whether the Job is empty. - if (!this.runner.connected) return + signal(_signal: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void { + if (this.cancellationReason === undefined) this.cancellationReason = cancellationReason + if (this.terminationSent || !this.runner.connected) return + this.terminationSent = true try { - this.runner.send({ type: 'terminate' }, (error) => { - if (error !== null && this.runner.connected) this.runner.kill() + this.runner.send?.({ type: 'terminate' }, (error) => { + if (error === null) return + this.failInfrastructure(error) + this.terminateForHostExit() }) - } catch { - // oxlint-disable-next-line typescript/no-unnecessary-condition -- ChildProcess.send() may synchronously disconnect before throwing. - if (this.runner.connected) this.runner.kill() + } catch (error) { + this.failInfrastructure(error instanceof Error ? error : new Error(String(error))) + this.terminateForHostExit() } } + startCancellationReason(): unknown { + return this.cancellationReason ?? new Error('subprocess target start was cancelled') + } + async waitForExit(): Promise { - if (this.stopped) return - await this.observation + await this.exited + } + + terminateForHostExit(): void { + try { this.runner.kill('SIGKILL') } catch { /* Host exit continues with other live runners. */ } } } /** - * Launch one direct command through the Job-owning runner. - * @param spec - exact target argv, cwd, stdio, environment, and lifecycle settings. - * @param internals - injected process runner used by tests. - * @returns parent-owned streams, target outcome, and the bound Job owner. + * Launch one target through a runner that uniquely owns its Job handle. + * @param spec - ordinary target request. + * @param targetEnv - validated complete target environment. + * @param internals - optional runner launch seams used by tests. + * @returns direct streams, result, and runner-owned managed range. */ export function launchWindowsJob( spec: SubprocessSpawnSpec, + targetEnv: Record, internals: WindowsJobInternals = {}, ): ManagedProcessLaunch { - const run = internals.spawn ?? spawn const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation - const files = runnerFiles(spec) - let stdio: ReturnType - try { - stdio = createWindowsStdioBridge( - spec, - `\\\\.\\pipe\\dsh-subprocess-${String(process.pid)}-${randomUUID()}`, - ) - } catch (error) { - cleanupRunnerFiles(files) - throw error + const child: RunnerProcess = (internals.spawn ?? spawn)(command, [ + ...prefix, + '--', + ...spec.argv, + ], { + cwd: process.cwd(), + env: runnerEnvironment(WINDOWS_RUNNER_SELECTION), + stdio: runnerStdio(spec, true), + }) + + const direct = Promise.withResolvers() + const infrastructure = Promise.withResolvers() + const rangeExit = Promise.withResolvers() + let resultSeen = false + let infrastructureFailed = false + const failInfrastructure = (error: Error): void => { + if (infrastructureFailed) return + infrastructureFailed = true + infrastructure.reject(error) } - let child: ReturnType + void infrastructure.promise.catch(() => {}) + + const owner = new WindowsJobOwner(child, rangeExit.promise, failInfrastructure) + child.on('message', (value: unknown) => { + if (resultSeen) { + const error = new Error('subprocess-local: Windows runner emitted more than one direct result') + failInfrastructure(error) + owner.terminateForHostExit() + return + } + let result: ReturnType + try { + result = parseWindowsRunnerResult(value) + } catch (error) { + /* v8 ignore next -- the closed parser raises Error instances for every malformed shape; + * conversion only defends future internal regressions. */ + const failure = error instanceof Error ? error : new Error(String(error)) + failInfrastructure(failure) + owner.terminateForHostExit() + return + } + resultSeen = true + if (result.type === 'target-exit') { + direct.resolve({ exitCode: result.exitCode, signal: result.signal }) + } else if (result.type === 'start-cancelled') { + direct.reject(owner.startCancellationReason()) + } else { + direct.reject(deserializeRunnerError(result.error)) + } + }) + 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 + if (clean) { + rangeExit.resolve() + return + } + const status = signal !== null + ? `signal ${signal}` + : exitCode === null + ? 'without an exit status' + : `exit code ${String(exitCode)}` + const error = new Error( + `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 } try { - child = run(command, [ - ...prefix, - '--mode', - 'win32', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ...stdio.runnerArgs, - ], { - env: childEnv(), - stdio: stdio.runnerStdio, + if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel') + child.send(start, (error) => { + if (error === null) return + failInfrastructure(error) + direct.reject(error) + owner.terminateForHostExit() }) } catch (error) { - stdio.dispose() - cleanupRunnerFiles(files) - throw error + const failure = error instanceof Error ? error : new Error(String(error)) + failInfrastructure(failure) + direct.reject(failure) + owner.terminateForHostExit() } - const lifecycle = observeChildLifecycle(child) - const result = runnerDirectResult(child, files, lifecycle.exited) - const owner = new WindowsJobOwner(child) - void result.direct.then( - () => { stdio.closeInput() }, - () => { stdio.dispose() }, - ) - cleanupAfterRunner(files, result.direct, lifecycle.closed) + return { - stdin: stdio.stdin, - stdout: stdio.stdout, - stderr: stdio.stderr, - get pid() { return result.pid }, - direct: result.direct, + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + direct: direct.promise, owner, + infrastructureFailure: infrastructure.promise, } } diff --git a/packages/subprocess/subprocess-local/src/windows-stdio.ts b/packages/subprocess/subprocess-local/src/windows-stdio.ts deleted file mode 100644 index 79037ee86b..0000000000 --- a/packages/subprocess/subprocess-local/src/windows-stdio.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** Parent-owned named-pipe streams for one Windows native launch. */ - -import type { StdioOptions } from 'node:child_process' -import { createServer } from 'node:net' -import type { Server, Socket } from 'node:net' -import { PassThrough } from 'node:stream' -import type { Readable, Writable } from 'node:stream' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' - -interface PipeEndpoint { - readonly path: string - readonly stream: PassThrough - dispose(): void -} - -/** Streams and runner arguments for one Windows launch. */ -export interface WindowsStdioBridge { - readonly stdin: Writable | null - readonly stdout: Readable | null - readonly stderr: Readable | null - readonly runnerArgs: string[] - readonly runnerStdio: StdioOptions - closeInput(): void - dispose(): void -} - -function closeServer(server: Server): void { - try { - server.close() - } catch { - // A listen failure or an already-accepted connection can close first. - } -} - -function createEndpoint(path: string, direction: 'input' | 'output'): PipeEndpoint { - const stream = new PassThrough() - let socket: Socket | undefined - let disposed = false - const server = createServer({ allowHalfOpen: true }) - // Direct-result failure remains authoritative for setup errors. Keep stream - // errors observable without allowing an early server failure to go unhandled. - /* v8 ignore next -- exercised only when the OS listener or socket reports an asynchronous fault. */ - stream.on('error', () => {}) - /* v8 ignore next -- platform-specific listen failures are reported asynchronously. */ - server.once('error', (error) => { stream.destroy(error) }) - server.once('connection', (connection) => { - /* v8 ignore start -- dispose racing an already-queued OS accept is not deterministic in unit tests. */ - if (disposed) { - connection.destroy() - return - } - /* v8 ignore stop */ - socket = connection - closeServer(server) - /* v8 ignore next -- exercised only by an asynchronous OS socket fault. */ - connection.once('error', (error) => { stream.destroy(error) }) - stream.once('close', () => { connection.destroy() }) - if (direction === 'output') { - connection.once('end', () => { connection.end() }) - connection.pipe(stream) - } else { - connection.resume() - stream.pipe(connection) - connection.once('close', () => { stream.destroy() }) - } - }) - try { - server.listen(path) - /* v8 ignore start -- the production path always supplies a validated short pipe name. */ - } catch (error) { - stream.destroy() - closeServer(server) - throw error - } - /* v8 ignore stop */ - return { - path, - stream, - dispose() { - disposed = true - closeServer(server) - socket?.destroy() - stream.destroy() - }, - } -} - -/** - * Create private parent-owned streams whose peer handles are opened by the Windows runner. - * @param spec - target stdio dispositions. - * @param basePath - unique named-pipe base chosen by the launch owner. - * @returns public streams, runner arguments, and cleanup for pre-start failure. - */ -export function createWindowsStdioBridge( - spec: SubprocessSpawnSpec, - basePath: string, -): WindowsStdioBridge { - const endpoints: PipeEndpoint[] = [] - let stdin: PipeEndpoint | undefined - let stdout: PipeEndpoint | undefined - let stderr: PipeEndpoint | undefined - try { - if (spec.stdio.stdin !== 'ignore') { - stdin = createEndpoint(`${basePath}-stdin`, 'input') - endpoints.push(stdin) - } - if (spec.stdio.stdout !== 'inherit') { - stdout = createEndpoint(`${basePath}-stdout`, 'output') - endpoints.push(stdout) - } - if (spec.stdio.stderr !== 'inherit') { - stderr = createEndpoint(`${basePath}-stderr`, 'output') - endpoints.push(stderr) - } - /* v8 ignore start -- only a synchronous Node listener-construction failure reaches this rollback. */ - } catch (error) { - for (const endpoint of endpoints) endpoint.dispose() - throw error - } - /* v8 ignore stop */ - return { - stdin: stdin?.stream ?? null, - stdout: stdout?.stream ?? null, - stderr: stderr?.stream ?? null, - runnerArgs: [ - ...stdin === undefined ? [] : ['--stdin-pipe', stdin.path], - ...stdout === undefined ? [] : ['--stdout-pipe', stdout.path], - ...stderr === undefined ? [] : ['--stderr-pipe', stderr.path], - ], - runnerStdio: [ - 'ignore', - spec.stdio.stdout === 'inherit' ? 'inherit' : 'ignore', - spec.stdio.stderr === 'inherit' ? 'inherit' : 'ignore', - 'ipc', - ], - closeInput() { - stdin?.dispose() - }, - dispose() { - for (const endpoint of endpoints) endpoint.dispose() - }, - } -} diff --git a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts b/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts deleted file mode 100644 index 88d9e282e3..0000000000 --- a/packages/subprocess/subprocess-local/tests/fixtures/fake-job-runner.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { appendRunnerEvent, consumeRunnerRequest } from '../../src/runner-protocol.ts' - -const args = process.argv.slice(2) -const requestPath = args[args.indexOf('--request') + 1] as string -const eventsPath = args[args.indexOf('--events') + 1] as string -const request = consumeRunnerRequest(requestPath) -appendRunnerEvent(eventsPath, { type: 'started', pid: process.pid }) - -const configuredExit = Number(request.argv[1]) -// Events carry target results; zero means the runner completed its own observation. -if (Number.isSafeInteger(configuredExit)) { - setTimeout(() => { - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: configuredExit, signal: null }) - process.exitCode = 0 - }, 10) -} else { - const hold = setInterval(() => {}, 1_000) - let terminated = false - const terminate = (): void => { - if (terminated) return - terminated = true - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 1, signal: null }) - clearInterval(hold) - setImmediate(() => { - if (process.connected) process.disconnect() - }) - process.exitCode = 0 - } - process.on('message', (message: unknown) => { - if (message !== null && typeof message === 'object' && (message as { type?: unknown }).type === 'terminate') terminate() - }) - process.on('disconnect', terminate) -} diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ae25749d10..356b4f5e9c 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,561 +1,455 @@ -import { spawn, spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { dirname } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { EventEmitter } from 'node:events' +import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' import { launchLinuxScope, prepareLinuxTerminalScope, - probeLinuxRunner, + probeLinuxBootstrap, + probeLinuxNative, probeLinuxScope, probeLinuxUserManager, } from '../src/linux-scope.ts' -import { spawnRunnerInvocation } from '../src/runner-launch.ts' +import type { LinuxScopeInternals } from '../src/linux-scope.ts' +import { + consumeLinuxLaunchRequest, + linuxLaunchFilesFromLocator, + writeLinuxStartupError, +} from '../src/runner-protocol.ts' +import { SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts' -function spec(argv: string[]): SubprocessSpawnSpec { +const childProcessMocks = vi.hoisted(() => ({ + execFile: vi.fn(), + spawn: vi.fn(), + spawnSync: vi.fn(), +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() return { - argv, - cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, + ...actual, + execFile: childProcessMocks.execFile as unknown as typeof actual.execFile, + spawn: childProcessMocks.spawn as typeof actual.spawn, + spawnSync: childProcessMocks.spawnSync as typeof actual.spawnSync, + } +}) + +class FakeChild extends EventEmitter { + pid: number | undefined = 321 + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + stdin = new PassThrough() + stdout = new PassThrough() + stderr = new PassThrough() + kills: NodeJS.Signals[] = [] + + kill(signal: NodeJS.Signals): boolean { + this.kills.push(signal) + return true + } + + exit(exitCode: number | null, signal: NodeJS.Signals | null): void { + this.exitCode = exitCode + this.signalCode = signal + this.emit('exit', exitCode, signal) + } +} + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() + childProcessMocks.execFile.mockReset() + childProcessMocks.spawn.mockReset() + childProcessMocks.spawnSync.mockReset() +}) + +function missingUnit() { + return { status: 1, stdout: '', stderr: 'Unit dsh.scope could not be found.' } +} + +function activeUnit(state = 'active') { + return { status: 0, stdout: `${state}\n`, stderr: '' } +} + +function spec() { + return { + argv: ['tool', 'literal arg'], + cwd: '/target', + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: 100, - env: { LITERAL_VALUE: '$HOME ${UNCHANGED}' }, - } + env: { TARGET: 'yes' }, + } as const } -function asyncQuery(runSync: typeof spawnSync) { - return async (command: string, args: readonly string[]) => { - const result = runSync(command, [...args], { encoding: 'utf8', timeout: 5_000 }) - return { - status: result.status, - stdout: typeof result.stdout === 'string' ? result.stdout : '', - stderr: typeof result.stderr === 'string' ? result.stderr : '', - ...result.error === undefined ? {} : { error: result.error }, - } - } +function launch( + query: LinuxScopeInternals['systemctlQuery'], + overrides: LinuxScopeInternals = {}, +) { + const child = new FakeChild() + let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined + const spawn = vi.fn((_command: string, _args: readonly string[], received: typeof options) => { + options = received + return child + }) + const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' })) + const systemctlQuery = overrides.systemctlQuery ?? query + const result = launchLinuxScope(spec(), { TARGET: 'yes' }, { + spawn: overrides.spawn ?? spawn as never, + spawnSync: overrides.spawnSync ?? spawnSync as never, + ...systemctlQuery === undefined ? {} : { systemctlQuery }, + systemdRun: overrides.systemdRun ?? '/bin/systemd-run', + systemctl: overrides.systemctl ?? '/bin/systemctl', + runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'], + ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable }, + ...overrides.execveAvailable === undefined ? {} : { execveAvailable: overrides.execveAvailable }, + }) + const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV] + if (requestPath === undefined) throw new Error('launch did not publish a request locator') + directories.push(linuxLaunchFilesFromLocator(requestPath).directory) + return { child, result, requestPath, spawn, spawnSync, options } } -describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => { - it('separates the live manager, stable scope, and ordinary-runner probes', () => { - const secretName = 'DSH_SCOPE_TEST_TOKEN' - const previousSecret = process.env[secretName] - process.env[secretName] = 'secret' - const calls: string[][] = [] - const environments: Array = [] - const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { - calls.push([command, ...args]) - environments.push(options?.env) - return { status: 0, error: undefined } - }) as unknown as typeof spawnSync - const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js'] - try { - expect(probeLinuxUserManager({ - spawnSync: runSync, - systemctl: 'systemctl', - })).toBe(true) - expect(probeLinuxRunner({ - spawnSync: runSync, - runnerInvocation, - })).toBe(true) - expect(probeLinuxScope({ - spawnSync: runSync, - systemdRun: 'systemd-run', - systemctl: 'systemctl', - })).toBe(true) - expect(calls[0]).toEqual(['systemctl', '--user', 'show-environment']) - expect(calls[1]).toEqual([...runnerInvocation, '--mode', 'probe-node']) - expect(calls[2]).toContain('--expand-environment=no') - expect(calls[2]).not.toContain('--pipe') - expect(calls[2]).not.toContain('--wait') - const unitArg = calls[2]?.find(arg => arg.startsWith('--unit=')) - if (unitArg === undefined) throw new Error('scope probe did not publish its unit') - const separator = calls[2]?.indexOf('--') ?? -1 - expect(calls[2]?.slice(separator + 1)).toEqual([ - 'systemctl', - '--user', - 'show', - `${unitArg.slice('--unit='.length)}.scope`, - '--property=ActiveState', - '--value', - ]) - expect(environments[0]?.LC_ALL).toBe('C') - for (const environment of environments) expect(environment).not.toHaveProperty(secretName) - } finally { - if (previousSecret === undefined) Reflect.deleteProperty(process.env, secretName) - else process.env[secretName] = previousSecret +describe('Linux native capability selection', () => { + it('rechecks bootstrap, user manager, and literal transient-scope support', () => { + const spawnSync = vi.fn(() => ({ status: 0, error: undefined })) + const runnerAvailable = vi.fn(() => true) + const inputs = { + spawnSync: spawnSync as never, + runnerAvailable, + runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]], + execveAvailable: true, + systemdRun: '/bin/systemd-run', + systemctl: '/bin/systemctl', } + expect(probeLinuxNative(inputs)).toBe(true) + expect(probeLinuxNative(inputs)).toBe(true) + expect(runnerAvailable).toHaveBeenCalledTimes(2) + expect(spawnSync).toHaveBeenCalledTimes(4) + expect(probeLinuxBootstrap({ ...inputs, execveAvailable: false })).toBe(false) + }) - const oldSystemd = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync - expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false) + it('reports each failed dynamic prerequisite without executing a target', () => { + expect(probeLinuxUserManager({ + spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never, + })).toBe(false) expect(probeLinuxScope({ - spawnSync: vi.fn(() => ({ status: 0, error: new Error('scope failed') })) as unknown as typeof spawnSync, + spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never, })).toBe(false) - - const failedRunner = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync - expect(probeLinuxRunner({ - spawnSync: failedRunner, - runnerInvocation: ['node-runtime', 'runner-entry.js'], + expect(probeLinuxBootstrap({ + execveAvailable: true, + runnerInvocation: ['/missing'], + runnerAvailable: () => false, })).toBe(false) - expect(failedRunner).toHaveBeenCalledOnce() - expect(probeLinuxRunner({ - spawnSync: vi.fn(() => ({ status: 0, error: new Error('runner failed') })) as unknown as typeof spawnSync, - runnerInvocation: ['node-runtime', 'runner-entry.js'], - })).toBe(false) - - const managerError = new Error('missing user manager') - expect(probeLinuxUserManager({ - spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync, - })).toBe(false) - expect(probeLinuxUserManager({ - spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, + expect(probeLinuxBootstrap({ + execveAvailable: true, + resolveRunnerInvocation: () => { throw new Error('runner resolution failed') }, })).toBe(false) }) - it('removes private runner files when systemd-run throws synchronously', () => { - const failure = new Error('systemd-run threw') - let requestPath: string | undefined - const run = vi.fn((_command: string, args: readonly string[]) => { - const requestIndex = args.indexOf('--request') - requestPath = args[requestIndex + 1] - throw failure - }) as unknown as typeof spawn + it('uses the default command adapters and runner resolution', () => { + childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined }) + expect(probeLinuxUserManager()).toBe(true) + expect(probeLinuxScope()).toBe(true) + expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2) + expect(probeLinuxBootstrap({ execveAvailable: true })).toBe(true) + expect(probeLinuxBootstrap({ + runnerInvocation: [process.execPath], + runnerAvailable: () => true, + })).toBe(typeof process.execve === 'function') + }) +}) - expect(() => launchLinuxScope(spec([process.execPath, '-e', '']), { - spawn: run, - runnerInvocation: spawnRunnerInvocation(), - })).toThrow(failure) - expect(requestPath).toBeDefined() - expect(existsSync(dirname(requestPath as string))).toBe(false) +describe('Linux scope establishment and quiescence', () => { + it('does not mistake pre-establishment unit absence for quiescence and rejects after cancellation', async () => { + const { child, result, requestPath, spawnSync } = launch(async () => missingUnit()) + const waiting = result.owner.waitForExit() + result.owner.signal('SIGTERM') + expect(child.kills).toEqual(['SIGTERM']) + expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([ + 'kill', '--kill-whom=all', '--signal=SIGTERM', + ]), expect.anything()) + const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed') + child.exit(null, 'SIGTERM') + await direct + await expect(waiting).rejects.toThrow('ended before consuming its launch request') + expect(existsSync(requestPath)).toBe(true) + result.owner.cleanup?.() }) - it('wraps terminal argv literally and binds signalling and observation to the same scope', async () => { - const signalCalls: Array<[string, readonly string[]]> = [] - const queryCalls: Array<[string, readonly string[]]> = [] - const runSync = vi.fn((command: string, args: readonly string[]) => { - signalCalls.push([command, args]) - return { status: 0, stdout: '', stderr: '', error: undefined } - }) as unknown as typeof spawnSync - const query = vi.fn(async (command: string, args: readonly string[]) => { - queryCalls.push([command, args]) - return { status: 0, stdout: 'inactive\n', stderr: '' } - }) - const argv = ['/bin/bash', '-c', 'printf "%s" "$HOME"'] - const launch = prepareLinuxTerminalScope(argv, { - spawnSync: runSync, - systemdRun: '/usr/bin/systemd-run', - systemctl: '/usr/bin/systemctl', - systemctlQuery: query, - }) - const unitArg = launch.args.find(arg => arg.startsWith('--unit=')) - if (unitArg === undefined) throw new Error('terminal scope did not publish its unit') - const unit = `${unitArg.slice('--unit='.length)}.scope` + it('accepts request consumption followed by rapid --collect unload as stopped', async () => { + const states = [activeUnit(), missingUnit()] + const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit()) + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + const waiting = result.owner.waitForExit() + child.exit(0, null) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(waiting).resolves.toBeUndefined() + result.owner.signal('SIGKILL') + result.owner.cleanup?.() + expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false) + }) - expect(launch.command).toBe('/usr/bin/systemd-run') - expect(launch.args.slice(0, -argv.length)).toEqual([ - '--user', - '--scope', - '--quiet', - '--collect', - '--expand-environment=no', - unitArg, - '--', - ]) - expect(launch.args.slice(-argv.length)).toEqual(argv) + it('uses manager-observed unit existence as establishment proof', async () => { + const { child, result } = launch(async () => activeUnit('inactive')) + await expect(result.owner.waitForExit()).resolves.toBeUndefined() + child.exit(1, null) + await expect(result.direct).rejects.toThrow('before its bootstrap consumed') + result.owner.cleanup?.() + }) - const owner = launch.bindOwner(() => false) - owner.signal('SIGTERM') - owner.signal('SIGKILL') - await owner.waitForExit() + it('keeps waiting while the unit is absent and the direct launcher is still running', async () => { + const states = [missingUnit(), activeUnit('inactive')] + const { child, result } = launch(async () => states.shift() ?? activeUnit('inactive')) + await expect(result.owner.waitForExit()).resolves.toBeUndefined() + child.exit(1, null) + await expect(result.direct).rejects.toThrow('before its bootstrap consumed') + result.owner.cleanup?.() + }) - expect(signalCalls).toEqual([ - [ - '/usr/bin/systemctl', - ['--user', 'kill', '--kill-whom=all', '--signal=SIGTERM', unit], - ], - [ - '/usr/bin/systemctl', - ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit], - ], - ]) - expect(queryCalls).toEqual([[ - '/usr/bin/systemctl', - ['--user', 'show', unit, '--property=ActiveState', '--value'], - ]]) + it('reports child termination before request consumption to both result and wait', async () => { + const { child, result } = launch(async () => missingUnit()) + child.exit(127, null) + await expect(result.direct).rejects.toThrow('before its bootstrap consumed') + await expect(result.owner.waitForExit()).rejects.toThrow('ended before consuming its launch request') + result.owner.cleanup?.() }) - it('keeps user argv out of systemd-run and reports the direct target outcome', async () => { - let wrapper: ReturnType | undefined - let systemdArgs: readonly string[] = [] - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - systemdArgs = args - const separator = args.indexOf('--') - const command = args[separator + 1] as string - wrapper = spawn(command, args.slice(separator + 2), options) - return wrapper - }) as unknown as typeof spawn - const runSyncMock = vi.fn((command: string, args: readonly string[]) => { - if (command === 'systemctl' && args[1] === 'show') { - const active = wrapper?.exitCode === null && wrapper.signalCode === null - return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } - } - return { status: 0, stdout: '', stderr: '', error: undefined } + it('reconstructs a pre-exec startup error instead of exposing bootstrap exit 127', async () => { + const { child, result, requestPath } = launch(async () => missingUnit()) + const files = linuxLaunchFilesFromLocator(requestPath) + unlinkSync(requestPath) + writeLinuxStartupError(files, { + type: 'spawn-error', + error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' }, }) - const runSync = runSyncMock as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), + child.exit(127, null) + await expect(result.direct).rejects.toMatchObject({ code: 'ENOENT' }) + result.owner.cleanup?.() + }) + + it('retries a failed state query and rejects unknown states or failed final kills', async () => { + const query = vi.fn() + .mockResolvedValueOnce({ status: null, stdout: '', stderr: '', error: new Error('query failed') }) + .mockResolvedValueOnce(missingUnit()) + const { result, requestPath } = launch(query) + unlinkSync(requestPath) + await expect(result.owner.waitForExit()).rejects.toThrow('query failed') + await expect(result.owner.waitForExit()).resolves.toBeUndefined() + result.owner.cleanup?.() + + const unknown = launch(async () => activeUnit('mystery')) + await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState') + unknown.result.owner.cleanup?.() + + const killFailed = launch(async () => activeUnit(), { + spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never, }) - await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - const callsBeforeStaleSignal = runSyncMock.mock.calls.length - launch.owner.signal('SIGKILL') - expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal) - expect(systemdArgs).toContain('--expand-environment=no') - expect(systemdArgs).not.toContain('--pipe') - expect(systemdArgs).not.toContain('--wait') - expect(systemdArgs).not.toContain('literal $VALUE') + killFailed.result.owner.signal('SIGKILL') + await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal') + killFailed.result.owner.cleanup?.() }) - it('uses a scope KILL after the owner proves the range empty', async () => { - let wrapper: ReturnType | undefined - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - const command = args[separator + 1] as string - wrapper = spawn(command, args.slice(separator + 2), { ...options, detached: true }) - return wrapper - }) as unknown as typeof spawn - const runSync = vi.fn((command: string, args: readonly string[]) => { - if (command === 'systemctl' && args[1] === 'kill') { - if (args.includes('--signal=SIGTERM')) { - return { status: 1, stdout: '', stderr: 'Unit could not be found', error: undefined } - } - if (wrapper?.pid !== undefined) process.kill(-wrapper.pid, 'SIGKILL') - return { - status: 1, - stdout: '', - stderr: 'Failed to send signal SIGKILL to auxiliary processes: Invalid argument', - error: undefined, - } - } - if (command === 'systemctl' && args[1] === 'show') { - const active = wrapper?.exitCode === null && wrapper.signalCode === null - return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } - } - return { status: 0, stdout: '', stderr: '', error: undefined } - }) as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), + it('reports command-query failures from the default systemctl adapter', async () => { + childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void + callback(null, 'inactive\n', '') + return new EventEmitter() }) - launch.owner.signal('SIGTERM') - launch.owner.signal('SIGKILL') - await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + const stopped = launch(undefined) + await expect(stopped.result.owner.waitForExit()).resolves.toBeUndefined() + stopped.result.owner.cleanup?.() + + const queryError = Object.assign(new Error('systemctl execution failed'), { code: 'ENOENT' }) + childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void + callback(queryError, '', '') + return new EventEmitter() + }) + const failed = launch(undefined) + await expect(failed.result.owner.waitForExit()).rejects.toBe(queryError) + failed.result.owner.cleanup?.() }) - it('retries wait after the selected native owner becomes readable again', async () => { - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - return spawn(args[separator + 1] as string, args.slice(separator + 2), options) - }) as unknown as typeof spawn - const failure = new Error('Failed to connect to bus: No such file or directory') - const query = vi.fn() - .mockRejectedValueOnce(failure) - .mockResolvedValue({ status: 0, stdout: 'inactive\n', stderr: '' }) - const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { - spawn: run, - spawnSync: vi.fn(() => ({ status: 0, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync, - systemctlQuery: query, - runnerInvocation: spawnRunnerInvocation(), + it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => { + const spawnSync = vi.fn() + .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 1, stderr: 'Unit dsh.scope could not be found.' }) + .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + const states = [activeUnit(), activeUnit('failed')] + const launched = launch(async () => states.shift() ?? missingUnit(), { + spawnSync: spawnSync as never, }) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).rejects.toBe(failure) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - expect(query).toHaveBeenCalledTimes(2) + launched.child.pid = undefined + unlinkSync(launched.requestPath) + launched.result.owner.signal('SIGTERM') + launched.result.owner.signal('SIGKILL') + launched.result.owner.signal('SIGKILL') + launched.result.owner.signal('SIGKILL') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + launched.result.owner.terminateForHostExit() + expect(spawnSync).toHaveBeenCalledTimes(4) + launched.result.owner.cleanup?.() }) - it('clears a failed KILL result after a later KILL succeeds', async () => { - let killed = false - let killAttempts = 0 - const runSync = vi.fn((_command: string, args: readonly string[]) => { - if (args.includes('--signal=SIGKILL')) { - killAttempts += 1 - if (killAttempts === 1) { - return { status: 1, stdout: '', stderr: 'kill failed', error: undefined } - } - killed = true - } - return { status: 0, stdout: '', stderr: '', error: undefined } - }) as unknown as typeof spawnSync - const query = vi.fn(async () => ({ - status: 0, - stdout: killed ? 'inactive\n' : 'active\n', - stderr: '', + it('reports unreadable manager output and a failed kill before establishment', async () => { + const withOutput = launch(async () => ({ + status: 5, stdout: '', stderr: 'permission denied', })) - const launch = prepareLinuxTerminalScope(['/bin/sh'], { - spawnSync: runSync, - systemctlQuery: query, - }) - const owner = launch.bindOwner(() => true) + await expect(withOutput.result.owner.waitForExit()).rejects.toThrow('permission denied') + withOutput.result.owner.cleanup?.() - owner.signal('SIGKILL') - await expect(owner.waitForExit()).rejects.toThrow('kill failed') - owner.signal('SIGKILL') - await expect(owner.waitForExit()).resolves.toBeUndefined() + const withoutOutput = launch(async () => ({ status: null, stdout: '', stderr: '' })) + await expect(withoutOutput.result.owner.waitForExit()).rejects.toThrow('exit null') + withoutOutput.result.owner.cleanup?.() - expect(query).toHaveBeenCalledTimes(2) + const killFailed = launch(async () => missingUnit(), { + spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'kill denied' })) as never, + }) + vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') }) + killFailed.result.owner.signal('SIGKILL') + await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('kill denied') + killFailed.result.owner.cleanup?.() }) - it('propagates systemctl execution failures and unknown active states', async () => { - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - return spawn(args[separator + 1] as string, args.slice(separator + 2), options) - }) as unknown as typeof spawn - const failure = new Error('systemctl execution failed') - const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { - spawn: run, - spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync, - systemctlQuery: async () => ({ status: null, stdout: '', stderr: '', error: failure }), - runnerInvocation: spawnRunnerInvocation(), - }) - await expect(failedRead.owner.waitForExit()).rejects.toBe(failure) - await expect(failedRead.direct).resolves.toEqual({ exitCode: 0, signal: null }) + it('settles direct outcomes once and reports malformed startup errors', async () => { + const childError = launch(async () => missingUnit()) + const spawnError = new Error('systemd-run failed') + childError.child.emit('error', spawnError) + childError.child.exit(1, null) + await expect(childError.result.direct).rejects.toBe(spawnError) + childError.result.owner.cleanup?.() - const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { - spawn: run, - spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync, - systemctlQuery: async () => ({ status: 0, stdout: 'reloading\n', stderr: '' }), - runnerInvocation: spawnRunnerInvocation(), - }) - await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState') - await expect(unknownState.direct).resolves.toEqual({ exitCode: 0, signal: null }) + const lateError = launch(async () => missingUnit()) + consumeLinuxLaunchRequest(lateError.requestPath) + lateError.child.exit(0, null) + lateError.child.emit('error', new Error('late child error')) + await expect(lateError.result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + lateError.result.owner.cleanup?.() - const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { - spawn: run, - spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync, - systemctlQuery: async () => ({ status: 1, stdout: '', stderr: '' }), - runnerInvocation: spawnRunnerInvocation(), - }) - await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1') - await expect(blankFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) + const malformed = launch(async () => missingUnit()) + const files = linuxLaunchFilesFromLocator(malformed.requestPath) + unlinkSync(malformed.requestPath) + writeFileSync(files.startupErrorPath, '{', { mode: 0o600 }) + malformed.child.exit(127, null) + await expect(malformed.result.direct).rejects.toBeInstanceOf(SyntaxError) + malformed.result.owner.cleanup?.() }) - it.each(['activating', 'deactivating', 'failed'])( - 'recognizes the %s scope state', - async (initialState) => { - let wrapper: ReturnType | undefined - let reads = 0 - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options) - return wrapper - }) as unknown as typeof spawn - const runSync = vi.fn(() => { - reads += 1 - return { - status: 0, - stdout: reads === 1 ? `${initialState}\n` : 'inactive\n', - stderr: '', - error: undefined, - } - }) as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), - }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - }, - ) + it('does not signal a direct group before the launcher publishes a pid', async () => { + const launched = launch(async () => activeUnit('inactive')) + launched.child.pid = undefined + const processKill = vi.spyOn(process, 'kill') + launched.result.owner.signal('SIGTERM') + expect(processKill).not.toHaveBeenCalled() + expect(launched.child.kills).toEqual([]) + consumeLinuxLaunchRequest(launched.requestPath) + launched.child.exit(0, null) + await expect(launched.result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + launched.result.owner.cleanup?.() + }) - it('uses runner liveness when systemd has already forgotten the scope', async () => { - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - return spawn(args[separator + 1] as string, args.slice(separator + 2), options) - }) as unknown as typeof spawn - const runSyncMock = vi.fn(() => ({ - status: 1, - stdout: '', - stderr: 'Unit could not be found', - error: undefined, - })) - const runSync = runSyncMock as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), + it('runs direct fallback before the exact synchronous scope kill on host exit', () => { + const events: string[] = [] + const { child, result } = launch(async () => missingUnit(), { + spawnSync: vi.fn(() => { events.push('scope'); return { status: 0 } }) as never, }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - expect(runSyncMock.mock.calls.length).toBeGreaterThan(1) + child.kill = vi.fn(() => { events.push('direct'); return true }) + vi.spyOn(process, 'kill').mockImplementation(() => { events.push('direct'); return true }) + result.owner.terminateForHostExit() + expect(events).toEqual(['direct', 'scope']) + result.owner.cleanup?.() }) +}) + +describe('Linux PTY bootstrap reuse', () => { + const terminalSpec = { + argv: ['bash', '--noprofile'], + cwd: '/target', + env: { TARGET: 'yes' }, + rows: 24, + cols: 80, + graceMs: 100, + } as const - it('settles a missing scope immediately when the wrapper never started', async () => { - const launch = launchLinuxScope(spec([process.execPath, '-e', '']), { - systemdRun: `missing-systemd-run-${String(process.pid)}-${String(Date.now())}`, - systemctlQuery: async () => ({ - status: 1, - stdout: '', - stderr: 'Unit dsh-subprocess-missing.scope could not be found', - }), - runnerInvocation: spawnRunnerInvocation(), + it('uses the same request/bootstrap, preserves argv, and cleans after owner settlement', async () => { + const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }, { + systemdRun: '/bin/systemd-run', + systemctl: '/bin/systemctl', + runnerInvocation: ['/usr/bin/node', '/runner.js'], + spawnSync: vi.fn(() => ({ status: 0 })) as never, + systemctlQuery: async () => missingUnit(), }) - expect(launch.pid).toBeUndefined() - await expect(launch.direct).rejects.toThrow('runner failed to start') - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + const requestPath = scope.env[SUBPROCESS_RUNNER_ENV] + if (requestPath === undefined) throw new Error('missing PTY request') + expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile']) + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + const owner = scope.bindOwner({ running: () => false, signal: vi.fn() }) + await expect(owner.waitForExit()).resolves.toBeUndefined() + expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null }) + scope.cleanup() + expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false) }) - it('does not fabricate a direct outcome after a non-forced scope signal', async () => { - let wrapper: ReturnType | undefined - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), { ...options, detached: true }) - return wrapper - }) as unknown as typeof spawn - const runSync = vi.fn((command: string, args: readonly string[]) => { - if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) { - process.kill(-wrapper.pid, 'SIGKILL') - } - if (command === 'systemctl' && args[1] === 'show') { - const active = wrapper?.exitCode === null && wrapper.signalCode === null - return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined } - } - return { status: 0, stdout: '', stderr: '', error: undefined } - }) as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), + it('surfaces PTY pre-exec errors instead of launcher outcomes', () => { + const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }) + const requestPath = scope.env[SUBPROCESS_RUNNER_ENV] + if (requestPath === undefined) throw new Error('missing PTY request') + const files = linuxLaunchFilesFromLocator(requestPath) + unlinkSync(requestPath) + writeLinuxStartupError(files, { + type: 'spawn-error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' }, }) - launch.owner.signal('SIGTERM') - await expect(launch.direct).rejects.toThrow('exited without a direct-command result') - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() + expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd') + scope.cleanup() }) - it.each([ - [ - 'execution error', - { status: null, stdout: '', stderr: '', error: new Error('systemctl execution failed') }, - 'systemctl execution failed', - ], - [ - 'stderr', - { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined }, - 'Failed to connect to bus', - ], - [ - 'exit status', - { status: 1, stdout: '', stderr: '', error: undefined }, - 'exit 1', - ], - ])('reports a failed scope KILL through the shared wait: %s', async (_label, failure, message) => { - let wrapper: ReturnType | undefined - const run = vi.fn((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options) - return wrapper - }) as unknown as typeof spawn - const runSyncMock = vi.fn(( - command: string, - args: readonly string[], - _options?: { env?: NodeJS.ProcessEnv }, - ) => { - if (command === 'systemctl' && args[1] === 'kill') { - return failure - } - return { status: 0, stdout: 'active\n', stderr: '', error: undefined } - }) - const runSync = runSyncMock as unknown as typeof spawnSync - const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), { - spawn: run, - spawnSync: runSync, - systemctlQuery: asyncQuery(runSync), - runnerInvocation: spawnRunnerInvocation(), - }) - void launch.direct.catch(() => {}) - try { - launch.owner.signal('SIGKILL') - await expect(launch.owner.waitForExit()).rejects.toThrow(message) - const killCall = runSyncMock.mock.calls.find(([, args]) => args.includes('--signal=SIGKILL')) - expect(killCall?.[0]).toBe('systemctl') - expect(killCall?.[1]).toContain('kill') - expect(killCall?.[1]).toContain('--kill-whom=all') - expect(killCall?.[2]?.env?.LC_ALL).toBe('C') - } finally { - wrapper?.kill('SIGKILL') - } + it('uses default owner dependencies and rejects an unconsumed request', () => { + const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }) + const requestPath = scope.env[SUBPROCESS_RUNNER_ENV] + if (requestPath === undefined) throw new Error('missing PTY request') + directories.push(linuxLaunchFilesFromLocator(requestPath).directory) + scope.bindOwner({ running: () => true, signal: vi.fn() }) + expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow( + 'before its bootstrap consumed', + ) + scope.cleanup() }) +}) - it('uses the production command defaults when no Linux internals are supplied', async () => { - let wrapper: ReturnType | undefined - let queryFailure: (Error & { code?: string | number }) | undefined - const run = vi.fn() - const runSync = vi.fn() - const runAsync = vi.fn() - const queryEnvironments: Array = [] - vi.resetModules() - vi.doMock('node:child_process', async (importOriginal) => { - const actual = await importOriginal() - run.mockImplementation((_command: string, args: readonly string[], options: Parameters[2]) => { - const separator = args.indexOf('--') - wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options) - return wrapper - }) - runSync.mockImplementation((_command: string, _args: readonly string[]) => { - return { status: 0, stdout: '', stderr: '', error: undefined } - }) - runAsync.mockImplementation(( - _command: string, - args: readonly string[], - options: { env?: NodeJS.ProcessEnv }, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - queryEnvironments.push(options.env) - if (queryFailure !== undefined) { - callback(queryFailure, '', '') - return - } - const active = wrapper?.exitCode === null && wrapper.signalCode === null - callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '') - }) - return { ...actual, execFile: runAsync, spawn: run, spawnSync: runSync } - }) - try { - const defaults = await import('../src/linux-scope.ts') - expect(defaults.probeLinuxUserManager()).toBe(true) - expect(defaults.probeLinuxRunner()).toBe(true) - expect(defaults.probeLinuxScope()).toBe(true) - const terminalLaunch = defaults.prepareLinuxTerminalScope(['shell', 'literal $HOME']) - expect(terminalLaunch.command).toBe('systemd-run') - expect(terminalLaunch.args.slice(-3)).toEqual(['--', 'shell', 'literal $HOME']) - const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object)) - expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object)) - expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function)) - expect(queryEnvironments[0]?.LC_ALL).toBe('C') +describe('Linux ordinary launch adapters', () => { + it('uses the default launch dependencies without changing the target request', async () => { + const child = new FakeChild() + childProcessMocks.spawn.mockReturnValue(child) + const result = launchLinuxScope(spec(), { TARGET: 'yes' }) + const call = childProcessMocks.spawn.mock.calls[0] + const options = call?.[2] as { env?: NodeJS.ProcessEnv } | undefined + const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV] + if (requestPath === undefined) throw new Error('launch did not publish a request locator') + directories.push(linuxLaunchFilesFromLocator(requestPath).directory) + expect(call?.[0]).toBe('systemd-run') + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + child.exit(0, null) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + result.owner.cleanup?.() + }) - queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 }) - const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) - await expect(numericFailure.owner.waitForExit()).rejects.toBe(queryFailure) - await expect(numericFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) - - queryFailure = Object.assign(new Error('named systemctl failure'), { code: 'EQUERY' }) - const namedFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)'])) - await expect(namedFailure.owner.waitForExit()).rejects.toBe(queryFailure) - await expect(namedFailure.direct).resolves.toEqual({ exitCode: 0, signal: null }) - } finally { - vi.doUnmock('node:child_process') - vi.resetModules() - } + it('removes the private launch directory when spawn throws synchronously', () => { + const spawnError = new Error('synchronous spawn failure') + let requestPath: string | undefined + expect(() => launchLinuxScope(spec(), { TARGET: 'yes' }, { + runnerInvocation: ['/usr/bin/node', '/runner.js'], + spawn: vi.fn((_command: string, _args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => { + requestPath = options.env?.[SUBPROCESS_RUNNER_ENV] + throw spawnError + }) as never, + })).toThrow(spawnError) + if (requestPath === undefined) throw new Error('spawn did not receive a request locator') + expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false) }) }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index d852b3b101..19b5c62c1b 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -6,6 +6,17 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { childEnv } from '../src/spawn.ts' +function mockWin32ForIsolatedRuntime(): void { + vi.doMock('@deepseek-ai/dsh-win32-process', () => ({ + loadWin32ProcessBindings: vi.fn(), + probeCurrentTokenJobSupport: vi.fn(), + })) +} + +function unmockWin32ForIsolatedRuntime(): void { + vi.doUnmock('@deepseek-ai/dsh-win32-process') +} + function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { // Windows has no bash; the suite's simple commands translate to node one-liners. const argv = process.platform === 'win32' @@ -372,6 +383,7 @@ describe('LocalSubprocessRuntime', () => { kill: () => {}, } vi.resetModules() + mockWin32ForIsolatedRuntime() vi.doMock('node-pty', () => ({ spawn: () => terminal })) vi.doMock('../src/process-inspector.ts', async importOriginal => ({ ...await importOriginal(), @@ -394,6 +406,7 @@ describe('LocalSubprocessRuntime', () => { } finally { vi.doUnmock('node-pty') vi.doUnmock('../src/process-inspector.ts') + unmockWin32ForIsolatedRuntime() vi.resetModules() } }) @@ -401,6 +414,8 @@ describe('LocalSubprocessRuntime', () => { it('wraps Linux terminals in the selected scope and binds owner liveness', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined let launcherRunning: (() => boolean) | undefined + let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => void) | undefined + const terminalKill = vi.fn(() => { throw new Error('terminal already exited') }) const terminal = { pid: 123, onData: () => ({ dispose: () => {} }), @@ -409,29 +424,31 @@ describe('LocalSubprocessRuntime', () => { return { dispose: () => {} } }, write: () => {}, - kill: () => {}, + kill: terminalKill, } const nodePtySpawn = vi.fn(() => terminal) const owner = { signal: vi.fn(), waitForExit: vi.fn(async () => {}), + terminateForHostExit: vi.fn(), } const launcherStates: boolean[] = [] - const bindOwner = vi.fn((running: () => boolean) => { - launcherRunning = running - launcherStates.push(running()) + const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): void }) => { + launcherRunning = () => direct.running() + launcherSignal = (signal) => { direct.signal(signal) } + launcherStates.push(direct.running()) return owner }) - const prepareLinuxTerminalScope = vi.fn((argv: readonly string[]) => ({ + const prepareLinuxTerminalScope = vi.fn(() => ({ command: '/usr/bin/systemd-run', - args: ['--user', '--scope', '--', ...argv], + args: ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'], + cwd: '/bootstrap', + env: { BOOTSTRAP: 'yes' }, bindOwner, + resolveOutcome: (outcome: unknown) => outcome, + cleanup: vi.fn(), })) - const probeLinuxUserManager = vi.fn(() => true) - const probeLinuxScope = vi.fn(() => true) - const probeLinuxRunner = vi.fn(() => { - throw new Error('terminal selection must not probe the ordinary runner') - }) + const probeLinuxNative = vi.fn(() => true) const inspector = { foregroundPgid: () => undefined, isStdinWaiting: () => false, @@ -443,13 +460,12 @@ describe('LocalSubprocessRuntime', () => { } vi.resetModules() + mockWin32ForIsolatedRuntime() vi.doMock('node-pty', () => ({ spawn: nodePtySpawn })) vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope, - probeLinuxRunner, - probeLinuxScope, - probeLinuxUserManager, + probeLinuxNative, })) let fiber: { dispose(): Promise } | undefined try { @@ -464,18 +480,21 @@ describe('LocalSubprocessRuntime', () => { argv: ['shell', '--literal'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, }) - expect(probeLinuxUserManager).toHaveBeenCalledOnce() - expect(probeLinuxScope).toHaveBeenCalledOnce() - expect(probeLinuxRunner).not.toHaveBeenCalled() - expect(prepareLinuxTerminalScope).toHaveBeenCalledExactlyOnceWith(['shell', '--literal']) + expect(probeLinuxNative).toHaveBeenCalledOnce() + expect(prepareLinuxTerminalScope).toHaveBeenCalledWith( + expect.objectContaining({ argv: ['shell', '--literal'] }), + expect.any(Object), + ) expect(nodePtySpawn).toHaveBeenCalledWith( '/usr/bin/systemd-run', - ['--user', '--scope', '--', 'shell', '--literal'], - expect.objectContaining({ rows: 24, cols: 80 }), + ['--user', '--scope', '--quiet', '--collect', '--', '/usr/bin/node', '/runner.js', '--', 'shell', '--literal'], + expect.objectContaining({ rows: 24, cols: 80, cwd: '/bootstrap', env: { BOOTSTRAP: 'yes' } }), ) expect(bindOwner).toHaveBeenCalledOnce() expect(launcherStates).toEqual([true]) expect(launcherRunning?.()).toBe(true) + expect(() => { launcherSignal?.('SIGTERM') }).not.toThrow() + expect(terminalKill).toHaveBeenCalledExactlyOnceWith('SIGTERM') exitListener?.({ exitCode: 0 }) expect(launcherRunning?.()).toBe(false) @@ -487,6 +506,60 @@ describe('LocalSubprocessRuntime', () => { await fiber?.dispose() vi.doUnmock('node-pty') vi.doUnmock('../src/linux-scope.ts') + unmockWin32ForIsolatedRuntime() + vi.resetModules() + } + }) + + it('cleans the Linux terminal launch protocol when node-pty throws synchronously', async () => { + const launchFailure = new Error('node-pty launch failed') + const cleanup = vi.fn() + const nodePtySpawn = vi.fn(() => { throw launchFailure }) + const prepareLinuxTerminalScope = vi.fn(() => ({ + command: '/usr/bin/systemd-run', + args: ['--user', '--scope', '--', 'shell'], + cwd: '/bootstrap', + env: { BOOTSTRAP: 'yes' }, + bindOwner: vi.fn(), + resolveOutcome: (outcome: unknown) => outcome, + cleanup, + })) + const inspector = { + foregroundPgid: () => undefined, + isStdinWaiting: () => false, + processTree: () => [{ pid: 123, started: 'shell' }], + processSession: () => [], + isAlive: () => false, + signalGroup: () => {}, + signalProcess: () => {}, + } + + vi.resetModules() + mockWin32ForIsolatedRuntime() + vi.doMock('node-pty', () => ({ spawn: nodePtySpawn })) + vi.doMock('../src/linux-scope.ts', () => ({ + launchLinuxScope: vi.fn(), + prepareLinuxTerminalScope, + probeLinuxNative: () => true, + })) + let fiber: { dispose(): Promise } | undefined + try { + const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') + const ctx = new Context() + fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) + const runtime = ctx.subprocess as InstanceType + runtime.internals = { platform: 'linux' } + runtime.terminalInspector = inspector + + await expect(runtime.spawnTerminal({ + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + })).rejects.toBe(launchFailure) + expect(cleanup).toHaveBeenCalledOnce() + } finally { + await fiber?.dispose() + vi.doUnmock('node-pty') + vi.doUnmock('../src/linux-scope.ts') + unmockWin32ForIsolatedRuntime() vi.resetModules() } }) @@ -504,6 +577,7 @@ describe('LocalSubprocessRuntime', () => { kill: () => {}, } vi.resetModules() + mockWin32ForIsolatedRuntime() vi.doMock('node-pty', () => ({ spawn: () => terminal })) try { const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts') @@ -532,6 +606,7 @@ describe('LocalSubprocessRuntime', () => { expect(disposalErrors).toHaveLength(1) } finally { vi.doUnmock('node-pty') + unmockWin32ForIsolatedRuntime() vi.resetModules() } }) @@ -540,6 +615,7 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) const handle = ctx.subprocess.spawn(spec('echo managed')) + expect(handle).not.toHaveProperty('pid') const result = await handle.done expect(result.exitCode).toBe(0) expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n') @@ -593,21 +669,17 @@ describe('LocalSubprocessRuntime', () => { } }) - it('rechecks the Linux manager while caching successful stable native probes', async () => { + it('rechecks native prerequisites for every eligible spawn and prepares storage before launch', async () => { const linuxLaunch = { kind: 'linux' } const windowsLaunch = { kind: 'windows' } const launchLinuxScope = vi.fn(() => linuxLaunch) const launchWindowsJob = vi.fn(() => windowsLaunch) - const probeLinuxUserManager = vi.fn(() => true) - const probeLinuxScope = vi.fn(() => true) - const probeLinuxRunner = vi.fn(() => true) + const probeLinuxNative = vi.fn(() => true) const probeWindowsJob = vi.fn(() => true) const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' })) - let nextPid = 100 const handles = [true, false, false].map((failFirstWait) => { let waits = 0 return { - pid: nextPid++, collected: {}, done: Promise.resolve({ exitCode: 0, signal: null }), terminate: vi.fn(), @@ -627,12 +699,11 @@ describe('LocalSubprocessRuntime', () => { const spawnSubprocess = vi.fn() vi.resetModules() + mockWin32ForIsolatedRuntime() vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope, prepareLinuxTerminalScope: vi.fn(), - probeLinuxRunner, - probeLinuxScope, - probeLinuxUserManager, + probeLinuxNative, })) vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob })) vi.doMock('../src/spawn.ts', async importOriginal => ({ @@ -657,9 +728,7 @@ describe('LocalSubprocessRuntime', () => { await new Promise(resolve => setImmediate(resolve)) await linuxRuntime.spawn(spec('true')).done await new Promise(resolve => setImmediate(resolve)) - expect(probeLinuxUserManager).toHaveBeenCalledTimes(3) - expect(probeLinuxScope).toHaveBeenCalledOnce() - expect(probeLinuxRunner).toHaveBeenCalledOnce() + expect(probeLinuxNative).toHaveBeenCalledTimes(3) expect(launchLinuxScope).toHaveBeenCalledTimes(2) const windowsContext = new Context() @@ -683,35 +752,30 @@ describe('LocalSubprocessRuntime', () => { vi.doUnmock('../src/linux-scope.ts') vi.doUnmock('../src/windows-job.ts') vi.doUnmock('../src/spawn.ts') + unmockWin32ForIsolatedRuntime() vi.resetModules() } }) - it('retries failed stable probes and does not cache Linux manager availability', async () => { - const probeLinuxUserManager = vi.fn() + it('does not cache failed or successful native capability probes', async () => { + const probeLinuxNative = vi.fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) .mockReturnValueOnce(false) .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true) - const probeLinuxScope = vi.fn() - .mockReturnValueOnce(false) - .mockReturnValueOnce(true) - const probeLinuxRunner = vi.fn() .mockReturnValueOnce(false) .mockReturnValueOnce(true) const probeWindowsJob = vi.fn() .mockReturnValueOnce(false) .mockReturnValueOnce(true) + .mockReturnValueOnce(true) vi.resetModules() + mockWin32ForIsolatedRuntime() vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope: vi.fn(), - probeLinuxRunner, - probeLinuxScope, - probeLinuxUserManager, + probeLinuxNative, })) vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob })) const fibers: Array<{ dispose(): Promise }> = [] @@ -733,9 +797,7 @@ describe('LocalSubprocessRuntime', () => { expect(linuxSelect('ordinary')).toBe('linux-scope') expect(linuxSelect('ordinary')).toBe('fallback') expect(linuxSelect('ordinary')).toBe('linux-scope') - expect(probeLinuxUserManager).toHaveBeenCalledTimes(6) - expect(probeLinuxScope).toHaveBeenCalledTimes(2) - expect(probeLinuxRunner).toHaveBeenCalledTimes(2) + expect(probeLinuxNative).toHaveBeenCalledTimes(6) const windowsContext = new Context() vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {}) @@ -750,11 +812,12 @@ describe('LocalSubprocessRuntime', () => { expect(windowsSelect('ordinary')).toBe('fallback') expect(windowsSelect('ordinary')).toBe('windows-job') expect(windowsSelect('ordinary')).toBe('windows-job') - expect(probeWindowsJob).toHaveBeenCalledTimes(2) + expect(probeWindowsJob).toHaveBeenCalledTimes(3) } finally { for (const fiber of fibers.reverse()) await fiber.dispose() vi.doUnmock('../src/linux-scope.ts') vi.doUnmock('../src/windows-job.ts') + unmockWin32ForIsolatedRuntime() vi.resetModules() } }) diff --git a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts b/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts deleted file mode 100644 index ed5e299f44..0000000000 --- a/packages/subprocess/subprocess-local/tests/managed-spawn.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { spawn } from 'node:child_process' -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import type { BoundProcessOwner } from '../src/managed-owner.ts' -import { waitWithAbort } from '../src/managed-owner.ts' -import { bindManagedProcess } from '../src/spawn.ts' - -function spec(graceMs = 30): SubprocessSpawnSpec { - return { - argv: [process.execPath], - cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, - graceMs, - } -} - -describe('managed process binding', () => { - it('forwards target pid publication after the handle is returned', async () => { - const target = { pid: undefined as number | undefined } - const handle = bindManagedProcess({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, { - stdin: null, - stdout: null, - stderr: null, - get pid() { return target.pid }, - direct: Promise.resolve({ exitCode: 0, signal: null }), - owner: { signal: vi.fn(), waitForExit: async () => {} }, - }) - - expect(handle.pid).toBeUndefined() - target.pid = 4242 - expect(handle.pid).toBe(4242) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - }) - - it('does not miss an abort between the initial check and listener registration', async () => { - let aborted = false - const addEventListener = vi.fn(() => { aborted = true }) - const removeEventListener = vi.fn() - const signal = { - get aborted() { return aborted }, - addEventListener, - removeEventListener, - } as unknown as AbortSignal - - await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) - expect(addEventListener).toHaveBeenCalledOnce() - expect(removeEventListener).toHaveBeenCalledOnce() - }) - - it('contains owner failure after an already-aborted wait returns false', async () => { - const controller = new AbortController() - const ownerFailure = Promise.withResolvers() - controller.abort() - - await expect(waitWithAbort(ownerFailure.promise, controller.signal)).resolves.toBe(false) - ownerFailure.reject(new Error('owner unavailable')) - await new Promise(resolve => setImmediate(resolve)) - }) - - it('keeps direct outcome separate from managed-range quiescence', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) - const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() - const stopped = Promise.withResolvers() - let ownerStopped = false - const signals: NodeJS.Signals[] = [] - const owner: BoundProcessOwner = { - signal(signal) { - if (ownerStopped) return - signals.push(signal) - if (signal === 'SIGKILL') { - ownerStopped = true - wrapper.kill('SIGKILL') - stopped.resolve(undefined) - } - }, - async waitForExit() { - if (ownerStopped) return - await stopped.promise - }, - } - const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: 4242, - direct: direct.promise, - owner, - }) - direct.resolve({ exitCode: 42, signal: null }) - await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) - - const bound = AbortSignal.timeout(10) - await expect(handle.waitForExit(bound)).resolves.toBe(false) - handle.terminate() - expect(signals).toEqual(['SIGTERM']) - await new Promise(resolve => setTimeout(resolve, 50)) - expect(signals).toEqual(['SIGTERM', 'SIGKILL']) - await expect(handle.waitForExit()).resolves.toBe(true) - handle.terminateForHostExit() - expect(signals).toEqual(['SIGTERM', 'SIGKILL']) - }) - - it('routes synchronous host-exit finalization directly to the owner', () => { - const wrapper = spawn(process.execPath, ['-e', 'process.exit(0)'], { stdio: ['ignore', 'pipe', 'pipe'] }) - const signal = vi.fn() - const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: 4242, - direct: Promise.resolve({ exitCode: 0, signal: null }), - owner: { signal, waitForExit: async () => {} }, - }) - handle.terminateForHostExit() - expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') - }) - - it.each([ - ['raw', 'pipe'], - ['collected', { maxBytes: 1024 }], - ] as const)('waits for %s output EOF after the direct outcome', async (_label, stdoutMode) => { - const stdout = new PassThrough() - const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() - const request = { - ...spec(1_000), - stdio: { stdin: 'ignore', stdout: stdoutMode, stderr: 'inherit' } as const, - } - const handle = bindManagedProcess(request, { - stdin: null, - stdout, - stderr: null, - pid: 4242, - direct: direct.promise, - owner: { signal: vi.fn(), waitForExit: async () => {} }, - }) - if (stdoutMode === 'pipe') stdout.resume() - let doneSettled = false - void handle.done.then(() => { doneSettled = true }) - direct.resolve({ exitCode: 23, signal: null }) - await new Promise(resolve => setImmediate(resolve)) - expect(doneSettled).toBe(false) - stdout.end() - await expect(Promise.race([ - handle.done, - new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 100)), - ])).resolves.toEqual({ exitCode: 23, signal: null }) - }) - - it('publishes direct outcome immediately when no collected stream needs draining', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'ignore', 'ignore'], - }) - const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() - const handle = bindManagedProcess({ - ...spec(1_000), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: wrapper.pid, - direct: direct.promise, - owner: { signal: vi.fn(), waitForExit: async () => {} }, - }) - try { - direct.resolve({ exitCode: 23, signal: null }) - const outcome = await Promise.race([ - handle.done, - new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 50)), - ]) - expect(outcome).toEqual({ exitCode: 23, signal: null }) - } finally { - wrapper.kill('SIGKILL') - } - }) - - it('retries termination after an expired escalation and range-observation rejection', async () => { - vi.useFakeTimers() - const failure = new Error('range observation failed') - const firstObservation = Promise.withResolvers() - const secondObservation = Promise.withResolvers() - const waitForExit = vi.fn() - .mockImplementationOnce(() => firstObservation.promise) - .mockImplementationOnce(() => secondObservation.promise) - const signal = vi.fn() - const handle = bindManagedProcess({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, { - stdin: null, - stdout: null, - stderr: null, - pid: 4242, - direct: new Promise(() => {}), - owner: { signal, waitForExit }, - }) - try { - handle.terminate() - const firstWait = handle.waitForExit() - expect(signal.mock.calls).toEqual([['SIGTERM']]) - await vi.advanceTimersByTimeAsync(30) - expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL']]) - firstObservation.reject(failure) - await expect(firstWait).rejects.toBe(failure) - - handle.terminate() - const secondWait = handle.waitForExit() - expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM']]) - await vi.advanceTimersByTimeAsync(30) - expect(signal.mock.calls).toEqual([['SIGTERM'], ['SIGKILL'], ['SIGTERM'], ['SIGKILL']]) - secondObservation.resolve(undefined) - await expect(secondWait).resolves.toBe(true) - expect(waitForExit).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('normalizes a non-Error direct rejection', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) - const rejection: unknown = 'runner failed' - const direct = Promise.resolve().then(() => { throw rejection }) - const signal = vi.fn() - const handle = bindManagedProcess(spec(), { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: wrapper.pid, - direct, - owner: { signal, waitForExit: async () => {} }, - }) - 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') - } - }) - - it('keeps abort ownership after direct exit until the managed range is empty', async () => { - const wrapper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { - stdio: ['ignore', 'pipe', 'pipe'], - }) - const direct = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() - const stopped = Promise.withResolvers() - const signal = vi.fn((requested: NodeJS.Signals) => { - if (requested !== 'SIGTERM') return - wrapper.kill('SIGTERM') - stopped.resolve(undefined) - }) - const controller = new AbortController() - const handle = bindManagedProcess({ ...spec(), signal: controller.signal }, { - stdin: wrapper.stdin, - stdout: wrapper.stdout, - stderr: wrapper.stderr, - pid: wrapper.pid, - direct: direct.promise, - owner: { - signal, - waitForExit: async () => { await stopped.promise }, - }, - }) - direct.resolve({ exitCode: 0, signal: null }) - await handle.done - controller.abort() - await expect(handle.waitForExit()).resolves.toBe(true) - expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM') - }) -}) diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 403a682178..b10c538e53 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -6,6 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import type { SubprocessSpawnSpec, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '../src/index.ts' import { launchLinuxScope, probeLinuxScope } from '../src/linux-scope.ts' +import { targetEnvironment } from '../src/runner-launch.ts' import { bindManagedProcess } from '../src/spawn.ts' const scratch = mkdtempSync(join(tmpdir(), 'dsh-native-containment-')) @@ -135,7 +136,8 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { it('terminates a setsid descendant and waits for the scope to become empty', async () => { const pidFile = join(scratch, `setsid-${Date.now()}.pid`) const command = `setsid sh -c 'echo $$ > "$1"; trap "" TERM; while :; do sleep 60; done' sh ${JSON.stringify(pidFile)} & wait` - const handle = bindManagedProcess(spec(['bash', '-c', command], 80), launchLinuxScope(spec(['bash', '-c', command], 80))) + const request = spec(['bash', '-c', command], 80) + const handle = bindManagedProcess(request, launchLinuxScope(request, targetEnvironment(request))) const descendant = await waitForPid(pidFile) handle.terminate() await handle.done @@ -145,14 +147,14 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => { const missing = spec([`missing-native-target-${Date.now()}`]) - const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing)) + const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing, targetEnvironment(missing))) await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) const deniedPath = join(scratch, `not-executable-${Date.now()}`) writeFileSync(deniedPath, '#!/bin/sh\nexit 0\n', { mode: 0o600 }) chmodSync(deniedPath, 0o600) const denied = spec([deniedPath]) - const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied)) + const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied, targetEnvironment(denied))) await expect(deniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) }) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 517ecbeaa0..9d83a3adf4 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -2,9 +2,9 @@ import { spawn, spawnSync } from 'node:child_process' import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { targetEnvironment } from '../src/runner-launch.ts' import { bindManagedProcess } from '../src/spawn.ts' import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' @@ -73,7 +73,7 @@ function directSpawnFailure(argv: string[], cwd = scratch): Promise { - it('keeps raw stdin writable before target pid publication', async () => { + it('keeps raw stdin writable while the runner starts the target', async () => { const output = join(scratch, `stdin-${Date.now()}.txt`) const script = ` const { writeFileSync } = require('node:fs') @@ -86,15 +86,15 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { ...spec([process.execPath, '-e', script]), stdio: { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' } as const, } - const handle = bindManagedProcess(request, launchWindowsJob(request)) + const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request))) if (handle.stdin === undefined) throw new Error('expected piped stdin') await new Promise((resolve, reject) => { handle.stdin?.once('error', reject) - handle.stdin?.end('before-publication', resolve) + handle.stdin?.end('immediate-stdin', resolve) }) await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) await expect(handle.waitForExit()).resolves.toBe(true) - expect(readFileSync(output, 'utf8')).toBe('before-publication') + expect(readFileSync(output, 'utf8')).toBe('immediate-stdin') }) it('reports direct exit before terminating its default-inheritance descendant', async () => { @@ -119,7 +119,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { cwd: targetCwd, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } as const, } - const handle = bindManagedProcess(request, launchWindowsJob(request)) + const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request))) if (handle.stdout === undefined) throw new Error('expected piped stdout') if (handle.stderr === undefined) throw new Error('expected piped stderr') handle.stdout.resume() @@ -154,62 +154,21 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { } }) - it('publishes target identity before reporting cwd restoration failure', async () => { - const preload = join(scratch, `fail-runner-cwd-restore-${Date.now()}.mjs`) - writeFileSync(preload, ` - const originalChdir = process.chdir.bind(process) - let calls = 0 - process.chdir = (path) => { - calls += 1 - if (calls === 2) { - const error = new Error('injected runner cwd restoration failure') - error.code = 'ENOENT' - error.syscall = 'chdir' - throw error - } - originalChdir(path) - } - `) - const previousNodeOptions = process.env.NODE_OPTIONS - process.env.NODE_OPTIONS = [previousNodeOptions, `--import=${pathToFileURL(preload).href}`] - .filter((value): value is string => value !== undefined && value.length > 0) - .join(' ') - try { - const command = process.env.ComSpec ?? process.env.COMSPEC - if (command === undefined) throw new Error('expected ComSpec for the Windows runner test') - const request = spec([command, '/d', '/s', '/c', 'exit 0']) - const launch = launchWindowsJob(request) - expect(launch.pid).toBeUndefined() - const failure = await launch.direct.catch((error: unknown) => error) - expect(launch.pid).toBeGreaterThan(0) - expect(failure).toMatchObject({ - message: 'injected runner cwd restoration failure', - code: 'ENOENT', - syscall: 'chdir', - }) - expect(failure).not.toHaveProperty('path') - await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') - } finally { - if (previousNodeOptions === undefined) Reflect.deleteProperty(process.env, 'NODE_OPTIONS') - else process.env.NODE_OPTIONS = previousNodeOptions - } - }) - 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)) + const relativeHandle = bindManagedProcess(relative, launchWindowsJob(relative, targetEnvironment(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)) + const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing, targetEnvironment(missing))) await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) await expect(missingHandle.waitForExit()).resolves.toBe(true) const accessDenied = spec([scratch]) - const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied)) + const accessDeniedHandle = bindManagedProcess(accessDenied, launchWindowsJob(accessDenied, targetEnvironment(accessDenied))) await expect(accessDeniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true) @@ -217,7 +176,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const cwdArgv = [process.execPath, '-e', 'process.exit(0)'] const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } - const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd)) + const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd, targetEnvironment(invalidCwd))) await expect(invalidCwdHandle.done).rejects.toMatchObject({ code: expectedCwd.code, syscall: expectedCwd.syscall, @@ -230,7 +189,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { writeFileSync(invalidExecutable, 'not a Windows executable\r\n') const directError = await directSpawnFailure([invalidExecutable]) const invalid = spec([invalidExecutable]) - const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid)) + const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid, targetEnvironment(invalid))) await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index b19af88b6b..d4c6dadcc2 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -1,42 +1,69 @@ -import { spawnSync } from 'node:child_process' +import { spawn } from 'node:child_process' +import type { Buffer } from 'node:buffer' import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -import { cleanupRunnerFiles, createRunnerFiles, readRunnerEventsAsync } from '../src/runner-protocol.ts' +import { + cleanupLinuxLaunchFiles, + createLinuxLaunchFiles, +} from '../src/runner-protocol.ts' +import { runnerEnvironment, SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts' -const builtEntry = fileURLToPath(new URL( - './lib/spawn-runner.js', - import.meta.resolve('@deepseek-ai/dsh-subprocess-local/package.json'), -)) -const required = process.env.DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER === '1' +const repoRoot = resolve(import.meta.dirname, '../../../..') +const sourceRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/src/bin.ts') +const builtRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/lib/runner.js') -describe.skipIf(!existsSync(builtEntry) && !required)('built subprocess runner entry', () => { - it('reports the direct target outcome through the built private entry', async () => { - if (!existsSync(builtEntry)) throw new Error(`required built subprocess runner is missing: ${builtEntry}`) - const files = createRunnerFiles({ - argv: [process.execPath, '-e', 'process.exit(11)'], - cwd: process.cwd(), - env: {}, +function targetEnv(): Record { + return { + ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)), + [SUBPROCESS_RUNNER_ENV]: 'target-collision-restored', + } +} + +async function execute(invocation: string[]): Promise<{ status: number | null; stdout: string; stderr: string }> { + const files = createLinuxLaunchFiles({ cwd: repoRoot, env: targetEnv() }) + try { + const child = spawn(invocation[0] as string, [ + ...invocation.slice(1), + '--', + process.execPath, + '--input-type=module', + '--eval', + `process.stdout.write(process.argv[0]+'|'+process.cwd()+'|'+process.env.${SUBPROCESS_RUNNER_ENV})`, + ], { + env: runnerEnvironment(files.requestPath), + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + const status = await new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('exit', resolveExit) + }) + return { status, stdout, stderr } + } finally { + cleanupLinuxLaunchFiles(files) + } +} + +describe('subprocess-local runner artifacts', () => { + it('executes the source entry through the provider-owned core', async () => { + const result = await execute([process.execPath, '--import', 'tsx/esm', sourceRunner]) + expect(result).toEqual({ + status: 0, + stdout: `${process.execPath}|${repoRoot}|target-collision-restored`, + stderr: '', + }) + }) + + it.skipIf(!existsSync(builtRunner))('executes the built ./runner subpath through the same core', async () => { + const result = await execute([process.execPath, builtRunner]) + expect(result).toEqual({ + status: 0, + stdout: `${process.execPath}|${repoRoot}|target-collision-restored`, + stderr: '', }) - try { - const result = spawnSync(process.execPath, [ - builtEntry, - '--mode', - 'node', - '--request', - files.requestPath, - '--events', - files.eventsPath, - ], { encoding: 'utf8', timeout: 10_000 }) - expect(result.error).toBeUndefined() - const events = await readRunnerEventsAsync(files.eventsPath) - expect(events).toHaveLength(2) - expect(events[0]?.type).toBe('started') - if (events[0]?.type !== 'started') throw new Error('expected started event') - expect(events[0].pid).toBeGreaterThan(0) - expect(events[1]).toEqual({ type: 'exit', exitCode: 11, signal: null }) - } finally { - cleanupRunnerFiles(files) - } }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index a65bcbed81..37c59ba112 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,1252 +1,666 @@ -import { spawn, spawnSync } from 'node:child_process' -import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + 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' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' import { - cleanupAfterRunner, - runnerDirectResult, - runnerFiles, + cleanupLinuxLaunchFiles, + consumeLinuxLaunchRequest, + createLinuxLaunchFiles, + deserializeRunnerError, + isWindowsTerminateRequest, + linuxLaunchFilesFromLocator, + parseWindowsRunnerResult, + parseWindowsStartRequest, + readLinuxStartupError, + serializeRunnerError, + writeLinuxStartupError, +} from '../src/runner-protocol.ts' +import { + consumeRunnerSelection, + parseRunnerTargetArgv, + runnerEnvironment, + runnerInvocationAvailable, runnerStdio, spawnRunnerInvocation, + SUBPROCESS_RUNNER_ENV, + targetEnvironment, + validateTerminalTarget, + WINDOWS_RUNNER_SELECTION, } from '../src/runner-launch.ts' -import { observeChildLifecycle } from '../src/managed-owner.ts' import { - appendRunnerEvent, - cleanupRunnerFiles, - consumeRunnerRequest, - createRunnerFiles, - deserializeSpawnError, - readRunnerEventsAsync, - serializeSpawnError, -} from '../src/runner-protocol.ts' -import { reportSpawnRunnerFailure, runSpawnRunner } from '../src/spawn-runner.ts' + reportSpawnRunnerFailure, + runSpawnRunner, +} from '../src/spawn-runner.ts' +import type { SpawnRunnerInternals } from '../src/spawn-runner.ts' -const sourceInvocation = [ - process.execPath, - '--import', - 'tsx/esm', - fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/src/bin.ts')), -] +const scratch: string[] = [] -function spec(overrides: Partial = {}): SubprocessSpawnSpec { +afterEach(() => { + for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +function track(files: T): T { + scratch.push(files.directory) + return files +} + +class FakeRunnerHost extends EventEmitter { + env: NodeJS.ProcessEnv = { [SUBPROCESS_RUNNER_ENV]: 'stale', SAFE: 'bootstrap' } + exitCode: number | undefined + connected = true + directory = process.cwd() + sent: unknown[] = [] + sendFailure: Error | undefined + sendThrown: unknown + + cwd(): string { return this.directory } + chdir(path: string): void { this.directory = path } + disconnect(): void { + if (!this.connected) return + this.connected = false + this.emit('disconnect') + } + send(message: unknown, callback?: (error: Error | null) => void): boolean { + if (this.sendThrown !== undefined) throw this.sendThrown + this.sent.push(message) + queueMicrotask(() => { callback?.(this.sendFailure ?? null) }) + return true + } +} + +function hostArgument(host: FakeRunnerHost): Parameters[2] { + return host as unknown as Parameters[2] +} + +function internals(overrides: Partial = {}): SpawnRunnerInternals { return { - argv: [process.execPath, '-e', ''], - cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, - graceMs: 100, + execve: vi.fn(() => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }), + loadWin32ProcessBindings: vi.fn(() => ({} as Win32ProcessBindings)), + spawnCurrentTokenJobProcess: vi.fn(() => ({ + pid: 123, + process: 10n as NativePtr, + job: 20n as NativePtr, + })), + closeCurrentProcessStandardHandles: vi.fn(), + pollProcessExit: vi.fn(() => 0), + isJobEmpty: vi.fn(() => true), + terminateJob: vi.fn(), + closeHandleChecked: vi.fn(), ...overrides, } } -function fakeChild(pid: number | undefined): ChildProcess { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid, exitCode: null, signalCode: null }) - return child +async function runWindows( + host: FakeRunnerHost, + native: SpawnRunnerInternals, + start: unknown = { type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' } }, +): Promise { + const running = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe', 'literal arg'], + hostArgument(host), + native, + ) + host.emit('message', start) + await running } -class FakeRunnerHost extends EventEmitter { - env: NodeJS.ProcessEnv = {} - exitCode: number | undefined - connected = false - directory = process.cwd() - readonly disconnect = vi.fn(() => { this.connected = false }) +describe('closed runner protocol', () => { + it('creates, consumes, reports through, and cleans one private Linux exchange', () => { + const files = track(createLinuxLaunchFiles({ cwd: '/target', env: { A: '1' } })) + expect(statSync(files.directory).mode & 0o777).toBe(0o700) + expect(statSync(files.requestPath).mode & 0o777).toBe(0o600) + expect(linuxLaunchFilesFromLocator(files.requestPath)).toEqual(files) + expect(consumeLinuxLaunchRequest(files.requestPath)).toEqual({ cwd: '/target', env: { A: '1' } }) + expect(existsSync(files.requestPath)).toBe(false) - cwd(): string { return this.directory } - chdir(directory: string): void { this.directory = directory } -} - -function asRunnerHost(host: FakeRunnerHost): Parameters[1] { - return host as unknown as Parameters[1] -} - -type RunnerInternals = NonNullable[2]> - -const fakeWin32Api = {} as Win32ProcessBindings -const fakeProcessHandle = 60n as NativePtr -const fakeJobHandle = 50n as NativePtr - -function fakeRunnerInternals(overrides: Partial = {}): RunnerInternals { - let nextPipeHandle = 70n - return { - spawn, - loadWin32ProcessBindings: vi.fn(() => fakeWin32Api), - openNamedPipeForStdio: vi.fn(() => nextPipeHandle++), - spawnCurrentTokenJobProcess: vi.fn(() => ({ - pid: 1234, - process: fakeProcessHandle, - job: fakeJobHandle, - })), - pollProcessExit: vi.fn(() => 0), - isJobEmpty: vi.fn(() => true), - terminateJob: vi.fn(), - waitForProcessExit: vi.fn(() => 0), - closeHandleChecked: vi.fn(), - ...overrides, - } as RunnerInternals -} - -function win32RunnerArgs( - requestPath: string, - eventsPath: string, - pipes: string[] = [], -): string[] { - return [ - '--mode', 'win32', - '--request', requestPath, - '--events', eventsPath, - ...pipes, - ] -} - -function runRunner(invocation: string[], requestPath: string, eventsPath: string) { - const [command, ...prefix] = invocation - return spawnSync(command as string, [ - ...prefix, - '--mode', - 'node', - '--request', - requestPath, - '--events', - eventsPath, - ], { encoding: 'utf8', timeout: 10_000 }) -} - -describe('spawn runner transport', () => { - it('selects the source runner without publishing a runner package face', () => { - expect(spawnRunnerInvocation()).toEqual(sourceInvocation) - const manifest = JSON.parse(readFileSync( - fileURLToPath(new URL('../package.json', import.meta.url)), - 'utf8', - )) as { exports: Record } - expect(manifest.exports).not.toHaveProperty('./spawn-runner') - expect(manifest.exports['./package.json']).toBe('./package.json') - }) - - it('observes runner events without SharedArrayBuffer', async () => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'SharedArrayBuffer') - Object.defineProperty(globalThis, 'SharedArrayBuffer', { configurable: true, value: undefined }) - vi.resetModules() - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - const isolated = await import('../src/runner-launch.ts') - const result = isolated.runnerDirectResult(fakeChild(123), files, new Promise(() => {})) - appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) - await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) - expect(result.pid).toBe(456) - } finally { - cleanupRunnerFiles(files) - if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'SharedArrayBuffer') - else Object.defineProperty(globalThis, 'SharedArrayBuffer', descriptor) - vi.resetModules() - } - }) - - it('re-enters a packaged executable through its private runner dispatch', () => { - const packagedProcess = process as NodeJS.Process & { pkg?: unknown } - const original = Object.getOwnPropertyDescriptor(packagedProcess, 'pkg') - Object.defineProperty(packagedProcess, 'pkg', { configurable: true, value: {} }) - try { - expect(spawnRunnerInvocation()).toEqual([process.execPath, '--dsh-internal-subprocess-runner']) - } finally { - if (original === undefined) Reflect.deleteProperty(packagedProcess, 'pkg') - else Object.defineProperty(packagedProcess, 'pkg', original) - } - }) - - it('supports the node runner capability probe', () => { - const result = spawnSync(sourceInvocation[0] as string, [ - ...sourceInvocation.slice(1), - '--mode', - 'probe-node', - ], { encoding: 'utf8', timeout: 10_000 }) - expect(result.error).toBeUndefined() - expect(result.status).toBe(0) - }) - - it('runs the Node target lifecycle in-process through the coverable runner logic', async () => { - const files = createRunnerFiles({ - argv: [process.execPath, '-e', 'process.exit(12)'], - cwd: process.cwd(), - env: {}, + const failure = Object.assign(new Error('spawn missing'), { + name: 'SpawnError', code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['x'], }) - const host = new FakeRunnerHost() - try { - await runSpawnRunner([ - '--mode', 'node', - '--request', files.requestPath, - '--events', files.eventsPath, - ], asRunnerHost(host)) - expect(host.exitCode).toBe(12) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - expect.objectContaining({ type: 'started' }), - { type: 'exit', exitCode: 12, signal: null }, - ]) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('reports an in-process Node target spawn failure', async () => { - const files = createRunnerFiles({ - argv: [`missing-dsh-runner-target-${String(process.pid)}-${String(Date.now())}`], - cwd: process.cwd(), - env: {}, + writeLinuxStartupError(files, { type: 'spawn-error', error: serializeRunnerError(failure) }) + expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600) + const result = readLinuxStartupError(files.startupErrorPath) + expect(result).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } }) + expect(deserializeRunnerError(result!.error)).toMatchObject({ + name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', errno: -2, }) - const host = new FakeRunnerHost() - try { - await runSpawnRunner([ - '--mode', 'node', - '--request', files.requestPath, - '--events', files.eventsPath, - ], asRunnerHost(host)) - expect(host.exitCode).toBe(127) - const [event] = await readRunnerEventsAsync(files.eventsPath) - expect(event?.type).toBe('spawn-error') - if (event?.type !== 'spawn-error') throw new Error('expected spawn error') - expect(event.error.code).toBe('ENOENT') - } finally { - cleanupRunnerFiles(files) - } - }) - - it('contains a post-start Node runner error and ignores scope signals', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} }) - const host = new FakeRunnerHost() - const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess - const injectedSpawn = vi.fn(() => { - queueMicrotask(() => { - host.emit('SIGTERM') - child.emit('spawn') - child.emit('error', new Error('post-start node failure')) - child.emit('exit', 0, null) - }) - return child - }) as unknown as typeof spawn - try { - await runSpawnRunner([ - '--mode', 'node', - '--request', files.requestPath, - '--events', files.eventsPath, - ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) - expect(injectedSpawn).toHaveBeenCalledTimes(1) - expect(injectedSpawn).toHaveBeenCalledWith('node', [], { - cwd: process.cwd(), - env: {}, - stdio: 'inherit', - detached: true, - }) - expect(host.exitCode).toBe(127) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 4321 }, - { type: 'runner-error', error: { name: 'Error', message: 'post-start node failure' } }, - ]) - expect(host.listenerCount('SIGTERM')).toBe(0) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('maps a signal-only Node exit to the runner failure exit code', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: process.cwd(), env: {} }) - const host = new FakeRunnerHost() - const child = Object.assign(new EventEmitter(), { pid: 4321 }) as ChildProcess - const injectedSpawn = vi.fn(() => { - queueMicrotask(() => { - child.emit('spawn') - child.emit('exit', null, 'SIGTERM') - }) - return child - }) as unknown as typeof spawn - try { - await runSpawnRunner([ - '--mode', 'node', - '--request', files.requestPath, - '--events', files.eventsPath, - ], asRunnerHost(host), fakeRunnerInternals({ spawn: injectedSpawn })) - expect(host.exitCode).toBe(1) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 4321 }, - { type: 'exit', exitCode: null, signal: 'SIGTERM' }, - ]) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('runs the in-process capability probes and always closes the probe Job', async () => { - const nodeHost = new FakeRunnerHost() - await expect(runSpawnRunner( - ['--mode', 'probe-node'], - asRunnerHost(nodeHost), - fakeRunnerInternals(), - )).resolves.toBeUndefined() - - const host = new FakeRunnerHost() - host.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe' - host.directory = 'C:\\runner' - const internals = fakeRunnerInternals() - await expect(runSpawnRunner( - ['--mode', 'probe-win32'], - asRunnerHost(host), - internals, - )).resolves.toBeUndefined() - expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(fakeWin32Api, { - command: 'C:\\Windows\\System32\\cmd.exe', - args: ['/d', '/s', '/c', 'exit 0'], - cwd: 'C:\\runner', - }) - expect(internals.waitForProcessExit).toHaveBeenCalledWith(fakeWin32Api, fakeProcessHandle) - expect(internals.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeJobHandle, - 'subprocess Windows Job probe', - ) - - const legacyHost = new FakeRunnerHost() - legacyHost.env.COMSPEC = 'legacy-cmd.exe' - const failing = fakeRunnerInternals({ waitForProcessExit: vi.fn(() => 9) }) - await expect(runSpawnRunner( - ['--mode', 'probe-win32'], - asRunnerHost(legacyHost), - failing, - )).rejects.toThrow('probe exited with code 9') - expect(failing.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeJobHandle, - 'subprocess Windows Job probe', - ) - - await expect(runSpawnRunner( - ['--mode', 'probe-win32'], - asRunnerHost(new FakeRunnerHost()), - fakeRunnerInternals(), - )).rejects.toThrow('without ComSpec') - }) - - it('runs the Win32 target, forwards every pipe, and waits for an empty Job', async () => { - vi.useFakeTimers() - const files = createRunnerFiles({ - argv: ['tool.exe', 'literal $HOME'], - cwd: 'C:\\target', - env: { ONLY: 'kept' }, - }) - const host = new FakeRunnerHost() - host.env.STALE = 'removed' - host.directory = 'C:\\runner' - host.connected = true - const pollProcessExit = vi.fn() - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(42) - const isJobEmpty = vi.fn() - .mockReturnValueOnce(false) - .mockReturnValueOnce(true) - const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty }) - try { - const running = runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ - '--stdin-pipe', '\\\\.\\pipe\\stdin', - '--stdout-pipe', '\\\\.\\pipe\\stdout', - '--stderr-pipe', '\\\\.\\pipe\\stderr', - ]), asRunnerHost(host), internals) - await vi.advanceTimersByTimeAsync(30) - await running - - expect(host.env).toEqual({ ONLY: 'kept' }) - expect(host.directory).toBe('C:\\runner') - expect(host.disconnect).toHaveBeenCalledOnce() - expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( - 1, - fakeWin32Api, - '\\\\.\\pipe\\stdin', - 'read', - ) - expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( - 2, - fakeWin32Api, - '\\\\.\\pipe\\stdout', - 'write', - ) - expect(internals.openNamedPipeForStdio).toHaveBeenNthCalledWith( - 3, - fakeWin32Api, - '\\\\.\\pipe\\stderr', - 'write', - ) - expect(internals.spawnCurrentTokenJobProcess).toHaveBeenCalledWith( - fakeWin32Api, - { command: 'tool.exe', args: ['literal $HOME'], cwd: 'C:\\target' }, - { - stdin: 70n, - stdout: 71n, - stderr: 72n, - }, - ) - expect(pollProcessExit).toHaveBeenCalledTimes(2) - expect(isJobEmpty).toHaveBeenCalledTimes(2) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { type: 'exit', exitCode: 42, signal: null }, - ]) - expect(internals.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeProcessHandle, - 'ordinary direct process', - ) - expect(internals.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeJobHandle, - 'ordinary process Job', - ) - } finally { - vi.useRealTimers() - cleanupRunnerFiles(files) - } - }) - - it('accepts only the Win32 terminate IPC message and coalesces disconnect', async () => { - vi.useFakeTimers() - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - const internals = fakeRunnerInternals() - try { - const running = runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - ) - host.emit('message', null) - host.emit('message', 'terminate') - host.emit('message', { type: 'other' }) - host.emit('message', { type: 'terminate' }) - host.emit('message', { type: 'terminate' }) - host.emit('disconnect') - await vi.advanceTimersByTimeAsync(10) - await running - - expect(internals.terminateJob).toHaveBeenCalledOnce() - expect(internals.terminateJob).toHaveBeenCalledWith(fakeWin32Api, fakeJobHandle, 1) - expect(host.disconnect).not.toHaveBeenCalled() - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { type: 'exit', exitCode: 0, signal: null }, - ]) - } finally { - vi.useRealTimers() - cleanupRunnerFiles(files) - } - }) - - it('reports a non-Error Win32 termination failure and closes both live handles', async () => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - host.connected = true - const terminateJob = vi.fn(() => { throw 'raw termination failure' }) - const internals = fakeRunnerInternals({ terminateJob }) - try { - const running = runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - ) - host.emit('disconnect') - await running - - expect(host.exitCode).toBe(127) - expect(host.disconnect).toHaveBeenCalledOnce() - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { type: 'runner-error', error: { name: 'Error', message: 'raw termination failure' } }, - ]) - expect(internals.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeProcessHandle, - 'ordinary direct process cleanup', - ) - expect(internals.closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeJobHandle, - 'ordinary process Job cleanup', - ) - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - [2, 'ENOENT'], - [3, 'ENOENT'], - [267, 'ENOENT'], - [5, 'EACCES'], - [193, 'EFTYPE'], - [999, 'UNKNOWN'], - ] as const)('maps Win32 CreateProcess error %i to %s', async (win32Code, code) => { - const files = createRunnerFiles({ - argv: ['missing.exe', 'literal argument'], - cwd: 'C:\\target', - env: {}, - }) - const host = new FakeRunnerHost() - const internals = fakeRunnerInternals({ - spawnCurrentTokenJobProcess: vi.fn(() => { - throw new Win32Error('CreateProcessW', win32Code) - }), - }) - try { - await runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - ) - expect(host.exitCode).toBeUndefined() - const [event] = await readRunnerEventsAsync(files.eventsPath) - expect(event?.type).toBe('spawn-error') - if (event?.type !== 'spawn-error') throw new Error('expected spawn error') - expect(event.error).toMatchObject({ - code, - syscall: 'spawn missing.exe', - path: 'missing.exe', - spawnargs: ['literal argument'], - }) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('preserves a Win32 target spawn failure when restoring the runner cwd also fails', async () => { - const files = createRunnerFiles({ - argv: ['missing.exe', 'literal argument'], - cwd: 'C:\\target', - env: {}, - }) - const host = new FakeRunnerHost() - host.directory = 'C:\\runner' - const chdir = vi.fn((directory: string) => { - if (directory === 'C:\\runner') throw new Error('cwd restore failed') - host.directory = directory - }) - host.chdir = chdir - const internals = fakeRunnerInternals({ - spawnCurrentTokenJobProcess: vi.fn(() => { - throw new Win32Error('CreateProcessW', 2) - }), - }) - try { - await runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - ) - expect(chdir).toHaveBeenCalledTimes(2) - expect(host.exitCode).toBeUndefined() - const [event] = await readRunnerEventsAsync(files.eventsPath) - expect(event?.type).toBe('spawn-error') - if (event?.type !== 'spawn-error') throw new Error('expected spawn error') - expect(event.error).toMatchObject({ - code: 'ENOENT', - syscall: 'spawn missing.exe', - path: 'missing.exe', - spawnargs: ['literal argument'], - }) - expect(event.error.message).not.toContain('cwd restore failed') - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - [undefined, false], - ['ENOENT', true], - ] as const)('maps a target chdir failure with code %s', async (code, hasSpawnShape) => { - const files = createRunnerFiles({ argv: ['tool.exe', 'arg'], cwd: 'C:\\missing', env: {} }) - const host = new FakeRunnerHost() - const error = Object.assign(new Error('target cwd failed'), { - syscall: 'chdir', - ...code === undefined ? {} : { code }, - }) - host.chdir = vi.fn(() => { throw error }) - try { - await runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - fakeRunnerInternals(), - ) - expect(host.exitCode).toBeUndefined() - const [event] = await readRunnerEventsAsync(files.eventsPath) - expect(event?.type).toBe('spawn-error') - if (event?.type !== 'spawn-error') throw new Error('expected spawn error') - expect(typeof event.error.message).toBe('string') - expect('path' in event.error).toBe(hasSpawnShape) - if (hasSpawnShape) { - expect(event.error).toMatchObject({ - code: 'ENOENT', - syscall: 'spawn tool.exe', - path: 'tool.exe', - spawnargs: ['arg'], - }) - } else { - expect(event.error).toMatchObject({ message: 'target cwd failed', syscall: 'chdir' }) - } - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - ['a non-CreateProcess Win32 error', new Win32Error('CreateFileW', 5), 'Win32Error'], - ['a non-Error setup failure', 'raw pipe setup failure', 'Error'], - ])('reports %s as runner infrastructure failure', async (_label, failure, name) => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - const internals = fakeRunnerInternals({ - openNamedPipeForStdio: vi.fn(() => { throw failure }), - }) - try { - await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ - '--stdin-pipe', '\\\\.\\pipe\\stdin', - ]), asRunnerHost(host), internals) - expect(host.exitCode).toBe(127) - const [event] = await readRunnerEventsAsync(files.eventsPath) - expect(event?.type).toBe('runner-error') - if (event?.type !== 'runner-error') throw new Error('expected runner error') - expect(event.error.name).toBe(name) - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - ['an Error', new Error('stdio close failed')], - ['a non-Error value', 'raw stdio close failure'], - ])('reports %s from the initial stdio close and retries cleanup', async (_label, failure) => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - let failedOnce = false - const closeHandleChecked = vi.fn((_api, _handle, label: string) => { - if (!failedOnce && label.includes('pipe')) { - failedOnce = true - throw failure - } - }) - const internals = fakeRunnerInternals({ closeHandleChecked }) - try { - await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ - '--stdin-pipe', '\\\\.\\pipe\\stdin', - ]), asRunnerHost(new FakeRunnerHost()), internals) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { - type: 'runner-error', - error: { name: 'Error', message: failure instanceof Error ? failure.message : failure }, - }, - ]) - expect(closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - 70n, - 'ordinary target stdin pipe', - ) - expect(closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - 70n, - 'ordinary target stdin pipe', - ) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('preserves the first stdio close failure while retaining every failed handle', async () => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - let remainingFailures = 2 - const closeHandleChecked = vi.fn((_api, _handle, label: string) => { - if (remainingFailures > 0 && label.includes('pipe')) { - remainingFailures -= 1 - throw remainingFailures === 1 ? new Error('first close failure') : 'second close failure' - } - }) - const internals = fakeRunnerInternals({ closeHandleChecked }) - try { - await runSpawnRunner(win32RunnerArgs(files.requestPath, files.eventsPath, [ - '--stdin-pipe', '\\\\.\\pipe\\stdin', - '--stdout-pipe', '\\\\.\\pipe\\stdout', - ]), asRunnerHost(new FakeRunnerHost()), internals) - expect(await readRunnerEventsAsync(files.eventsPath)).toContainEqual({ - type: 'runner-error', - error: { name: 'Error', message: 'first close failure' }, - }) - expect(closeHandleChecked).toHaveBeenCalledTimes(6) - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - ['poll', 'poll failed'], - ['direct close', 'direct close failed'], - ['Job query', 'Job query failed'], - ['Job close', 'Job close failed'], - ] as const)('reports a Win32 %s failure and cleans remaining handles', async (stage, message) => { - vi.useFakeTimers() - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const pollProcessExit = vi.fn(() => { - if (stage === 'poll') throw new Error(message) - return 0 - }) - const isJobEmpty = vi.fn(() => { - if (stage === 'Job query') throw new Error(message) - return true - }) - const closeHandleChecked = vi.fn((_api, _handle, label: string) => { - if (stage === 'direct close' && label === 'ordinary direct process') { - throw new Error(message) - } - if (stage === 'Job close' && label === 'ordinary process Job') { - throw new Error(message) - } - if (label.endsWith('cleanup')) throw new Error('ignored cleanup failure') - }) - const internals = fakeRunnerInternals({ pollProcessExit, isJobEmpty, closeHandleChecked }) - try { - const running = runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(new FakeRunnerHost()), - internals, - ) - await vi.advanceTimersByTimeAsync(10) - await running - - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - ...stage === 'poll' ? [] : [{ type: 'exit' as const, exitCode: 0, signal: null }], - { type: 'runner-error', error: { name: 'Error', message } }, - ]) - expect(closeHandleChecked).toHaveBeenCalledWith( - fakeWin32Api, - fakeJobHandle, - expect.stringContaining('Job'), - ) - } finally { - vi.useRealTimers() - cleanupRunnerFiles(files) - } - }) - - it('preserves the first failure when termination settles reentrantly during polling', async () => { - vi.useFakeTimers() - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - const terminateJob = vi.fn(() => { throw new Error('reentrant termination failed') }) - const pollProcessExit = vi.fn(() => { - host.emit('disconnect') - return 0 - }) - const internals = fakeRunnerInternals({ terminateJob, pollProcessExit }) - try { - const running = runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - ) - await vi.advanceTimersByTimeAsync(10) - await running - - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { type: 'exit', exitCode: 0, signal: null }, - { - type: 'runner-error', - error: { name: 'Error', message: 'reentrant termination failed' }, - }, - ]) - } finally { - vi.useRealTimers() - cleanupRunnerFiles(files) - } - }) - - it('reports failure while restoring cwd after a successful Win32 spawn', async () => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - host.directory = 'C:\\runner' - const chdir = vi.fn((directory: string) => { - if (directory === 'C:\\runner') throw new Error('cwd restore failed') - host.directory = directory - }) - host.chdir = chdir - try { - await runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - fakeRunnerInternals(), - ) - expect(chdir).toHaveBeenCalledTimes(2) - expect(host.exitCode).toBe(127) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'started', pid: 1234 }, - { type: 'runner-error', error: { name: 'Error', message: 'cwd restore failed' } }, - ]) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('disconnects after an uncaught Win32 binding setup failure', async () => { - const files = createRunnerFiles({ argv: ['tool.exe'], cwd: 'C:\\target', env: {} }) - const host = new FakeRunnerHost() - host.connected = true - const internals = fakeRunnerInternals({ - loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding setup failed') }), - }) - try { - await expect(runSpawnRunner( - win32RunnerArgs(files.requestPath, files.eventsPath), - asRunnerHost(host), - internals, - )).rejects.toThrow('binding setup failed') - expect(host.disconnect).toHaveBeenCalledOnce() - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([]) - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - [['--mode'], 'missing value'], - [['--unknown', 'value'], 'unknown argument'], - [['--mode', 'unknown'], 'unknown mode'], - [['--mode', 'node'], 'requires request and event paths'], - ] as const)('rejects invalid runner arguments: %s', async (argv, message) => { - await expect(runSpawnRunner([...argv], asRunnerHost(new FakeRunnerHost()))).rejects.toThrow(message) - }) - - it('reports only failures whose arguments identify an event transport', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - reportSpawnRunnerFailure([ - '--mode', 'node', - '--request', files.requestPath, - '--events', files.eventsPath, - ], new Error('runner main failed')) - reportSpawnRunnerFailure(['--mode', 'probe-node'], new Error('ignored probe failure')) - reportSpawnRunnerFailure(['--mode'], new Error('unparseable failure')) - expect(await readRunnerEventsAsync(files.eventsPath)).toEqual([ - { type: 'runner-error', error: { name: 'Error', message: 'runner main failed' } }, - ]) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('maps every target stdio disposition', () => { - expect(runnerStdio(spec())).toEqual(['ignore', 'pipe', 'pipe']) - expect(runnerStdio(spec({ - stdio: { stdin: { data: 'input' }, stdout: 'inherit', stderr: 'inherit' }, - }))).toEqual(['pipe', 'inherit', 'inherit']) - }) - - it('materializes and consumes the exact runner request once', () => { - const removed = `DSH_RUNNER_REMOVED_${process.pid}` - const files = runnerFiles(spec({ - argv: [process.execPath, 'literal $HOME'], - env: { RUNNER_VALUE: 'explicit', [removed]: undefined }, - })) - try { - const request = consumeRunnerRequest(files.requestPath) - expect(request.argv).toEqual([process.execPath, 'literal $HOME']) - expect(request.cwd).toBe(process.cwd()) - expect(request.env.RUNNER_VALUE).toBe('explicit') - expect(request.env).not.toHaveProperty(removed) - expect(existsSync(files.requestPath)).toBe(false) - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - ['non-object request', null, 'no executable'], - ['non-array argv', { argv: 'node', cwd: '.', env: {} }, 'no executable'], - ['empty argv', { argv: [], cwd: '.', env: {} }, 'no executable'], - ['non-string argv', { argv: [1], cwd: '.', env: {} }, 'no executable'], - ['non-string cwd', { argv: ['node'], cwd: 1, env: {} }, 'invalid cwd or environment'], - ['non-record env', { argv: ['node'], cwd: '.', env: [] }, 'invalid cwd or environment'], - ['non-string env value', { argv: ['node'], cwd: '.', env: { VALUE: 1 } }, 'invalid cwd or environment'], - ])('rejects an invalid %s', (_label, request, message) => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - writeFileSync(files.requestPath, JSON.stringify(request)) - expect(() => consumeRunnerRequest(files.requestPath)).toThrow(message) - } finally { - cleanupRunnerFiles(files) - } - }) - - 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('contains an unexpected owned-path cleanup failure', () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - rmSync(files.requestPath, { force: true }) - mkdirSync(files.requestPath) - try { - expect(() => { cleanupRunnerFiles(files) }).not.toThrow() - expect(existsSync(files.directory)).toBe(true) - } finally { - rmSync(files.directory, { recursive: true, force: true }) - } - }) - - it('reads only complete known event records and propagates file errors', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([]) - await expect(readRunnerEventsAsync(join(files.directory, 'missing.ndjson'))).resolves.toEqual([]) - appendRunnerEvent(files.eventsPath, { type: 'started', pid: 123 }) - appendRunnerEvent(files.eventsPath, { - type: 'runner-error', - error: { name: 'Error', message: 'runner failed' }, - }) - appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: null, signal: 'SIGTERM' }) - appendRunnerEvent(files.eventsPath, { - type: 'spawn-error', - error: { - name: 'Error', - message: 'spawn failed', - code: 'ENOENT', - errno: -2, - syscall: 'spawn missing', - path: 'missing', - spawnargs: ['argument'], - }, - }) - await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([ - { type: 'started', pid: 123 }, - { type: 'runner-error', error: { name: 'Error', message: 'runner failed' } }, - { type: 'exit', exitCode: null, signal: 'SIGTERM' }, - { - type: 'spawn-error', - error: { - name: 'Error', - message: 'spawn failed', - code: 'ENOENT', - errno: -2, - syscall: 'spawn missing', - path: 'missing', - spawnargs: ['argument'], - }, - }, - ]) - writeFileSync(files.eventsPath, '{"type":"started","pid":123}\n{"type":"exit"') - await expect(readRunnerEventsAsync(files.eventsPath)).resolves.toEqual([{ type: 'started', pid: 123 }]) - for (const event of [null, []]) { - writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) - await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event') - } - writeFileSync(files.eventsPath, '{"type":"unknown"}\n') - await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted unknown event') - await expect(readRunnerEventsAsync(files.directory)).rejects.toThrow() - } finally { - cleanupRunnerFiles(files) - } - }) - - it.each([ - ['started without a pid', { type: 'started' }], - ['started with a non-number pid', { type: 'started', pid: '1' }], - ['started with a fractional pid', { type: 'started', pid: 1.5 }], - ['started with a non-positive pid', { type: 'started', pid: 0 }], - ['exit with a missing code', { type: 'exit', signal: null }], - ['exit with a non-number code', { type: 'exit', exitCode: '0', signal: null }], - ['exit with a fractional code', { type: 'exit', exitCode: 1.5, signal: null }], - ['exit with a negative code', { type: 'exit', exitCode: -1, signal: null }], - ['exit with a non-string signal', { type: 'exit', exitCode: 0, signal: 9 }], - ['exit with an unknown signal', { type: 'exit', exitCode: 0, signal: 'NOT_A_SIGNAL' }], - ['spawn error without an object', { type: 'spawn-error', error: null }], - ['spawn error without a name', { type: 'spawn-error', error: { message: 'failed' } }], - ['spawn error without a message', { type: 'spawn-error', error: { name: 'Error' } }], - ['spawn error with a numeric code', { type: 'spawn-error', error: { name: 'Error', message: 'failed', code: 1 } }], - ['spawn error with a string errno', { type: 'spawn-error', error: { name: 'Error', message: 'failed', errno: '1' } }], - ['spawn error with a numeric syscall', { type: 'spawn-error', error: { name: 'Error', message: 'failed', syscall: 1 } }], - ['spawn error with a numeric path', { type: 'spawn-error', error: { name: 'Error', message: 'failed', path: 1 } }], - ['spawn error with non-array args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: 'arg' } }], - ['spawn error with non-string args', { type: 'spawn-error', error: { name: 'Error', message: 'failed', spawnargs: [1] } }], - ])('rejects an invalid event payload: %s', async (_label, event) => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - writeFileSync(files.eventsPath, `${JSON.stringify(event)}\n`) - await expect(readRunnerEventsAsync(files.eventsPath)).rejects.toThrow('emitted invalid event') - } finally { - cleanupRunnerFiles(files) - } - }) - - it('creates private request files and preserves Node-shaped error fields', () => { - const files = createRunnerFiles({ argv: [process.execPath], cwd: process.cwd(), env: {} }) - try { - if (process.platform !== 'win32') expect(statSync(files.requestPath).mode & 0o777).toBe(0o600) - const source = Object.assign(new Error('spawn missing ENOENT'), { - code: 'ENOENT', - errno: -2, - syscall: 'spawn missing', - path: 'missing', - spawnargs: ['literal $VALUE'], - }) - const restored = deserializeSpawnError(serializeSpawnError(source)) as NodeJS.ErrnoException & { - path?: string - spawnargs?: string[] - } - expect(restored).toMatchObject({ - message: 'spawn missing ENOENT', - code: 'ENOENT', - errno: -2, - syscall: 'spawn missing', - path: 'missing', - spawnargs: ['literal $VALUE'], - }) - const minimal = serializeSpawnError('plain failure') - expect(minimal).toEqual({ name: 'Error', message: 'plain failure' }) - expect(deserializeSpawnError(minimal)).toMatchObject({ name: 'Error', message: 'plain failure' }) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('maps runner failures and missing direct results', async () => { - const runnerFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(runnerFailure.eventsPath, { - type: 'runner-error', - error: { name: 'Error', message: 'runner setup failed', code: 'EIO' }, - }) - const result = runnerDirectResult(fakeChild(123), runnerFailure, new Promise(() => {})) - expect(result.pid).toBeUndefined() - await expect(result.direct).rejects.toMatchObject({ message: 'runner setup failed', code: 'EIO' }) - } finally { - cleanupRunnerFiles(runnerFailure) - } - - const afterStartFailure = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(afterStartFailure.eventsPath, { type: 'started', pid: 456 }) - const result = runnerDirectResult(fakeChild(123), afterStartFailure, new Promise(() => {})) - const directFailure = result.direct.catch((error: unknown) => error) - appendRunnerEvent(afterStartFailure.eventsPath, { - type: 'runner-error', - error: { name: 'Error', message: 'post-start runner failed', code: 'EIO' }, - }) - await vi.waitFor(() => { expect(result.pid).toBe(456) }) - await expect(directFailure).resolves.toMatchObject({ message: 'post-start runner failed', code: 'EIO' }) - } finally { - cleanupRunnerFiles(afterStartFailure) - } - - const missing = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(missing.eventsPath, { type: 'started', pid: 456 }) - const result = runnerDirectResult(fakeChild(123), missing, Promise.resolve()) - await vi.waitFor(() => { expect(result.pid).toBe(456) }) - await expect(result.direct).rejects.toThrow('exited without a direct-command result') - } finally { - cleanupRunnerFiles(missing) - } - - }) - - it('publishes terminal events already present when asynchronous observation starts', async () => { - const failed = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(failed.eventsPath, { - type: 'spawn-error', - error: { name: 'Error', message: 'target missing', code: 'ENOENT' }, - }) - const result = runnerDirectResult(fakeChild(123), failed, new Promise(() => {})) - await expect(result.direct).rejects.toMatchObject({ message: 'target missing', code: 'ENOENT' }) - } finally { - cleanupRunnerFiles(failed) - } - - const exited = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(exited.eventsPath, { type: 'started', pid: 456 }) - appendRunnerEvent(exited.eventsPath, { type: 'exit', exitCode: 23, signal: null }) - const result = runnerDirectResult(fakeChild(123), exited, new Promise(() => {})) - await expect(result.direct).resolves.toEqual({ exitCode: 23, signal: null }) - expect(result.pid).toBe(456) - } finally { - cleanupRunnerFiles(exited) - } - }) - - it('requires an event snapshot started after wrapper exit before reporting a missing result', async () => { - const staleRead = Promise.withResolvers>>() - let readCount = 0 - vi.resetModules() - vi.doMock('../src/runner-protocol.ts', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readRunnerEventsAsync: vi.fn(async (eventsPath: string) => { - readCount += 1 - if (readCount === 1) return staleRead.promise - return actual.readRunnerEventsAsync(eventsPath) - }), - } - }) - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - const exited = Promise.withResolvers() - const isolated = await import('../src/runner-launch.ts') - const result = isolated.runnerDirectResult(fakeChild(123), files, exited.promise) - expect(readCount).toBe(1) - exited.resolve(undefined) - await Promise.resolve() - appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) - staleRead.resolve([{ type: 'started', pid: 456 }]) - await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) - expect(readCount).toBe(2) - } finally { - cleanupRunnerFiles(files) - vi.doUnmock('../src/runner-protocol.ts') - vi.resetModules() - } - }) - - it('reports a missing direct result at runner exit without waiting for pipe close', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 123, exitCode: null, signalCode: null }) - const lifecycle = observeChildLifecycle(child) - const result = runnerDirectResult(child, files, lifecycle.exited) - child.emit('exit', 1, null) - await expect(result.direct).rejects.toThrow('exited without a direct-command result') - child.emit('close', 1, null) - await lifecycle.closed - } finally { - cleanupRunnerFiles(files) - } - }) - - it('contains wrapper spawn errors while publishing the runner startup rejection', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - const child = spawn(`missing-dsh-native-runner-${String(process.pid)}-${String(Date.now())}`, [], { - stdio: 'ignore', - }) - const lifecycle = observeChildLifecycle(child) - const result = runnerDirectResult(child, files, lifecycle.exited) - expect(result.pid).toBeUndefined() - await expect(result.direct).rejects.toThrow('runner failed to start') - await expect(lifecycle.closed).resolves.toBeUndefined() - } finally { - cleanupRunnerFiles(files) - } - }) - - it('returns before target publication and updates the pid getter from runner events', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - try { - const result = runnerDirectResult(fakeChild(process.pid), files, new Promise(() => {})) - expect(result.pid).toBeUndefined() - appendRunnerEvent(files.eventsPath, { type: 'started', pid: 456 }) - await vi.waitFor(() => { expect(result.pid).toBe(456) }) - appendRunnerEvent(files.eventsPath, { type: 'exit', exitCode: 0, signal: null }) - await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) - } finally { - cleanupRunnerFiles(files) - } - }) - - it('cleans runner files only after the direct result and runner close settle', async () => { - const files = createRunnerFiles({ argv: ['node'], cwd: '.', env: {} }) - const closed = Promise.withResolvers() - cleanupAfterRunner(files, Promise.resolve({ exitCode: 0, signal: null }), closed.promise) - await new Promise(resolve => setImmediate(resolve)) - expect(existsSync(files.directory)).toBe(true) - closed.resolve(undefined) - await new Promise(resolve => setImmediate(resolve)) + writeFileSync(join(files.directory, '.startup-error.tmp'), 'incomplete') + cleanupLinuxLaunchFiles(files) expect(existsSync(files.directory)).toBe(false) }) - it('reports the direct target pid and exit outcome from the source entry', async () => { - const files = createRunnerFiles({ - argv: [process.execPath, '-e', 'process.exit(7)'], - cwd: process.cwd(), - env: {}, - }) + it('removes the private directory when request creation fails partway through', () => { + const isolatedTmp = mkdtempSync(join(tmpdir(), 'dsh-launch-failure-spec-')) + vi.stubEnv('TMPDIR', isolatedTmp) + vi.stubEnv('TMP', isolatedTmp) + vi.stubEnv('TEMP', isolatedTmp) try { - const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) - expect(result.error).toBeUndefined() - const events = await readRunnerEventsAsync(files.eventsPath) - expect(events).toHaveLength(2) - expect(events[0]?.type).toBe('started') - if (events[0]?.type !== 'started') throw new Error('expected started event') - expect(events[0].pid).toBeGreaterThan(0) - expect(events[1]).toEqual({ type: 'exit', exitCode: 7, signal: null }) + const stringify = vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => { + throw new Error('request serialization failed') + }) + expect(() => createLinuxLaunchFiles({ cwd: '/target', env: {} })).toThrow('request serialization failed') + stringify.mockRestore() + expect(readdirSync(isolatedTmp)).toEqual([]) } finally { - cleanupRunnerFiles(files) + vi.unstubAllEnvs() + rmSync(isolatedTmp, { recursive: true, force: true }) } }) - it('preserves literal argv, cwd, and the exact target environment', () => { - const files = createRunnerFiles({ - argv: [ - process.execPath, - '-e', - 'console.log(JSON.stringify({ cwd: process.cwd(), value: process.env.RUNNER_VALUE, arg: process.argv[1] }))', - 'literal $HOME ${UNCHANGED}', - ], - cwd: process.cwd(), - env: { RUNNER_VALUE: 'explicit' }, + it('strictly rejects malformed Linux and Windows messages', () => { + const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} })) + writeFileSync(files.requestPath, JSON.stringify({ cwd: '/ok', env: {}, extra: true })) + expect(() => consumeLinuxLaunchRequest(files.requestPath)).toThrow('invalid Linux launch request') + expect(() => linuxLaunchFilesFromLocator('relative.json')).toThrow('invalid Linux launch-request locator') + expect(readLinuxStartupError(files.startupErrorPath)).toBeUndefined() + writeFileSync(files.startupErrorPath, 'null') + expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('invalid startup error') + writeFileSync(files.startupErrorPath, JSON.stringify({ + type: 'unknown', error: { name: 'Error', message: 'bad' }, + })) + expect(() => readLinuxStartupError(files.startupErrorPath)).toThrow('unknown error result') + + expect(parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: { A: '1' } })).toEqual({ + type: 'start', cwd: 'C:\\x', env: { A: '1' }, }) - try { - const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) - expect(result.status).toBe(0) - expect(result.stdout.trim()).toBe(JSON.stringify({ - cwd: process.cwd(), - value: 'explicit', - arg: 'literal $HOME ${UNCHANGED}', - })) - } finally { - cleanupRunnerFiles(files) - } + expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: {}, extra: 1 })).toThrow() + expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true) + expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false) + expect(parseWindowsRunnerResult({ type: 'start-cancelled' })).toEqual({ type: 'start-cancelled' }) + expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: null, signal: 'SIGTERM' })).toEqual({ + type: 'target-exit', exitCode: null, signal: 'SIGTERM', + }) + expect(parseWindowsRunnerResult({ type: 'spawn-error', error: { name: 'Error', message: 'bad' } })).toEqual({ + type: 'spawn-error', error: { name: 'Error', message: 'bad' }, + }) + for (const invalid of [ + null, + { type: 'unknown' }, + { type: 'start-cancelled', payload: 1 }, + { type: 'target-exit', exitCode: -1, signal: null }, + { type: 'target-exit', exitCode: 0, signal: 'NOPE' }, + { type: 'runner-error', error: { name: 'Error', message: 'bad', cause: {} } }, + ]) expect(() => parseWindowsRunnerResult(invalid)).toThrow() }) - it('reports target spawn failure without executing a fallback command', async () => { - const files = createRunnerFiles({ - argv: [`missing-dsh-runner-${Date.now()}`], - cwd: process.cwd(), - env: {}, - }) - try { - const result = runRunner(sourceInvocation, files.requestPath, files.eventsPath) - expect(result.error).toBeUndefined() - const events = await readRunnerEventsAsync(files.eventsPath) - expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT' } }) - } finally { - cleanupRunnerFiles(files) - } - }) + it('contains cleanup failures and removes a substituted symlink only', () => { + const files = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} })) + cleanupLinuxLaunchFiles(files) + cleanupLinuxLaunchFiles(files) + const target = join(tmpdir(), `dsh-runner-cleanup-target-${String(process.pid)}`) + const link = join(tmpdir(), `dsh-runner-cleanup-link-${String(process.pid)}`) + scratch.push(target, link) + mkdirSync(target, { recursive: true }) + symlinkSync(target, link) + cleanupLinuxLaunchFiles({ + directory: link, + requestPath: join(link, 'launch-request.json'), + startupErrorPath: join(link, 'startup-error.json'), + }) + expect(existsSync(link)).toBe(false) + expect(existsSync(target)).toBe(true) + + const blocked = track(createLinuxLaunchFiles({ cwd: '/ok', env: {} })) + unlinkSync(blocked.requestPath) + mkdirSync(blocked.requestPath) + cleanupLinuxLaunchFiles(blocked) + expect(existsSync(blocked.directory)).toBe(true) + }) +}) + +describe('runner launch inputs', () => { + const spec = { + argv: ['node', 'a'], + cwd: process.cwd(), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: 100, + env: { EXPLICIT: 'yes' }, + } as const + + it('keeps target state out of the bootstrap environment and consumes its selector', () => { + const env = runnerEnvironment('/tmp/request') + expect(env[SUBPROCESS_RUNNER_ENV]).toBe('/tmp/request') + expect(env.SYSTEMD_LOG_TARGET).toBe('null') + expect(env.EXPLICIT).toBeUndefined() + expect(consumeRunnerSelection(env)).toBe('/tmp/request') + expect(env[SUBPROCESS_RUNNER_ENV]).toBeUndefined() + expect(consumeRunnerSelection({})).toBeUndefined() + expect(parseRunnerTargetArgv(['--', 'node', 'a'])).toEqual(['node', 'a']) + expect(() => parseRunnerTargetArgv(['node'])).toThrow('private -- delimiter') + expect(runnerStdio(spec, false)).toEqual(['pipe', 'pipe', 'inherit']) + expect(runnerStdio(spec, true)).toEqual(['pipe', 'pipe', 'inherit', 'ipc']) + expect(runnerStdio({ + ...spec, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' }, + }, false)).toEqual(['ignore', 'inherit', 'pipe']) + }) + + it('validates every Node-baseline NUL location before launch', () => { + expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' }) + expect(validateTerminalTarget({ ...spec, rows: 24, cols: 80 })).toMatchObject({ EXPLICIT: 'yes' }) + for (const invalid of [ + { ...spec, argv: ['node\0'] }, + { ...spec, argv: ['node', 'a\0'] }, + { ...spec, cwd: 'bad\0cwd' }, + { ...spec, env: { 'BAD\0KEY': 'x' } }, + { ...spec, env: { BAD: 'x\0' } }, + ]) { + try { + targetEnvironment(invalid) + throw new Error('expected targetEnvironment to reject') + } catch (error) { + expect(error).toMatchObject({ name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' }) + } + } + }) + + it('resolves the source runner entry and checks concrete paths without executing it', () => { + const invocation = spawnRunnerInvocation() + expect(invocation[0]).toBe(process.execPath) + expect(invocation).toContain('tsx/esm') + expect(runnerInvocationAvailable(invocation)).toBe(true) + expect(runnerInvocationAvailable(['/definitely/missing-dsh-runner'])).toBe(false) + expect(runnerInvocationAvailable(['node'])).toBe(true) + expect(runnerInvocationAvailable(['node', 'runner.js'])).toBe(true) + + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + expect(spawnRunnerInvocation()).toEqual([process.execPath]) + } finally { + Reflect.deleteProperty(process, 'pkg') + } + }) + + it('bounds non-Error and stackless runner failures', () => { + expect(serializeRunnerError('plain failure')).toMatchObject({ + name: 'Error', message: 'plain failure', + }) + const stackless = new Error('stackless') + Reflect.deleteProperty(stackless, 'stack') + expect(serializeRunnerError(stackless)).toEqual({ name: 'Error', message: 'stackless' }) + const minimal = deserializeRunnerError({ name: 'Error', message: 'minimal' }) + expect(minimal).toMatchObject({ name: 'Error', message: 'minimal' }) + expect(minimal).not.toHaveProperty('code') + expect(minimal).not.toHaveProperty('errno') + expect(minimal).not.toHaveProperty('syscall') + expect(minimal).not.toHaveProperty('path') + expect(minimal).not.toHaveProperty('spawnargs') + }) +}) + +describe('Linux one-shot exec bootstrap', () => { + it('uses final cwd/env PATH while preserving the original argv', async () => { + const files = track(createLinuxLaunchFiles({ + cwd: '/final/work', + env: { PATH: 'relative::/absolute', [SUBPROCESS_RUNNER_ENV]: 'target-value' }, + })) + const host = new FakeRunnerHost() + const execve = vi.fn((_file: string, _argv: string[], _env: Record) => { + throw Object.assign(new Error('not found'), { code: 'ENOENT' }) + }) + await runSpawnRunner(files.requestPath, ['--', 'tool', 'literal arg'], hostArgument(host), internals({ execve })) + expect(host.directory).toBe('/final/work') + expect(host.env[SUBPROCESS_RUNNER_ENV]).toBeUndefined() + expect(execve.mock.calls.map(call => call[0])).toEqual([ + '/final/work/relative/tool', + '/final/work/tool', + '/absolute/tool', + ]) + expect(execve.mock.calls[0]?.[1]).toEqual(['tool', 'literal arg']) + expect(execve.mock.calls[0]?.[2]).toMatchObject({ [SUBPROCESS_RUNNER_ENV]: 'target-value' }) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ + type: 'spawn-error', error: { code: 'ENOENT', path: 'tool' }, + }) + }) + + it('uses the default PATH and stops on a non-search error', async () => { + const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + const execve = vi.fn((_file: string) => { throw Object.assign(new Error('denied'), { code: 'EACCES' }) }) + await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) + expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool']) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'spawn-error', error: { code: 'EACCES' } }) + + const explicit = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + const fatal = vi.fn(() => { throw Object.assign(new Error('bad executable'), { code: 'EIO' }) }) + await runSpawnRunner(explicit.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({ execve: fatal })) + expect(fatal).toHaveBeenCalledOnce() + + const stackless = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + await runSpawnRunner(stackless.requestPath, ['--', './tool'], hostArgument(new FakeRunnerHost()), internals({ + execve: vi.fn(() => { throw new Error('unclassified failure') }), + })) + expect(readLinuxStartupError(stackless.startupErrorPath)).toMatchObject({ + type: 'spawn-error', error: { message: 'unclassified failure' }, + }) + + const searched = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + const searchedExecve = vi.fn() + .mockImplementationOnce(() => { throw Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }) }) + .mockImplementationOnce(() => { throw Object.assign(new Error('I/O failure'), { code: 'EIO' }) }) + await runSpawnRunner(searched.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ + execve: searchedExecve as never, + })) + expect(readLinuxStartupError(searched.startupErrorPath)).toMatchObject({ + type: 'spawn-error', error: { code: 'EIO' }, + }) + }) + + it('publishes request/protocol failures as runner errors', async () => { + const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + writeFileSync(files.requestPath, '{') + await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals()) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'runner-error' }) + + const early = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) + await reportSpawnRunnerFailure(early.requestPath, new Error('delimiter failed'), hostArgument(new FakeRunnerHost())) + expect(readLinuxStartupError(early.startupErrorPath)).toMatchObject({ + type: 'runner-error', error: { message: 'delimiter failed' }, + }) + }) +}) + +describe('Windows Job runner protocol owner', () => { + it('maps the bounded Win32 process-creation error classes', async () => { + for (const [win32Code, code] of [ + [3, 'ENOENT'], + [267, 'ENOENT'], + [5, 'EACCES'], + [193, 'EFTYPE'], + [999, 'UNKNOWN'], + ] as const) { + const host = new FakeRunnerHost() + await runWindows(host, internals({ + spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }), + })) + expect(host.sent).toMatchObject([{ type: 'spawn-error', error: { code } }]) + } + }) + + it('rejects a Windows runner without an initial IPC channel', async () => { + const disconnected = new FakeRunnerHost() + disconnected.connected = false + await runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(disconnected), + internals(), + ) + expect(disconnected.exitCode).toBe(127) + + const missingSend = new FakeRunnerHost() + Object.defineProperty(missingSend, 'send', { value: undefined }) + await runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(missingSend), + internals(), + ) + expect(missingSend.exitCode).toBe(127) + }) + + it('sends target-exit only after suspended Job launch and closes runner stdio', async () => { + const host = new FakeRunnerHost() + const native = internals() + await runWindows(host, native) + expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), { + command: 'tool.exe', args: ['literal arg'], cwd: 'C:\\target', + }) + expect(native.closeCurrentProcessStandardHandles).toHaveBeenCalledOnce() + expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process') + expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job') + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(host.exitCode).toBe(0) + expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' }) + }) + + it('exhausts spawn-error, runner-error, and payload-free start-cancelled', async () => { + const spawnHost = new FakeRunnerHost() + await runWindows(spawnHost, internals({ + spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }), + })) + expect(spawnHost.sent).toMatchObject([{ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool.exe' } }]) + expect(spawnHost.exitCode).toBe(0) + + const runnerHost = new FakeRunnerHost() + await runWindows(runnerHost, internals({ + loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding failed') }), + })) + expect(runnerHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'binding failed' } }]) + expect(runnerHost.exitCode).toBe(127) + + const cancelledHost = new FakeRunnerHost() + const native = internals() + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(cancelledHost), native) + cancelledHost.emit('message', { type: 'terminate' }) + cancelledHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await running + expect(cancelledHost.sent).toEqual([{ type: 'start-cancelled' }]) + expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() + }) + + it('cancels after accepting start but before target commit', async () => { + const host = new FakeRunnerHost() + const native = internals() + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + host.emit('message', { type: 'terminate' }) + await running + expect(host.sent).toEqual([{ type: 'start-cancelled' }]) + expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() + }) + + it('does not create a target after pre-commit IPC disconnect', async () => { + const host = new FakeRunnerHost() + const native = internals() + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + host.disconnect() + await running + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() + expect(native.terminateJob).not.toHaveBeenCalled() + expect(host.exitCode).toBe(127) + }) + + it('terminates and closes the unique Job immediately when IPC disconnects', async () => { + const host = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) }) + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + host.disconnect() + await running + expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1) + expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job cleanup') + expect(host.exitCode).toBe(127) + }) + + it('honors terminate after commit and treats result-send failure as infrastructure failure', async () => { + const host = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) }) + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(host), native) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + host.emit('message', { type: 'terminate' }) + host.emit('message', { type: 'terminate' }) + expect(native.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1) + host.disconnect() + await running + + const sendFailureHost = new FakeRunnerHost() + sendFailureHost.sendFailure = new Error('send failed') + const sendFailureNative = internals({ isJobEmpty: vi.fn(() => false) }) + await runWindows(sendFailureHost, sendFailureNative) + expect(sendFailureNative.terminateJob).toHaveBeenCalledWith(expect.anything(), 20n, 1) + expect(sendFailureNative.closeHandleChecked).toHaveBeenCalledWith( + expect.anything(), + 20n, + 'ordinary process Job cleanup', + ) + expect(sendFailureHost.exitCode).toBe(127) + }) + + it('handles commit-time termination reentrancy and termination failure', async () => { + const reentrantHost = new FakeRunnerHost() + const reentrant = internals({ + closeCurrentProcessStandardHandles: vi.fn(() => { + reentrantHost.emit('message', { type: 'terminate' }) + }), + pollProcessExit: vi.fn(() => undefined), + isJobEmpty: vi.fn(() => false), + }) + const reentrantRun = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(reentrantHost), + reentrant, + ) + reentrantHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + expect(reentrant.terminateJob).toHaveBeenCalledTimes(2) + reentrantHost.disconnect() + await reentrantRun + + const failedHost = new FakeRunnerHost() + const failed = internals({ + pollProcessExit: vi.fn(() => undefined), + isJobEmpty: vi.fn(() => false), + terminateJob: vi.fn(() => { throw new Error('terminate Job failed') }), + }) + const failedRun = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(failedHost), + failed, + ) + failedHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + failedHost.emit('message', { type: 'terminate' }) + await failedRun + expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'terminate Job failed' } }]) + }) + + it('finishes when a later poll observes Job emptiness after result delivery', async () => { + const host = new FakeRunnerHost() + const native = internals({ + pollProcessExit: vi.fn().mockReturnValueOnce(0).mockReturnValue(undefined), + isJobEmpty: vi.fn().mockReturnValueOnce(false).mockReturnValue(true), + }) + const running = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(host), + native, + ) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await running + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(native.isJobEmpty).toHaveBeenCalledTimes(2) + }) + + it('contains poll failures and queued ticks after disconnect', async () => { + const failedHost = new FakeRunnerHost() + await runWindows(failedHost, internals({ + pollProcessExit: vi.fn(() => { throw new Error('poll failed') }), + })) + expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'poll failed' } }]) + + let tick: (() => void) | undefined + const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => { + tick = callback + return 1 as unknown as ReturnType + }) + try { + const host = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) }) + const running = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(host), + native, + ) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + tick?.() + host.disconnect() + await running + tick?.() + } finally { + interval.mockRestore() + } + }) + + it('cleans a direct handle after the Job identity was already cleared', async () => { + const host = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => true) }) + const running = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(host), + native, + ) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + host.emit('message', { type: 'terminate' }) + host.disconnect() + await running + expect(native.closeHandleChecked).toHaveBeenCalledWith( + expect.anything(), 10n, 'ordinary direct process cleanup', + ) + }) + + it('fails closed for malformed or duplicate start messages and disconnected reporting', async () => { + const malformed = new FakeRunnerHost() + await runWindows(malformed, internals(), { type: 'start', cwd: 'C:\\x', env: {}, extra: true }) + expect(malformed.sent).toMatchObject([{ type: 'runner-error' }]) + + const duplicate = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) }) + const running = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(duplicate), native) + duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} }) + duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} }) + await running + expect(duplicate.sent).toMatchObject([{ type: 'runner-error' }]) + + const raced = new FakeRunnerHost() + const racedRun = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(raced), internals()) + const lateMessage = raced.listeners('message')[0] as ((value: unknown) => void) | undefined + const lateDisconnect = raced.listeners('disconnect')[0] as (() => void) | undefined + raced.emit('message', { type: 'bad' }) + raced.emit('message', { type: 'bad' }) + await racedRun + await Promise.resolve() + lateMessage?.({ type: 'bad' }) + lateDisconnect?.() + + const disconnected = new FakeRunnerHost() + disconnected.connected = false + await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(disconnected)) + expect(disconnected.exitCode).toBe(127) + + const connected = new FakeRunnerHost() + connected.sendThrown = new Error('synchronous send failure') + await reportSpawnRunnerFailure(WINDOWS_RUNNER_SELECTION, new Error('early'), hostArgument(connected)) + expect(connected.exitCode).toBe(127) + expect(connected.connected).toBe(false) + + const noSelection = new FakeRunnerHost() + await reportSpawnRunnerFailure(undefined, new Error('no selector'), hostArgument(noSelection)) + expect(noSelection.exitCode).toBe(127) + }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 8b54a6b3de..e839201d65 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + bindManagedProcess, childEnv, killGroup, OutputCollector, @@ -12,6 +13,7 @@ import { } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { waitWithAbort } from '../src/managed-owner.ts' /** * Translate the suite's POSIX command strings into node one-liners on Windows, @@ -252,13 +254,14 @@ describe('spawnSubprocess', () => { ...spec('unused', { graceMs }), argv: [process.execPath, '-e', childScript], }) - const rootPid = running.pid - if (rootPid === undefined) throw new Error('test child did not publish a pid') const helper = await waitForPidFile(pidFile) const realKill: typeof process.kill = process.kill.bind(process) + let rootPid: number | undefined let termAt = 0 let forceSignals = 0 const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target !== 'number' || target >= 0) return realKill(target, signal) + rootPid ??= -target if (target !== -rootPid) return realKill(target, signal) if (signal === 'SIGTERM') { termAt = Date.now() @@ -589,9 +592,7 @@ describe('killGroup', () => { }) it('swallows ESRCH for vanished groups', async () => { - const running = spawnSubprocess(spec('true')) - await running.done - expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() + expect(() => { killGroup(2 ** 30, 'SIGTERM') }).not.toThrow() }) }) @@ -649,7 +650,8 @@ describe('windows tree semantics (injected platform)', () => { }) running.terminateForHostExit() await running.done - expect(killed).toEqual([running.pid]) + expect(killed).toHaveLength(1) + expect(killed[0]).toBeGreaterThan(0) }) it('terminate routes through taskkill by root pid', async () => { @@ -669,7 +671,8 @@ describe('windows tree semantics (injected platform)', () => { }) running.terminate() const outcome = await running.done - expect(killed).toContain(running.pid) + expect(killed).toHaveLength(1) + expect(killed[0]).toBeGreaterThan(0) expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL') }) @@ -775,6 +778,126 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina }) describe('coverage seams', () => { + 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() + const infrastructureFailure = Promise.withResolvers() + 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('does not deliver a stale escalation after range exit wins the timer race', async () => { + vi.useFakeTimers() + const clearTimer = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}) + try { + const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() + const stopped = Promise.withResolvers() + const signal = vi.fn() + const handle = bindManagedProcess(spec('true', { + graceMs: 10, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }), { + stdin: null, + stdout: null, + stderr: null, + direct: direct.promise, + owner: { + signal, + waitForExit: () => stopped.promise, + terminateForHostExit: vi.fn(), + }, + }) + + handle.terminate() + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM', expect.any(Error)) + stopped.resolve(undefined) + await expect(handle.waitForExit()).resolves.toBe(true) + await vi.advanceTimersByTimeAsync(10) + expect(signal).toHaveBeenCalledTimes(1) + + direct.resolve({ exitCode: 0, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + } finally { + clearTimer.mockRestore() + vi.useRealTimers() + } + }) + + it('delivers an already-aborted managed spawn reason before target settlement', async () => { + const reason = new Error('caller cancelled') + const controller = new AbortController() + controller.abort(reason) + const signal = vi.fn() + const handle = bindManagedProcess(spec('true', { + signal: controller.signal, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }), { + stdin: null, + stdout: null, + stderr: null, + direct: Promise.resolve({ exitCode: 0, signal: null }), + owner: { + signal, + waitForExit: async () => {}, + terminateForHostExit: vi.fn(), + }, + }) + + expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM', reason) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).resolves.toBe(true) + }) + + it('contains a late wait rejection after an already-aborted observation', async () => { + const pending = Promise.withResolvers() + await expect(waitWithAbort(pending.promise, AbortSignal.abort())).resolves.toBe(false) + pending.reject(new Error('late observation failure')) + await Promise.resolve() + }) + + it('closes the abort-listener registration race', async () => { + let aborted = false + const removeEventListener = vi.fn() + const signal = { + get aborted() { return aborted }, + addEventListener(_type: string, listener: () => void) { + aborted = true + listener() + }, + removeEventListener, + } as unknown as AbortSignal + await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) + expect(removeEventListener).toHaveBeenCalledOnce() + }) + it('taskkillProcessTree ignores an unpublished pid and contains a missing binary', () => { expect(() => { taskkillProcessTree(undefined) }).not.toThrow() // On POSIX there is no taskkill; spawnSync reports the failure in its @@ -792,13 +915,13 @@ describe('coverage seams', () => { platform: 'linux', linuxProcessGroupHasLiveMembers: () => false, }) - const rootPid = running.pid - if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) + let rootPid: number | undefined const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0) { + rootPid ??= -target if (signal === 0) return true - if (signal === 'SIGKILL') realKill(rootPid, 'SIGKILL') + if (signal === 'SIGKILL') realKill(-target, 'SIGKILL') return true } return realKill(target, signal) @@ -814,11 +937,11 @@ describe('coverage seams', () => { it('treats a vanished group probe as quiescent without signalling', async () => { const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) - const rootPid = running.pid - if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) + let rootPid: number | undefined const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0) { + rootPid ??= -target throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' }) } return realKill(target, signal) @@ -826,6 +949,7 @@ describe('coverage seams', () => { try { running.terminate() await new Promise(resolve => setTimeout(resolve, 20)) + if (rootPid === undefined) throw new Error('fallback owner did not probe its private process group') realKill(rootPid, 'SIGKILL') await running.done await expect(running.waitForExit()).resolves.toBe(true) @@ -836,11 +960,11 @@ describe('coverage seams', () => { it('treats an EPERM group probe as still alive', async () => { const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) - const rootPid = running.pid - if (rootPid === undefined) throw new Error('test child did not publish a pid') const realKill = process.kill.bind(process) + let rootPid: number | undefined const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { if (typeof target === 'number' && target < 0 && signal === 0) { + rootPid ??= -target throw Object.assign(new Error('simulated permission denial'), { code: 'EPERM' }) } return realKill(target, signal) @@ -849,6 +973,7 @@ describe('coverage seams', () => { await expect(running.waitForExit(AbortSignal.timeout(20))).resolves.toBe(false) } finally { killSpy.mockRestore() + if (rootPid === undefined) throw new Error('fallback owner did not probe its private process group') realKill(-rootPid, 'SIGKILL') await running.done } @@ -942,13 +1067,13 @@ describe('coverage seams', () => { await running.waitForExit() }) - it('waitForExit is immediate after host-exit finalization observes an absent tree', async () => { + it('host-exit finalization synchronously terminates until range absence is observed', async () => { const taskkill = vi.fn() const running = spawnSubprocess(spec('true'), { platform: 'win32', taskkill }) await running.done running.terminateForHostExit() await expect(running.waitForExit()).resolves.toBe(true) - expect(taskkill).not.toHaveBeenCalled() + expect(taskkill).toHaveBeenCalledOnce() }) it('repeated terminate after exit never probes or signals a reused process group', async () => { @@ -999,16 +1124,19 @@ describe('coverage seams 2', () => { await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch running.terminate() await running.done - expect(killedPid).toBe(running.pid) + expect(killedPid).toBeGreaterThan(0) await expect(running.waitForExit()).resolves.toBe(true) }) it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => { // An inert taskkill simulates a tree that never reports exit: terminate() // delivers nothing, so a bounded consumer wait must come back false. - const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) - const rootPid = running.pid - if (rootPid === undefined) throw new Error('test child did not publish a pid') + let rootPid: number | undefined + const running = spawnSubprocess(spec('sleep 60'), { + spillDir, + platform: 'win32', + taskkill: (pid) => { rootPid = pid }, + }) running.terminate() const bound = new AbortController() const timer = setTimeout(() => { bound.abort() }, 60) @@ -1016,6 +1144,7 @@ describe('coverage seams 2', () => { clearTimeout(timer) // Real cleanup: the injected platform spawned without detachment, so the // child is a plain (group-less) POSIX process — kill it directly. + if (rootPid === undefined) throw new Error('fallback owner did not call its private taskkill adapter') process.kill(rootPid, 'SIGKILL') await running.done }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 40b1a7f211..ffa34187d1 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -110,6 +110,7 @@ describe('LocalTerminalHandle', () => { } }, waitForExit: () => stopped.promise, + terminateForHostExit: vi.fn(), } const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) @@ -134,6 +135,7 @@ describe('LocalTerminalHandle', () => { } }, waitForExit: () => stopped.promise, + terminateForHostExit: vi.fn(), } const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) @@ -154,6 +156,7 @@ describe('LocalTerminalHandle', () => { const owner: BoundProcessOwner = { signal: (signal) => { signals.push(signal) }, waitForExit, + terminateForHostExit: vi.fn(), } const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) @@ -172,6 +175,7 @@ describe('LocalTerminalHandle', () => { waitForExit: vi.fn() .mockRejectedValueOnce(firstFailure) .mockRejectedValueOnce(finalFailure), + terminateForHostExit: vi.fn(), } const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) @@ -186,19 +190,50 @@ describe('LocalTerminalHandle', () => { const pty = new FakePty() const inspector = new FakeInspector() const signal = vi.fn() - const owner: BoundProcessOwner = { signal, waitForExit: async () => {} } + const terminateForHostExit = vi.fn() + const owner: BoundProcessOwner = { signal, waitForExit: async () => {}, terminateForHostExit } const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, 'linux', owner) handle.terminateForHostExit() - expect(signal).toHaveBeenCalledExactlyOnceWith('SIGKILL') + expect(signal).not.toHaveBeenCalled() + expect(terminateForHostExit).toHaveBeenCalledOnce() expect(inspector.processes).toEqual([]) expect(pty.kills).toEqual([]) }) + it('rejects managed outcome conversion and cleans its protocol after exit', async () => { + const pty = new FakePty() + const failure = new Error('invalid bootstrap outcome') + const cleanupManagedProtocol = vi.fn() + const owner: BoundProcessOwner = { + signal: vi.fn(), + waitForExit: async () => {}, + terminateForHostExit: vi.fn(), + } + const handle = new LocalTerminalHandle( + pty.asPty(), + new FakeInspector(), + 10, + 'linux', + owner, + () => { throw failure }, + cleanupManagedProtocol, + ) + + pty.emitExit() + await expect(handle.done).rejects.toBe(failure) + await expect(handle.terminate()).resolves.toBeUndefined() + await vi.waitFor(() => { expect(cleanupManagedProtocol).toHaveBeenCalledOnce() }) + }) + it('waits for the node-pty exit event after the managed range becomes empty', async () => { const pty = new FakePty() - const owner: BoundProcessOwner = { signal: vi.fn(), waitForExit: async () => {} } + const owner: BoundProcessOwner = { + signal: vi.fn(), + waitForExit: async () => {}, + terminateForHostExit: vi.fn(), + } const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 100, 'linux', owner) let settled = false @@ -217,6 +252,7 @@ describe('LocalTerminalHandle', () => { const owner: BoundProcessOwner = { signal: (signal) => { signals.push(signal) }, waitForExit: async () => {}, + terminateForHostExit: vi.fn(), } const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 68850d04c4..49f4bc35d8 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -1,322 +1,277 @@ -import { spawn, spawnSync } from 'node:child_process' -import type { ChildProcess } from 'node:child_process' import { EventEmitter } from 'node:events' import { PassThrough } from 'node:stream' -import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { appendRunnerEvent } from '../src/runner-protocol.ts' -import { launchWindowsJob, probeWindowsJob } from '../src/windows-job.ts' -import type { WindowsStdioBridge } from '../src/windows-stdio.ts' +import { + launchWindowsJob, + probeWindowsJob, +} from '../src/windows-job.ts' +import { bindManagedProcess } from '../src/spawn.ts' -const fixture = fileURLToPath(new URL('fixtures/fake-job-runner.ts', import.meta.url)) -const invocation: [string, ...string[]] = [process.execPath, '--import', 'tsx/esm', fixture] +class FakeChild extends EventEmitter { + pid: number | undefined = 432 + connected = true + stdin = new PassThrough() + stdout = new PassThrough() + stderr = new PassThrough() + sent: unknown[] = [] + killed: NodeJS.Signals[] = [] + sendError: Error | undefined + throwOnSendCall: number | undefined + sendThrown: unknown = new Error('send threw') + private sendCalls = 0 -function spec(argv: string[]): SubprocessSpawnSpec { - return { - argv, - cwd: process.cwd(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - graceMs: 100, + send(message: unknown, callback?: (error: Error | null) => void): boolean { + this.sendCalls += 1 + if (this.sendCalls === this.throwOnSendCall) throw this.sendThrown + this.sent.push(message) + queueMicrotask(() => { callback?.(this.sendError ?? null) }) + return true + } + kill(signal: NodeJS.Signals): boolean { + this.killed.push(signal) + return true } } -describe('Windows Job runner adapter', () => { - it('probes the runner before a user command is selected', () => { - const runSync = vi.fn(() => ({ status: 0, error: undefined })) as unknown as typeof spawnSync - expect(probeWindowsJob({ spawnSync: runSync, runnerInvocation: invocation })).toBe(true) - expect(runSync).toHaveBeenCalledWith( - process.execPath, - [...invocation.slice(1), '--mode', 'probe-win32'], - expect.objectContaining({ stdio: 'ignore' }), - ) - expect(probeWindowsJob({ - spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync, - runnerInvocation: invocation, - })).toBe(false) - expect(probeWindowsJob({ - spawnSync: vi.fn(() => ({ status: 0, error: new Error('probe failed') })) as unknown as typeof spawnSync, - runnerInvocation: invocation, - })).toBe(false) +const spec = { + argv: ['tool.exe', 'literal arg'], + cwd: 'C:\\target', + env: { TARGET: 'yes' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: 100, +} as const + +function launch(child = new FakeChild()) { + const spawn = vi.fn(() => child) + const result = launchWindowsJob(spec, { TARGET: 'yes' }, { + spawn: spawn as never, + runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'], }) + return { child, result, spawn } +} - it('reports direct outcome separately from runner settlement', async () => { - const launch = launchWindowsJob(spec(['fake-target', '7']), { - spawn, - runnerInvocation: invocation, - }) - expect(launch.pid).toBeUndefined() - await vi.waitFor(() => { expect(launch.pid).toBeGreaterThan(0) }) - await expect(launch.direct).resolves.toEqual({ exitCode: 7, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - }) - - it('signals the Job runner and waits for its managed range to stop', async () => { - const launch = launchWindowsJob(spec(['fake-target']), { - spawn, - runnerInvocation: invocation, - }) - launch.owner.signal('SIGTERM') - await expect(launch.direct).resolves.toEqual({ exitCode: 1, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - launch.owner.signal('SIGKILL') - }) - - it.each([ - { exitCode: 127, signal: null, status: 'exit code 127' }, - { exitCode: null, signal: 'SIGTERM' as NodeJS.Signals, status: 'signal SIGTERM' }, - { exitCode: null, signal: null, status: 'without an exit status' }, - ])('rejects range settlement when the runner exits with $status', async ({ exitCode, signal, status }) => { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - Object.assign(child, { pid: 432, connected: false, kill }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - const directFailure = launch.direct.catch((error: unknown) => error) - - child.emit('close', exitCode, signal) - - await expect(launch.owner.waitForExit()).rejects.toThrow( - `Windows Job runner exited with ${status} before proving its managed range empty`, - ) - await expect(directFailure).resolves.toBeInstanceOf(Error) - launch.owner.signal('SIGKILL') - expect(kill).not.toHaveBeenCalled() - }) - - it('uses runner exit status as the managed-range settlement fact after startup failure', async () => { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn() - Object.assign(child, { pid: 432, connected: true, kill, send }) - const run = vi.fn((_command: string, args: readonly string[]) => { - const eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { - type: 'spawn-error', - error: { name: 'Error', message: 'spawn missing ENOENT', code: 'ENOENT' }, - }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['missing-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - - launch.owner.signal('SIGTERM') - await expect(launch.direct).rejects.toMatchObject({ code: 'ENOENT' }) - expect(send).toHaveBeenCalledExactlyOnceWith({ type: 'terminate' }, expect.any(Function)) - expect(kill).not.toHaveBeenCalled() - child.emit('close', 0, null) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - }) - - it('treats a wrapper that never started as an empty managed range', async () => { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - Object.assign(child, { pid: undefined, connected: false, kill }) - const run = vi.fn(() => child) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['missing-runner'] }) - - child.emit('error', new Error('spawn missing-runner ENOENT')) - child.emit('close', -2, null) - await expect(launch.direct).rejects.toThrow('runner failed to start') - launch.owner.signal('SIGTERM') - expect(kill).not.toHaveBeenCalled() - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - }) - - it('waits for a runner that disconnects before its clean close', async () => { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn() - Object.assign(child, { pid: 321, connected: false, kill, send }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - - launch.owner.signal('SIGTERM') - expect(send).not.toHaveBeenCalled() - expect(kill).not.toHaveBeenCalled() - - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - }) - - it('kills the runner only when IPC delivery fails while still connected', async () => { - for (const mode of ['callback-error', 'callback-disconnect', 'throw', 'throw-disconnect'] as const) { - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { - if (mode === 'callback-disconnect' || mode === 'throw-disconnect') { - Object.assign(child, { connected: false }) - } - if (mode === 'throw' || mode === 'throw-disconnect') throw new Error('send threw') - callback(new Error('send failed')) - return true - }) - Object.assign(child, { - pid: 321, - connected: true, - kill, - send, - }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 321 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - - launch.owner.signal('SIGTERM') - const shouldKill = mode === 'callback-error' || mode === 'throw' - expect(kill).toHaveBeenCalledTimes(shouldKill ? 1 : 0) - - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', shouldKill ? null : 0, shouldKill ? 'SIGTERM' : null) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - if (shouldKill) { - await expect(launch.owner.waitForExit()).rejects.toThrow('before proving its managed range empty') - } else { - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - } - const sends = send.mock.calls.length - const kills = kill.mock.calls.length - launch.owner.signal('SIGKILL') - expect(send).toHaveBeenCalledTimes(sends) - expect(kill).toHaveBeenCalledTimes(kills) - } - - const child = new EventEmitter() as ChildProcess - const kill = vi.fn(() => true) - const send = vi.fn((_message: unknown, callback: (error: Error | null) => void) => { - callback(null) - return true - }) - Object.assign(child, { pid: 654, connected: true, kill, send }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 654 }) - return child - }) as unknown as typeof spawn - const launch = launchWindowsJob(spec(['fake-target']), { spawn: run, runnerInvocation: ['fake-runner'] }) - launch.owner.signal('SIGTERM') - expect(send).toHaveBeenCalledOnce() - expect(kill).not.toHaveBeenCalled() - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await launch.direct - await launch.owner.waitForExit() - }) - - it('uses production runner defaults', async () => { - const child = new EventEmitter() as ChildProcess - Object.assign(child, { - pid: 987, - connected: true, - kill: vi.fn(() => true), - send: vi.fn(), - }) - let eventsPath = '' - const run = vi.fn((_command: string, args: readonly string[]) => { - eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 987 }) - return child - }) - const runSync = vi.fn(() => ({ status: 0, error: undefined })) +describe('Windows Job capability', () => { + it('uses the production dependency paths by default', async () => { vi.resetModules() + const child = new FakeChild() + const spawn = vi.fn(() => child) + const load = vi.fn(() => ({ bindings: true }) as never) + const probe = vi.fn() vi.doMock('node:child_process', async importOriginal => ({ ...await importOriginal(), - spawn: run, - spawnSync: runSync, + spawn, })) - try { - const defaults = await import('../src/windows-job.ts') - expect(defaults.probeWindowsJob()).toBe(true) - const launch = defaults.launchWindowsJob(spec(['fake-target'])) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - child.emit('close', 0, null) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - expect(run).toHaveBeenCalledOnce() - expect(runSync).toHaveBeenCalledOnce() - } finally { - vi.doUnmock('node:child_process') - vi.resetModules() - } - }) - - it('cleans synchronous setup failures and leaves collected-stream settlement to common binding', async () => { - const bridgeFailure = new Error('bridge failed') - const spawnFailure = new Error('spawn threw') - const bridges: Array; closeInput: ReturnType }> = [] - vi.resetModules() - vi.doMock('../src/windows-stdio.ts', async importOriginal => ({ - ...await importOriginal(), - createWindowsStdioBridge: vi.fn((request: SubprocessSpawnSpec): WindowsStdioBridge => { - if (request.argv[0] === 'bridge-failure') throw bridgeFailure - const stdout = typeof request.stdio.stdout === 'object' ? new PassThrough() : null - const stderr = typeof request.stdio.stderr === 'object' ? new PassThrough() : null - const bridge = { - stdin: null, - stdout, - stderr, - runnerArgs: [], - runnerStdio: ['ignore', 'ignore', 'ignore', 'ipc'], - closeInput: vi.fn(), - dispose: vi.fn(() => { - stdout?.destroy() - stderr?.destroy() - }), - } satisfies WindowsStdioBridge - bridges.push(bridge) - return bridge - }), + vi.doMock('@deepseek-ai/dsh-win32-process', () => ({ + loadWin32ProcessBindings: load, + probeCurrentTokenJobSupport: probe, })) try { const isolated = await import('../src/windows-job.ts') - expect(() => isolated.launchWindowsJob(spec(['bridge-failure']), { runnerInvocation: ['fake-runner'] })) - .toThrow(bridgeFailure) + expect(isolated.probeWindowsJob()).toBe(true) + expect(load).toHaveBeenCalledOnce() + expect(probe).toHaveBeenCalledOnce() - expect(() => isolated.launchWindowsJob(spec(['spawn-failure']), { - spawn: vi.fn(() => { throw spawnFailure }) as unknown as typeof spawn, - runnerInvocation: ['fake-runner'], - })).toThrow(spawnFailure) - expect(bridges.at(-1)?.dispose).toHaveBeenCalledOnce() - - const child = new EventEmitter() as ChildProcess - Object.assign(child, { pid: 432, connected: true, kill: vi.fn(), send: vi.fn() }) - const run = vi.fn((_command: string, args: readonly string[]) => { - const eventsPath = args[args.indexOf('--events') + 1] as string - appendRunnerEvent(eventsPath, { type: 'started', pid: 432 }) - appendRunnerEvent(eventsPath, { type: 'exit', exitCode: 0, signal: null }) - setImmediate(() => { child.emit('close', 0, null) }) - return child - }) as unknown as typeof spawn - const request = { - ...spec(['collect']), - stdio: { - stdin: 'ignore', - stdout: { maxBytes: 1024 }, - stderr: { maxBytes: 1024 }, - }, - } satisfies SubprocessSpawnSpec - const launch = isolated.launchWindowsJob(request, { spawn: run, runnerInvocation: ['fake-runner'] }) - await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(launch.owner.waitForExit()).resolves.toBeUndefined() - expect(bridges.at(-1)?.closeInput).toHaveBeenCalledOnce() + const result = isolated.launchWindowsJob(spec, { TARGET: 'yes' }) + expect(spawn).toHaveBeenCalledOnce() + child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) + child.connected = false + child.emit('close', 0, null) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(result.owner.waitForExit()).resolves.toBeUndefined() } finally { - vi.doUnmock('../src/windows-stdio.ts') + vi.doUnmock('node:child_process') + vi.doUnmock('@deepseek-ai/dsh-win32-process') vi.resetModules() } }) + + it('rechecks runner and empty Job support on every eligible spawn', () => { + const runnerAvailable = vi.fn(() => true) + const load = vi.fn(() => ({ bindings: true }) as never) + const probe = vi.fn() + const inputs = { + runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'] as [string, ...string[]], + runnerAvailable, + loadWin32ProcessBindings: load, + probeCurrentTokenJobSupport: probe, + } + expect(probeWindowsJob(inputs)).toBe(true) + expect(probeWindowsJob(inputs)).toBe(true) + expect(runnerAvailable).toHaveBeenCalledTimes(2) + expect(load).toHaveBeenCalledTimes(2) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('falls back when either runner or current Job capability is unavailable', () => { + expect(probeWindowsJob({ + resolveRunnerInvocation: () => { throw new Error('runner resolution failed') }, + })).toBe(false) + expect(probeWindowsJob({ runnerInvocation: ['/missing'], runnerAvailable: () => false })).toBe(false) + expect(probeWindowsJob({ + runnerInvocation: ['C:\\node.exe'], + runnerAvailable: () => true, + loadWin32ProcessBindings: () => { throw new Error('bindings missing') }, + })).toBe(false) + }) +}) + +describe('Windows parent runner contract', () => { + it('launches with real stdio plus IPC and sends cwd/env through the strict start message', () => { + const { child, result, spawn } = launch() + expect(spawn).toHaveBeenCalledWith('C:\\node.exe', [ + 'C:\\runner.js', '--', 'tool.exe', 'literal arg', + ], expect.objectContaining({ + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'inherit', 'ipc'], + })) + expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }]) + expect(result.stdin).toBe(child.stdin) + expect(result.stdout).toBe(child.stdout) + expect(result.stderr).toBe(child.stderr) + }) + + 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, signal: null }) + await expect(result.direct).resolves.toEqual({ exitCode: 7, signal: null }) + child.connected = false + child.emit('close', 0, null) + await expect(result.owner.waitForExit()).resolves.toBeUndefined() + }) + + it('rejects done when runner failure precedes stdio settlement', async () => { + const { child, result } = launch() + const handle = bindManagedProcess(spec, result) + child.emit('message', { type: 'target-exit', exitCode: 7, signal: null }) + await Promise.resolve() + child.connected = false + child.emit('close', 127, null) + await expect(handle.done).rejects.toThrow('exit code 127') + }) + + it('maps spawn-error and start-cancelled without requiring public target identity', async () => { + const spawned = launch() + spawned.child.emit('message', { + type: 'spawn-error', error: { name: 'Error', message: 'missing', code: 'ENOENT' }, + }) + await expect(spawned.result.direct).rejects.toMatchObject({ code: 'ENOENT' }) + spawned.child.connected = false + spawned.child.emit('close', 0, null) + await expect(spawned.result.owner.waitForExit()).resolves.toBeUndefined() + + const cancelled = launch() + const reason = new Error('caller aborted') + cancelled.result.owner.signal('SIGTERM', reason) + expect(cancelled.child.sent.at(-1)).toEqual({ type: 'terminate' }) + cancelled.child.emit('message', { type: 'start-cancelled' }) + await expect(cancelled.result.direct).rejects.toBe(reason) + cancelled.child.connected = false + cancelled.child.emit('close', 0, null) + await expect(cancelled.result.owner.waitForExit()).resolves.toBeUndefined() + + const implicit = launch() + implicit.child.emit('message', { type: 'start-cancelled' }) + await expect(implicit.result.direct).rejects.toThrow('target start was cancelled') + implicit.child.connected = false + implicit.child.emit('close', 0, null) + await expect(implicit.result.owner.waitForExit()).resolves.toBeUndefined() + }) + + it('rejects direct and wait for runner-error or abnormal runner exit', async () => { + const failed = launch() + failed.child.emit('message', { + type: 'runner-error', error: { name: 'Error', message: 'Job assignment failed' }, + }) + await expect(failed.result.direct).rejects.toThrow('Job assignment failed') + 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 + missing.child.emit('close', null, 'SIGKILL') + await expect(missing.result.direct).rejects.toThrow('signal SIGKILL') + + const statusless = launch() + statusless.child.connected = false + statusless.child.emit('close', null, null) + await expect(statusless.result.direct).rejects.toThrow('without an exit status') + }) + + it('fails closed on malformed/duplicate result, runner spawn error, and start-send error', async () => { + const malformed = launch() + malformed.child.emit('message', { type: 'target-exit', exitCode: -1, signal: null }) + expect(malformed.child.killed).toEqual(['SIGKILL']) + await expect(malformed.result.infrastructureFailure).rejects.toThrow('invalid target-exit') + + const duplicate = launch() + duplicate.child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) + duplicate.child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) + 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') + + const errored = launch() + const spawnError = new Error('runner executable missing') + errored.child.emit('error', spawnError) + await expect(errored.result.direct).rejects.toBe(spawnError) + await expect(errored.result.owner.waitForExit()).rejects.toBe(spawnError) + + const sendFailedChild = new FakeChild() + 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') + 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') + + const nonError = new FakeChild() + nonError.throwOnSendCall = 1 + nonError.sendThrown = 'start send failed' + const nonErrorResult = launch(nonError).result + await expect(nonErrorResult.direct).rejects.toThrow('start send failed') + await expect(nonErrorResult.infrastructureFailure).rejects.toThrow('start send failed') + }) + + it('fails infrastructure and kills the runner when termination delivery fails', async () => { + const callback = launch() + 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') + 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.toThrow('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') + }) + + it('uses synchronous runner termination for host exit and isolates repeated control', () => { + const { child, result } = launch() + result.owner.signal('SIGTERM', new Error('first')) + result.owner.signal('SIGKILL', new Error('second')) + expect(child.sent.filter(message => (message as { type?: string }).type === 'terminate')).toHaveLength(1) + result.owner.terminateForHostExit() + expect(child.killed).toEqual(['SIGKILL']) + }) }) diff --git a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts b/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts deleted file mode 100644 index c8b3cf4e07..0000000000 --- a/packages/subprocess/subprocess-local/tests/windows-stdio.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { once } from 'node:events' -import { connect } from 'node:net' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { createWindowsStdioBridge } from '../src/windows-stdio.ts' - -function pipeBase(): string { - return process.platform === 'win32' - ? `\\\\.\\pipe\\dsh-windows-stdio-test-${randomUUID()}` - : join('/tmp', `dsh-windows-stdio-${randomUUID()}`) -} - -function spec(): SubprocessSpawnSpec { - return { - argv: ['target'], - cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1024 } }, - graceMs: 100, - } -} - -function pathAfter(args: readonly string[], key: string): string { - const path = args[args.indexOf(key) + 1] - if (path === undefined) throw new Error(`missing ${key}`) - return path -} - -describe('Windows parent-owned stdio bridge', () => { - it('binds before returning so a synchronously launched peer can connect', async () => { - const bridge = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, - }, pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const result = spawnSync(process.execPath, ['-e', ` - const { connect } = require('node:net') - const socket = connect(${JSON.stringify(stdoutPath)}) - socket.once('connect', () => { - socket.write('blocked-parent', () => { - socket.destroy() - process.exit(0) - }) - }) - socket.once('error', () => { process.exit(1) }) - setTimeout(() => { process.exit(2) }, 2000) - `], { timeout: 5_000 }) - expect(result.status).toBe(0) - - const chunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - await once(bridge.stdout as NodeJS.ReadableStream, 'end') - expect(Buffer.concat(chunks).toString()).toBe('blocked-parent') - bridge.dispose() - }) - - it('moves bytes in both directions and ends output with its target-side peer', async () => { - const bridge = createWindowsStdioBridge(spec(), pipeBase()) - const stdoutPath = pathAfter(bridge.runnerArgs, '--stdout-pipe') - const stderrPath = pathAfter(bridge.runnerArgs, '--stderr-pipe') - const stdinPath = pathAfter(bridge.runnerArgs, '--stdin-pipe') - expect(bridge.runnerStdio).toEqual(['ignore', 'ignore', 'ignore', 'ipc']) - await new Promise(resolve => setImmediate(resolve)) - - bridge.stdin?.end('in') - const stdoutPeer = connect(stdoutPath) - const stderrPeer = connect(stderrPath) - const stdinPeer = connect(stdinPath) - await Promise.all([once(stdoutPeer, 'connect'), once(stderrPeer, 'connect'), once(stdinPeer, 'connect')]) - - const stdoutChunks: Buffer[] = [] - const stderrChunks: Buffer[] = [] - const stdinChunks: Buffer[] = [] - bridge.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk) }) - bridge.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) }) - stdinPeer.on('data', (chunk: Buffer) => { stdinChunks.push(chunk) }) - const stdoutEnded = once(bridge.stdout as NodeJS.ReadableStream, 'end') - const stderrEnded = once(bridge.stderr as NodeJS.ReadableStream, 'end') - const stdinEnded = once(stdinPeer, 'end') - - stdoutPeer.end('out') - stderrPeer.end('err') - await Promise.all([stdoutEnded, stderrEnded, stdinEnded]) - - expect(Buffer.concat(stdoutChunks).toString()).toBe('out') - expect(Buffer.concat(stderrChunks).toString()).toBe('err') - expect(Buffer.concat(stdinChunks).toString()).toBe('in') - bridge.dispose() - }) - - it('uses inherited output directly and disposes unconnected endpoints', () => { - const inherited = createWindowsStdioBridge({ - ...spec(), - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }, pipeBase()) - expect(inherited.stdin).toBeNull() - expect(inherited.stdout).toBeNull() - expect(inherited.stderr).toBeNull() - expect(inherited.runnerArgs).toEqual([]) - expect(inherited.runnerStdio).toEqual(['ignore', 'inherit', 'inherit', 'ipc']) - inherited.dispose() - - const pending = createWindowsStdioBridge(spec(), pipeBase()) - pending.closeInput() - expect(pending.stdin?.destroyed).toBe(true) - pending.dispose() - expect(pending.stdout?.destroyed).toBe(true) - expect(pending.stderr?.destroyed).toBe(true) - }) -}) diff --git a/packages/subprocess/subprocess-local/tsdown.config.ts b/packages/subprocess/subprocess-local/tsdown.config.ts index 845b418bde..b0f7e7e85e 100644 --- a/packages/subprocess/subprocess-local/tsdown.config.ts +++ b/packages/subprocess/subprocess-local/tsdown.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', - 'spawn-runner': 'lib/types/bin.js', + runner: 'lib/types/bin.js', }, outDir: 'lib', format: ['esm'], diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 86bba761f3..84d2845b96 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.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 packages/subprocess/subprocess/README.md -README.md: 4bfcf20a2116bffe8f830ca940aa32af71d73dc6 -README.zh.md: 75355ba952209be8126b999968343b1b0ca46840 +README.md: 35744148bf9f247c478c7fb86db31f6663712953 +README.zh.md: e8632cb0c73514d472763b5d875dc7d54abbc1eb diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 4bfcf20a21..35744148bf 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Any composition that runs child processes can start a fully specified child process or a real terminal session through `ctx.subprocess`, receive a live handle with streams, a target PID when available, and direct exit facts, then terminate and wait for the provider-managed range. The service provides executable lookup, the shared environment scrub, and bounded output capture, while every default — argv, deadlines, shell semantics — stays explicit on the request, so the consuming capability seams decide what a process means. A composition mounts one provider implementation (such as `dsh-subprocess-local`) that registers the service; the seam package itself is an abstract contract, not a loadable plugin. Nothing here reaches a model directly: process output and lifecycle are rendered by the consuming tools. +Any composition that runs child processes can start a fully specified child process or a real terminal session through `ctx.subprocess`, receive a live handle with streams and direct exit facts, then terminate and wait for the provider-managed range. The service provides executable lookup, the shared environment scrub, and bounded output capture, while every default — argv, deadlines, shell semantics — stays explicit on the request, so the consuming capability seams decide what a process means. A composition mounts one provider implementation (such as `dsh-subprocess-local`) that registers the service; the seam package itself is an abstract contract, not a loadable plugin. Nothing here reaches a model directly: process output and lifecycle are rendered by the consuming tools. ## Table of Contents @@ -38,7 +38,7 @@ One provider registers `ctx.subprocess` per composition; load it beside the cons ### Starting a managed process -The request is fully explicit: the program and arguments, the working directory, one stdio disposition per stream, a termination grace, an optional abort signal, and optional environment overrides. The provider publishes `pid` only when a real target PID is available; `undefined` means unavailable or not yet published and never encodes failure. `done` resolves with the direct command's exit facts (`exitCode` and `signal`) and rejects for spawn or provider failures; collected output stays readable after exit. +The request is fully explicit: the program and arguments, the working directory, one stdio disposition per stream, a termination grace, an optional abort signal, and optional environment overrides. Target and managed-range identities remain provider-private. `done` resolves with the direct command's exit facts (`exitCode` and `signal`) and rejects for spawn or provider failures; collected output stays readable after exit. ```text const executable = await ctx.subprocess.resolveExecutable('bash') @@ -100,7 +100,7 @@ The seam is built on one separation: the service owns process coordinates and li ### Data model and flow -A spawn returns a live handle immediately. Its `pid` can remain `undefined` until the provider has a real target identity, while `done` independently reports the direct command outcome or failure and `waitForExit()` reports managed-range quiescence. The request's abort signal drives the same termination procedure as `terminate()`. Collected readers are cursor-free: offsets are whole-stream byte coordinates the caller owns, so independent readers cannot consume one another's output, and a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. `spawnTerminal` is one deep primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members. +A spawn returns a live handle immediately without exposing target identity. `done` independently reports the direct command outcome or failure, while `waitForExit()` reports managed-range quiescence. The request's abort signal drives the same termination procedure as `terminate()`. Collected readers are cursor-free: offsets are whole-stream byte coordinates the caller owns, so independent readers cannot consume one another's output, and a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. `spawnTerminal` is one deep primitive because ordinary pipes cannot allocate a controlling terminal or clean terminal-session members. ### Lifecycle and invariants diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 75355ba952..e8632cb0c7 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -任何需要运行子进程的组合都可以通过 `ctx.subprocess` 启动完全明确指定的子进程或真实终端会话,收到带流、可用时的目标 PID 与直接退出事实的活动句柄,然后终止并等待由提供方管理的范围。本服务提供可执行文件查找、共享的环境清理与有界输出捕获,而每一项默认值——argv、时限、shell 语义——都显式留在请求上,由消费方能力 seam 决定进程的含义。组合只需挂载一个提供方实现(如 `dsh-subprocess-local`)来注册该服务;seam 包本身是抽象约定,不是可直接加载的插件。本包不直接接触模型:进程输出与生命周期的渲染由消费方工具负责。 +任何需要运行子进程的组合都可以通过 `ctx.subprocess` 启动完全明确指定的子进程或真实终端会话,收到带流与直接退出事实的活动句柄,然后终止并等待由提供方管理的范围。本服务提供可执行文件查找、共享的环境清理与有界输出捕获,而每一项默认值——argv、时限、shell 语义——都显式留在请求上,由消费方能力 seam 决定进程的含义。组合只需挂载一个提供方实现(如 `dsh-subprocess-local`)来注册该服务;seam 包本身是抽象约定,不是可直接加载的插件。本包不直接接触模型:进程输出与生命周期的渲染由消费方工具负责。 ## 目录 @@ -38,7 +38,7 @@ kind: "package-reference" ### 启动受管进程 -请求完全明确:程序与参数、工作目录、每条流一种 stdio 处置方式、终止宽限期、可选的中止信号与可选的环境覆盖。只有真实目标 PID 可用时,提供方才会发布 `pid`;`undefined` 表示不可用或尚未发布,绝不表示失败。`done` 以直接命令的退出事实(`exitCode` 与 `signal`)resolve,并在 spawn 或提供方失败时 reject;收集输出在退出后仍可读取。 +请求完全明确:程序与参数、工作目录、每条流一种 stdio 处置方式、终止宽限期、可选的中止信号与可选的环境覆盖。目标与受管范围标识保留在提供方内部。`done` 以直接命令的退出事实(`exitCode` 与 `signal`)resolve,并在 spawn 或提供方失败时 reject;收集输出在退出后仍可读取。 ```text const executable = await ctx.subprocess.resolveExecutable('bash') @@ -100,7 +100,7 @@ const output = handle.collected.stdout?.readFrom(0) ### 数据模型与流程 -spawn 会立即返回活动句柄。提供方拥有真实目标身份之前,`pid` 可以保持 `undefined`;`done` 独立报告直接命令的结果或失败,`waitForExit()` 则报告受管范围是否完全停稳。请求的中止信号驱动与 `terminate()` 相同的终止流程。收集模式的读取器无游标:偏移量是调用方拥有的全流字节坐标,因此独立读取器不会消费彼此的输出,偏移量滑出内存尾部的读取标记为 `lossy`,并在 spill 文件存在时指向它。`spawnTerminal` 是一项底层原语,因为普通管道无法分配控制终端或清理终端会话成员。 +spawn 会立即返回活动句柄,而不公开目标身份。`done` 独立报告直接命令的结果或失败,`waitForExit()` 则报告受管范围是否完全停稳。请求的中止信号驱动与 `terminate()` 相同的终止流程。收集模式的读取器无游标:偏移量是调用方拥有的全流字节坐标,因此独立读取器不会消费彼此的输出,偏移量滑出内存尾部的读取标记为 `lossy`,并在 spill 文件存在时指向它。`spawnTerminal` 是一项底层原语,因为普通管道无法分配控制终端或清理终端会话成员。 ### 生命周期与不变式 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 8fc9773df7..4a6977c6c5 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -80,10 +80,9 @@ declare module '@deepseek-ai/cordis' { * Implementations must honor these semantics: * - Executable paths belong to one execution world shared with the mounted * filesystem provider. - * - {@link spawn} returns a live handle synchronously. Its pid is provider-owned - * and may remain unavailable during asynchronous startup. `done` resolves with - * the spawned command's exit facts and may reject for spawn or provider - * failures. + * - {@link spawn} returns a live handle synchronously. Target identity remains + * provider-private; `done` resolves with the spawned command's exit facts and + * may reject for spawn or provider failures. * - Collect-mode readers are offset-based and non-consuming, so independent * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped @@ -92,7 +91,7 @@ declare module '@deepseek-ai/cordis' { * provider's documented procedure against its managed range. * {@link SubprocessHandle.waitForExit} observes that same range so a * consumer-owned teardown ladder can hold each tier on real quiescence; each - * provider documents its identity, signalling, and observability limits. + * provider documents its signalling and observability limits. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. * - {@link spawnTerminal} owns terminal allocation, text transport, diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index bfb2ce1ad1..5a68231c0f 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -161,12 +161,10 @@ export interface SubprocessCollectedOutputs { * remains readable after exit; piped streams belong to the caller. * * Termination and {@link SubprocessHandle.waitForExit} use the same managed - * range. Each provider documents the process identity and range it can - * observe. + * range. Each provider documents the range it can observe and its signalling + * and observation limits. */ export interface SubprocessHandle { - /** Provider-published target process identifier, or undefined until it is available. */ - readonly pid: number | undefined /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ readonly stdin: Writable | undefined /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index d2b75b4536..1e71605781 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -26,7 +26,6 @@ class StubSubprocessRuntime extends SubprocessRuntime { ? { stdout: { readFrom: () => read } } : {} return { - pid: spec.argv[0] === 'pending' ? undefined : spec.argv.length, stdin: undefined, stdout: undefined, stderr: undefined, @@ -60,7 +59,7 @@ describe('SubprocessRuntime seam', () => { stdio: { stdin: 'ignore', stdout: { maxBytes: 1 }, stderr: 'inherit' }, graceMs: 1, }) - expect(handle.pid).toBe(1) + expect(Object.hasOwn(handle, 'pid')).toBe(false) expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) @@ -68,21 +67,6 @@ describe('SubprocessRuntime seam', () => { expect(outcome.exitCode).toBe(0) }) - it('preserves an unavailable provider pid without treating it as failure', async () => { - const ctx = new Context() - await ctx.plugin(StubSubprocessRuntime) - const handle = ctx.subprocess.spawn({ - argv: ['pending'], - cwd: '/stub', - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - graceMs: 1, - }) - - expect(handle.pid).toBeUndefined() - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(handle.waitForExit()).resolves.toBe(true) - }) - it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 16fc16bea1..a3e3e7b55a 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 5898fe0afb5e2a4c553b8406437783a3cf44b011 -README.zh.md: ce3adcf55db4555ad3b6ced37998a27a6d83caef +README.md: ebffba7780bbc3888c36e5afdca1f3cf387b03ab +README.zh.md: 3ed6aedfc4e845361e8fc26f7599851d430a5d97 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 5898fe0afb..ebffba7780 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -28,8 +28,8 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Named-pipe stdio primitive** — `openNamedPipeForStdio()` opens a parent-owned endpoint with only the target-side read or write access required by that stream. `spawnCurrentTokenJobProcess()` accepts those explicit handles, temporarily enables inheritance for target creation, and otherwise uses the runner's inherited standard handle. -- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` applies the suspended-create, Job-assignment, and resume lifecycle through `CreateProcessW` and returns the original process handle plus the unnamed Job to the same runner. A zero-time process wait publishes direct exit separately, while `QueryInformationJobObject(JobObjectBasicAccountingInformation)` keeps that runner alive until `ActiveProcesses` reaches zero. +- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardHandles()` then closes the runner's copies so target exit can produce EOF at the parent. +- **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child policy above these primitives. @@ -37,7 +37,7 @@ The Windows ACL sandbox adds SID, DACL, grant, workspace, and public child polic ## Header verification -The process, named-pipe, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): +The process, stdio, and Job constants plus selected structure sizes and offsets are checked against the MinGW Windows headers by [`verify/abi-probe.cpp`](verify/abi-probe.cpp): ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index ce3adcf55d..3ed6aedfc4 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -28,8 +28,8 @@ kind: "package-library" - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **named-pipe stdio 原语** — `openNamedPipeForStdio()` 打开 parent-owned endpoint,并只申请该流 target 侧需要的 read 或 write access。`spawnCurrentTokenJobProcess()` 接受这些显式 handle,在创建目标期间临时启用继承;未显式提供的流继续使用 runner 继承的标准句柄。 -- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 通过 `CreateProcessW` 应用 suspended-create、Job-assignment 与 resume 生命周期,并把原始 process handle 与 unnamed Job 返回给同一个 runner。process 的 zero-time wait 单独发布 direct exit,`QueryInformationJobObject(JobObjectBasicAccountingInformation)` 则让该 runner 一直存活到 `ActiveProcesses` 归零。 +- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardHandles()` 随后关闭 runner 的副本,使 target 退出可以在 parent 产生 EOF。 +- **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公共 child policy。 @@ -37,7 +37,7 @@ Windows ACL 沙箱在这些原语上增加 SID、DACL、grant、workspace 与公 ## 头部验证 -process、named-pipe、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: +process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp) 对照 MinGW Windows 头文件检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 3a7448b497..d3b2eafcb4 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -10,12 +10,6 @@ export const INFINITE = 0xFFFFFFFF export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 -/** Read access requested for a private named-pipe client handle. */ -export const GENERIC_READ = 0x80000000 -/** Write access requested for a private named-pipe client handle. */ -export const GENERIC_WRITE = 0x40000000 -/** Open an existing named-pipe endpoint. */ -export const OPEN_EXISTING = 3 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 62375975ac..b1adeff700 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -67,15 +67,6 @@ export interface Win32ProcessBindings { args: null, ): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number - createFileW( - path: string, - desiredAccess: number, - shareMode: number, - securityAttributes: null, - creationDisposition: number, - flagsAndAttributes: number, - templateFile: null, - ): NativePtr setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, @@ -265,9 +256,6 @@ function bindings(): Win32ProcessBindings { 'uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, ]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), - createFileW: bind(kernel32, 'CreateFileW', PVOID, [ - 'str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID, - ]), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index fe61f4a409..17960f94c4 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,10 +19,11 @@ export type { } from './ffi.ts' export { closeHandleChecked, + closeCurrentProcessStandardHandles, drainPipe, isJobEmpty, - openNamedPipeForStdio, pollProcessExit, + probeCurrentTokenJobSupport, spawnInheritedJobProcess, spawnCurrentTokenJobProcess, spawnPipedProcess, @@ -30,7 +31,6 @@ export { waitForProcessExit, } from './process.ts' export type { - ChildStdioHandles, CurrentTokenProcessSpawnOptions, SpawnedJobProcess, SpawnedPipedProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 608e31c100..a609b2d08c 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -63,13 +63,6 @@ export interface CurrentTokenProcessSpawnOptions { cwd: string } -/** Optional explicit target standard handles; omitted entries use the caller's standard handle. */ -export interface ChildStdioHandles { - stdin?: NativePtr - stdout?: NativePtr - stderr?: NativePtr -} - /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ export interface RestrictedProcessSpawnOptions extends CurrentTokenProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ @@ -327,38 +320,10 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { return job } -/** - * Open one private named-pipe client for target stdio. - * @param api - active binding table. - * @param path - unique parent-owned named-pipe path. - * @param access - target-side read access for stdin or write access for output. - * @returns caller-owned connected pipe handle. - */ -export function openNamedPipeForStdio( - api: Win32ProcessBindings, - path: string, - access: 'read' | 'write', -): NativePtr { - const handle = api.createFileW( - path, - access === 'read' ? abi.GENERIC_READ : abi.GENERIC_WRITE, - 0, - null, - abi.OPEN_EXISTING, - 0, - null, - ) - if (isNullPtr(handle) || (handle as bigint) === -1n || (handle as bigint) === 0xFFFFFFFFFFFFFFFFn) { - throwLastError(api, 'CreateFileW', path) - } - return handle -} - /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, options: CurrentTokenProcessSpawnOptions, - stdio: ChildStdioHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, ): SpawnedJobProcess { @@ -370,9 +335,9 @@ function spawnJobProcess( api.closeHandle(job) throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) } - const stdIn = stdio.stdin ?? getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = stdio.stdout ?? getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = stdio.stderr ?? getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') + const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') + const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') + const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined @@ -468,7 +433,7 @@ export function spawnInheritedJobProcess( options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, {}, 'CreateProcessAsUserW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => createRestrictedProcess( api, options, @@ -483,16 +448,14 @@ export function spawnInheritedJobProcess( * Spawn an ordinary process suspended, assign its Job, then resume it. * @param api - active binding table. * @param options - command, cwd, and argv. - * @param stdio - optional explicit handles opened for this target. * @returns caller-owned process and Job handles after successful resume. */ export function spawnCurrentTokenJobProcess( api: Win32ProcessBindings, options: CurrentTokenProcessSpawnOptions, - stdio: ChildStdioHandles = {}, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, stdio, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( null, commandLine, @@ -507,6 +470,42 @@ export function spawnCurrentTokenJobProcess( )) } +/** + * Verify that an unnamed kill-on-close Job can be created and released now. + * @param api - active binding table. + */ +export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void { + const job = createKillOnCloseJob(api) + closeHandleChecked(api, job, 'current-token Job capability probe') +} + +/** + * Close the runner's inherited standard-handle copies after target creation. + * The target retains its inherited copies; closing these permits parent pipe + * EOF to follow the target rather than the longer-lived runner. + * @param api - active binding table. + */ +export function closeCurrentProcessStandardHandles(api: Win32ProcessBindings): void { + const handles: NativePtr[] = [] + for (const selector of [abi.STD_INPUT_HANDLE, abi.STD_OUTPUT_HANDLE, abi.STD_ERROR_HANDLE]) { + const handle = api.getStdHandle(selector) + if (isNullPtr(handle) || handles.includes(handle)) continue + handles.push(handle) + } + const failures: Error[] = [] + for (const handle of handles) { + try { + closeHandleChecked(api, handle, 'runner standard handle') + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))) + } + } + if (failures.length === 1) { + for (const failure of failures) throw failure + } + if (failures.length > 1) throw new AggregateError(failures, 'closing runner standard handles failed') +} + /** * Poll one process handle without blocking the runner event loop. * @param api - active binding table. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 6035b7c3a6..99d90d8267 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,30 +2,34 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, + closeCurrentProcessStandardHandles, isJobEmpty, - openNamedPipeForStdio, pollProcessExit, + probeCurrentTokenJobSupport, spawnCurrentTokenJobProcess, terminateJob, Win32Error, } from '../src/index.ts' import { CREATE_SUSPENDED, - GENERIC_READ, - GENERIC_WRITE, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, - OPEN_EXISTING, + STD_ERROR_HANDLE, + STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, WAIT_TIMEOUT, } from '../src/abi.ts' import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' +function nativePtr(value: bigint): NativePtr { + return value as NativePtr +} + function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), - createFileW: vi.fn(() => 70n), setInformationJobObject: vi.fn(() => 1), queryInformationJobObject: vi.fn((_job: NativePtr, _cls: number, information: Buffer) => { information.writeUInt32LE(0, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET) @@ -116,11 +120,18 @@ describe('ordinary Job process operations', () => { expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) - it('passes explicit target stdio handles without reading caller stdio', () => { + it('inherits the runner standard handles and restores their flags', () => { let startup: Record | undefined - const getStdHandle = vi.fn(() => 99n as NativePtr) + const handles = new Map([ + [STD_INPUT_HANDLE, 71n as NativePtr], + [STD_OUTPUT_HANDLE, 72n as NativePtr], + [STD_ERROR_HANDLE, 73n as NativePtr], + ]) + const getStdHandle = vi.fn((selector: number) => handles.get(selector) as NativePtr) + const setHandleInformation = vi.fn(() => 1) const bindings = api({ getStdHandle, + setHandleInformation, createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { startup = koffi.decode(infoPtr, STARTUPINFOW) as Record koffi.encode(processInfo, PROCESS_INFORMATION, { @@ -136,43 +147,17 @@ describe('ordinary Job process operations', () => { command: 'probe.exe', args: [], cwd: 'C:\\work', - }, { - stdin: 71n as NativePtr, - stdout: 72n as NativePtr, - stderr: 73n as NativePtr, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(getStdHandle).not.toHaveBeenCalled() + expect(getStdHandle.mock.calls.map(([selector]) => selector)).toEqual([ + STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, + STD_ERROR_HANDLE, + ]) expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) - }) - - it('opens private named-pipe clients with stream-specific access', () => { - const createFileW = vi.fn(() => 70n as NativePtr) - const bindings = api({ createFileW }) - expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-stdin', 'read')).toBe(70n) - expect(openNamedPipeForStdio(bindings, '\\\\.\\pipe\\dsh-stdout', 'write')).toBe(70n) - expect(createFileW).toHaveBeenNthCalledWith( - 1, - '\\\\.\\pipe\\dsh-stdin', - GENERIC_READ, - 0, - null, - OPEN_EXISTING, - 0, - null, - ) - expect(createFileW).toHaveBeenNthCalledWith( - 2, - '\\\\.\\pipe\\dsh-stdout', - GENERIC_WRITE, - 0, - null, - OPEN_EXISTING, - 0, - null, - ) - - const invalid = api({ createFileW: vi.fn(() => -1n as NativePtr) }) - expect(() => openNamedPipeForStdio(invalid, '\\\\.\\pipe\\missing', 'read')).toThrow(Win32Error) + expect(setHandleInformation.mock.calls).toEqual([ + [71n, 1, 1], [72n, 1, 1], [73n, 1, 1], + [71n, 1, 0], [72n, 1, 0], [73n, 1, 0], + ]) }) it('polls direct exit and Job emptiness without blocking', () => { @@ -223,4 +208,65 @@ describe('ordinary Job process operations', () => { const closeFailure = api({ closeHandle: vi.fn(() => 0) }) expect(() => { closeHandleChecked(closeFailure, 50n as NativePtr, 'test Job') }).toThrow(Win32Error) }) + + it('probes an unnamed Job and closes its handle', () => { + const closeHandle = vi.fn(() => 1) + const bindings = api({ closeHandle }) + expect(() => { probeCurrentTokenJobSupport(bindings) }).not.toThrow() + expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) + }) + + it('closes unique non-null inherited standard handles', () => { + const getStdHandle = vi.fn((_selector: number): NativePtr => nativePtr(0n)) + .mockReturnValueOnce(nativePtr(0n)) + .mockReturnValueOnce(nativePtr(72n)) + .mockReturnValueOnce(nativePtr(72n)) + const closeHandle = vi.fn(() => 1) + const bindings = api({ getStdHandle, closeHandle }) + + expect(() => { closeCurrentProcessStandardHandles(bindings) }).not.toThrow() + expect(getStdHandle.mock.calls.map(([selector]) => selector)).toEqual([ + STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, + STD_ERROR_HANDLE, + ]) + expect(closeHandle).toHaveBeenCalledExactlyOnceWith(72n) + }) + + it('reports one or several inherited standard-handle close failures', () => { + const singleFailure = api({ + getStdHandle: vi.fn() + .mockReturnValueOnce(nativePtr(71n)) + .mockReturnValueOnce(nativePtr(72n)) + .mockReturnValueOnce(nativePtr(73n)), + closeHandle: vi.fn((handle: NativePtr) => handle === 72n ? 0 : 1), + }) + expect(() => { closeCurrentProcessStandardHandles(singleFailure) }).toThrow(Win32Error) + + const severalFailures = api({ + getStdHandle: vi.fn() + .mockReturnValueOnce(nativePtr(71n)) + .mockReturnValueOnce(nativePtr(72n)) + .mockReturnValueOnce(nativePtr(73n)), + closeHandle: vi.fn((handle: NativePtr) => { + if (handle === 71n) throw 'raw close failure' + return handle === 72n ? 0 : 1 + }), + }) + let failure: unknown + try { + closeCurrentProcessStandardHandles(severalFailures) + } catch (error) { + failure = error + } + expect(failure).toMatchObject({ + name: 'AggregateError', + message: 'closing runner standard handles failed', + }) + const errors = (failure as { errors: unknown }).errors + expect(errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: 'raw close failure' }), + expect.any(Win32Error), + ])) + }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 60ad3c662d..3cbb883ccf 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -21,9 +21,6 @@ int wmain() P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); - P(GENERIC_READ); - P(GENERIC_WRITE); - P(OPEN_EXISTING); P(WAIT_TIMEOUT); P(STD_INPUT_HANDLE); P(STD_OUTPUT_HANDLE); @@ -46,9 +43,6 @@ int wmain() static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); - static_assert(GENERIC_READ == 0x80000000, "generic read access"); - static_assert(GENERIC_WRITE == 0x40000000, "generic write access"); - static_assert(OPEN_EXISTING == 3, "open existing disposition"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); static_assert(sizeof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION) == 48, "job accounting size"); static_assert(offsetof(JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, ActiveProcesses) == 40, "active process offset"); diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index c7c8cfed66..9bb8019a54 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -18,7 +18,7 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure' /** The sole application launcher inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/bin.js' +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js' /** Python-visible executable basename. */ const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime' /** Default Node major; SEA mode requires at least Node 22. */ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 8708b010d9..7afa1acea8 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -168,10 +168,10 @@ const packageFileExtras: Readonly> = { ], '@deepseek-ai/dsh-skill-badge': ['assets'], // Ordinary native containment ships a path-loaded runner and its shared - // result-protocol chunk beside the existing node-pty permission repair. + // runner chunk beside the existing node-pty permission repair. '@deepseek-ai/dsh-subprocess-local': [ - 'lib/spawn-runner.js', - 'lib/runner-protocol-*.js', + 'lib/runner.js', + 'lib/runner-*.js', 'scripts/ensure-spawn-helper.mjs', ], // tsdown shares the repository/pack code between the lib entry and the bin diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index b89c72c047..93e931e425 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -463,10 +463,6 @@ describe('Node 24 lane ownership', () => { 'packages/experimental/agent-team/tests/built-lib.e2e.ts', ]), ) - expect(subject.find(item => item.id === 'built-bin-smoke')?.env).toEqual({ - DSH_EXAMPLE_MODE: 'lib', - DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER: '1', - }) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 91ccc15a70..4132e16146 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -772,10 +772,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { ], { label: 'built-bin smoke', needs, - env: { - DSH_EXAMPLE_MODE: 'lib', - DSH_REQUIRE_BUILT_SUBPROCESS_RUNNER: '1', - }, + env: { DSH_EXAMPLE_MODE: 'lib' }, }) } diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 672ff33178..e4ea8485c6 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -10,6 +10,7 @@ import importlib.metadata import json import os import queue +import shutil import subprocess import sys import sysconfig @@ -1330,52 +1331,97 @@ def smoke_packaged_runner(executable: Path) -> None: """Exercise the private subprocess runner through the single-file entry.""" with tempfile.TemporaryDirectory(prefix="dsh-packaged-runner-") as temporary: root = Path(temporary).resolve() - request_path = root / "request.json" - events_path = root / "events.ndjson" - probe = subprocess.run( - [str(executable), "--dsh-internal-subprocess-runner", "--mode", "probe-node"], - cwd=root, - capture_output=True, - text=True, - timeout=30, - check=False, + target_script = ( + "import os,sys; " + "ok = (os.getcwd() == os.environ['PACKAGED_RUNNER_EXPECTED_CWD'] " + "and os.environ.get('DSH_SUBPROCESS_RUNNER') == 'target-collision-restored'); " + "sys.exit(7 if ok else 9)" ) - if probe.returncode != 0: - raise AssertionError(f"packaged runner probe failed: {probe.stderr}") - - request_path.write_text(json.dumps({ - "argv": [sys.executable, "-c", "import sys; sys.exit(7)"], - "cwd": str(root), - "env": {}, - })) - result = subprocess.run( - [ - str(executable), - "--dsh-internal-subprocess-runner", - "--mode", - "node", - "--request", - str(request_path), - "--events", - str(events_path), - ], - cwd=root, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - if result.returncode != 7: - raise AssertionError( - f"packaged runner returned {result.returncode}, expected 7; stderr: {result.stderr}" + if not IS_WINDOWS: + request_path = root / "launch-request.json" + target_env = dict(os.environ) + target_env["DSH_SUBPROCESS_RUNNER"] = "target-collision-restored" + target_env["PACKAGED_RUNNER_EXPECTED_CWD"] = str(root) + request_path.write_text( + json.dumps({"cwd": str(root), "env": target_env}), + encoding="utf-8", ) - events = [json.loads(line) for line in events_path.read_text().splitlines()] - if len(events) != 2 or events[0].get("type") != "started" or events[1] != { - "type": "exit", - "exitCode": 7, + request_path.chmod(0o600) + environment = dict(os.environ) + environment["DSH_SUBPROCESS_RUNNER"] = str(request_path) + result = subprocess.run( + [str(executable), "--", sys.executable, "-c", target_script], + cwd=root, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 7 or request_path.exists() or (root / "startup-error.json").exists(): + raise AssertionError( + "packaged POSIX runner failed: " + f"exit={result.returncode}; stdout={result.stdout!r}; stderr={result.stderr!r}" + ) + return + + node = shutil.which("node") + if node is None: + raise AssertionError("packaged Windows runner smoke requires node on PATH") + helper = root / "windows-runner-smoke.mjs" + helper.write_text( + """import { spawn } from 'node:child_process' +const [runtime, target, cwd, targetScript] = process.argv.slice(2) +const child = spawn(runtime, ['--', target, '-c', targetScript], { + cwd, + env: { ...process.env, DSH_SUBPROCESS_RUNNER: 'windows' }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], +}) +const messages = [] +let stdout = '' +let stderr = '' +child.stdout.on('data', chunk => { stdout += chunk.toString() }) +child.stderr.on('data', chunk => { stderr += chunk.toString() }) +child.on('message', message => { messages.push(message) }) +const result = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('spawn', () => { + child.send({ + type: 'start', + cwd, + env: { + ...process.env, + DSH_SUBPROCESS_RUNNER: 'target-collision-restored', + PACKAGED_RUNNER_EXPECTED_CWD: cwd, + }, + }, error => { if (error) reject(error) }) + }) + child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) }) +}) +process.stdout.write(JSON.stringify({ ...result, messages, stdout, stderr })) +""", + encoding="utf-8", + ) + helper_result = subprocess.run( + [node, str(helper), str(executable), sys.executable, str(root), target_script], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if helper_result.returncode != 0: + raise AssertionError(f"packaged Windows runner helper failed: {helper_result.stderr}") + observed = json.loads(helper_result.stdout) + expected = { + "exitCode": 0, "signal": None, - }: - raise AssertionError(f"packaged runner emitted unexpected events: {events}") + "messages": [{"type": "target-exit", "exitCode": 7, "signal": None}], + "stdout": "", + "stderr": "", + } + if observed != expected: + raise AssertionError(f"packaged Windows runner returned unexpected facts: {observed}") def is_idle_notification(message: dict[str, object]) -> bool: diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index bbe67e5b80..96a216a937 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -32,6 +32,7 @@ const MANIFEST_BIN_ALLOWLIST = new Map([ /** Every executable in a Node application workspace has one explicit role. */ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['apps/cli/src/bin.ts', 'supported dsh application launcher'], + ['apps/cli/src/runtime-bootstrap.ts', 'private packaging-only runtime dispatcher'], ['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'], ['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'], ['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'], diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index 77a61a5099..6a2ce8a42e 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Its pid is provider-owned and may remain unavailable during asynchronous startup. `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its identity, signalling, and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\"\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly pid: number | undefined;\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\"\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} From 9aecbad9dd0770b82c4a6b8d373174a7383e9354 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 19:16:34 +0800 Subject: [PATCH 074/110] fix(subprocess): keep runner worker-loadable --- .../tests/image-loadable.spec.ts | 17 +++++++++++++++++ packages/subprocess/subprocess-local/src/bin.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index a00fa133d0..192dba7b62 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -37,6 +37,7 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const SUBJECT = '@deepseek-ai/dsh-timeout' const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +const SUBPROCESS_LOCAL = '@deepseek-ai/dsh-subprocess-local' const WEB_SERVER = '@deepseek-ai/dsh-host-webserver' const workspaces = indexWorkspacePackages(repoRoot) @@ -114,6 +115,15 @@ const packedWebServer = (): ReturnType => webServerMemo ??= entries: [], }) +let subprocessLocalMemo: ReturnType | undefined +const packedSubprocessLocal = (): ReturnType => subprocessLocalMemo ??= packVfsImage({ + config: `- id: subject\n name: '${SUBPROCESS_LOCAL}'\n`, + profile: 'subprocess-runner-face-check', + workspaces, + resolveFrom: repoRoot, + entries: [], +}) + /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -136,6 +146,13 @@ const archive = async (): Promise => expect(result.transform.rewritten).toBeGreaterThan(0) }) + it('keeps the published subprocess runner face in the lowered image', () => { + const result = packedSubprocessLocal() + expect(result.roster).toEqual([SUBPROCESS_LOCAL]) + expect(result.missing).toEqual([]) + expect(Object.hasOwn(result.files, `node_modules/${SUBPROCESS_LOCAL}/lib/runner.js`)).toBe(true) + }) + it('names every JavaScript entry for the debugger, workspace files by repository path', () => { const result = packed() const decoder = new TextDecoder() diff --git a/packages/subprocess/subprocess-local/src/bin.ts b/packages/subprocess/subprocess-local/src/bin.ts index 02393125f8..bc72389109 100644 --- a/packages/subprocess/subprocess-local/src/bin.ts +++ b/packages/subprocess/subprocess-local/src/bin.ts @@ -31,6 +31,6 @@ if (isExecutedEntry()) { if (selection === undefined) { process.exitCode = 127 } else { - await runSelectedSubprocessRunner(selection) + void runSelectedSubprocessRunner(selection) } } From 8ef28f76c079c381ac506b04438791f257931537 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 19:55:16 +0800 Subject: [PATCH 075/110] fix(subprocess): preserve Windows runner spawn semantics --- ...-shared-win32-process-primitives.i18n.yaml | 4 +- ...6-08-19-shared-win32-process-primitives.md | 4 +- ...8-19-shared-win32-process-primitives.zh.md | 4 +- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 2 +- ...-08-28-subprocess-native-containment.zh.md | 2 +- .../subprocess-local/src/runner-launch.ts | 135 +++++++++++++++++- .../subprocess-local/src/spawn-runner.ts | 27 ++-- .../tests/spawn-runner-built.e2e.ts | 53 ++++++- .../tests/spawn-runner.spec.ts | 63 +++++++- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- packages/subprocess/win32-process/src/ffi.ts | 4 +- .../subprocess/win32-process/src/index.ts | 2 +- .../subprocess/win32-process/src/process.ts | 44 +++--- .../tests/ordinary-process.spec.ts | 62 +++----- 17 files changed, 320 insertions(+), 98 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml index 643453c53b..82d3f8ee67 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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-19-shared-win32-process-primitives.md -2026-08-19-shared-win32-process-primitives.md: 7de80e15ef8b42e72187af40e5ee0646e80f071d -2026-08-19-shared-win32-process-primitives.zh.md: ebd9c437d617761cb7faef873f952443eca3f6ad +2026-08-19-shared-win32-process-primitives.md: 9ca8607177946826286b283bc5c996fc04596a72 +2026-08-19-shared-win32-process-primitives.zh.md: 903ccc027c377f4af6cb8cff1f07a852f712a633 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md index 7de80e15ef..9ca8607177 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -14,9 +14,9 @@ The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace p The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. -Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful anonymous-pipe creation returns the process plus stdout/stderr read handles to the sandbox. The ordinary runner temporarily restores inheritability on its own standard handles, passes those exact handles through `STARTF_USESTDHANDLES`, then closes its copies after target creation so target exit can produce EOF at the parent. Restricted and ordinary creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the [native-containment runner](2026-08-28-subprocess-native-containment.md) uniquely retains the ordinary direct-process handle and unnamed Job, polls direct exit and active-process count, and closes the Job only after it is empty. +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful anonymous-pipe creation returns the process plus stdout/stderr read handles to the sandbox. The ordinary runner temporarily restores inheritability on its own standard handles, passes those exact handles through `STARTF_USESTDHANDLES`, then destroys its Node/libuv standard streams after target creation so target exit can produce EOF at the parent. Restricted and ordinary creation both start the target suspended, assign it to the kill-on-close Job, and resume it only after assignment, so target code cannot run outside the Job. The sandbox retains its existing pipe-drain and direct-wait lifecycle; the [native-containment runner](2026-08-28-subprocess-native-containment.md) uniquely retains the ordinary direct-process handle and unnamed Job, polls direct exit and active-process count, and closes the Job only after it is empty. -The current-token API is named `CurrentTokenProcessSpawnOptions` and `spawnCurrentTokenJobProcess`; no `Ordinary*` or `Unrestricted*` aliases preserve ambiguous semantics. The package exports only operations used by the two production consumers. Exact `applicationName`, parent-owned Node streams and IPC, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. +The current-token API is named `CurrentTokenProcessSpawnOptions` and `spawnCurrentTokenJobProcess`; no `Ordinary*` or `Unrestricted*` aliases preserve ambiguous semantics. The current-token spawn accepts a provider-resolved `applicationName` separately from the preserved command-line argv entry, while executable resolution remains provider-owned. The package exports only operations used by the two production consumers. IPC, public process handles, and backend selection remain outside. The package is a library, not a Cordis service or a public Windows SDK. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md index ebd9c437d6..903ccc027c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -14,9 +14,9 @@ Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace p Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 -每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。anonymous pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。ordinary runner 临时恢复自身标准句柄的可继承位,通过 `STARTF_USESTDHANDLES` 原样传递这些句柄,并在目标创建后关闭自身副本,使目标退出可以让 parent 观察到 EOF。restricted 与 ordinary 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;[原生收容 runner](2026-08-28-subprocess-native-containment.zh.md)唯一保留 ordinary direct-process handle 与 unnamed Job,轮询 direct exit 和 active-process count,并只在 Job 为空后关闭它。 +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。anonymous pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。ordinary runner 临时恢复自身标准句柄的可继承位,通过 `STARTF_USESTDHANDLES` 原样传递这些句柄,并在目标创建后销毁自身的 Node/libuv 标准流,使目标退出可以让 parent 观察到 EOF。restricted 与 ordinary 创建都会以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。sandbox 保留既有 pipe-drain 与 direct-wait 生命周期;[原生收容 runner](2026-08-28-subprocess-native-containment.zh.md)唯一保留 ordinary direct-process handle 与 unnamed Job,轮询 direct exit 和 active-process count,并只在 Job 为空后关闭它。 -current-token API 直接命名为 `CurrentTokenProcessSpawnOptions` 与 `spawnCurrentTokenJobProcess`;不保留语义含糊的 `Ordinary*` 或 `Unrestricted*` 别名。该包只导出两个生产消费方已使用的操作。精确 `applicationName`、parent 自有的 Node stream 与 IPC、公共 process handle 以及后端选择仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 +current-token API 直接命名为 `CurrentTokenProcessSpawnOptions` 与 `spawnCurrentTokenJobProcess`;不保留语义含糊的 `Ordinary*` 或 `Unrestricted*` 别名。current-token spawn 单独接受由 provider 解析的 `applicationName`,同时保留原始命令行 argv 项;executable resolution 仍由 provider 拥有。该包只导出两个生产消费方已使用的操作。IPC、公共 process handle 以及后端选择仍留在外部。该包是 library,不是 Cordis service 或公共 Windows SDK。 ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 21f604876d..1f1ab7d4de 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 4423494f784218f01005034dd0305098823ee7fe -2026-08-28-subprocess-native-containment.zh.md: 724c017a681a35f4c8d0ddecea46c28cb5301a76 +2026-08-28-subprocess-native-containment.md: e323bb98658a1cfc8373283e0e95fbd7f752afad +2026-08-28-subprocess-native-containment.zh.md: fc60c3d449974e7558013b4afd95d31d751f8689 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 4423494f78..e323bb9865 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. Target stdin, stdout, and stderr remain real Node-created standard handles: the runner temporarily makes its inherited handles inheritable, creates the target with `STARTF_USESTDHANDLES`, and closes its own copies after target creation. User bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. The runner resolves a separate `CreateProcessW` application path with Node's target-cwd/PATH search order while preserving the original argv entry. Target stdin, stdout, and stderr remain real Node-created standard handles: the runner temporarily makes its inherited handles inheritable, creates the target with `STARTF_USESTDHANDLES`, and then destroys its own Node/libuv standard streams so only target copies keep parent pipes open. User bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 724c017a68..fc60c3d449 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。target stdin、stdout 与 stderr 继续使用 Node 创建的真实标准句柄:runner 临时把继承的句柄设为可继承,通过 `STARTF_USESTDHANDLES` 原样传递,并在 target 创建后关闭自身副本。用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 按 Node 的 target-cwd/PATH 搜索顺序解析独立的 `CreateProcessW` application path,同时保留原始 argv 项。target stdin、stdout 与 stderr 继续使用 Node 创建的真实标准句柄:runner 临时把继承的句柄设为可继承,通过 `STARTF_USESTDHANDLES` 原样传递,随后销毁自己持有的 Node/libuv 标准流,使 parent pipe 只由 target 副本保持打开。用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 699c4ec85c..96ab71cf96 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,7 +1,7 @@ /** Parent-side invocation and bootstrap state for the private native runner. */ import type { StdioOptions } from 'node:child_process' -import { accessSync, constants as fsConstants } from 'node:fs' +import { accessSync, constants as fsConstants, statSync } from 'node:fs' import { extname, isAbsolute } from 'node:path' import { inspect } from 'node:util' import { fileURLToPath } from 'node:url' @@ -108,6 +108,139 @@ export function runnerStdio( return stdio } +function windowsEnvironmentValue( + env: Readonly>, + name: 'PATH' | 'NODEFAULTCURRENTDIRECTORYINEXEPATH', +): string | undefined { + for (const key of Object.keys(env).sort()) { + if (key.toUpperCase() === name) return env[key] + } + return undefined +} + +function executableCandidateExists(candidate: string): boolean { + try { + return !statSync(candidate).isDirectory() + } catch { + return false + } +} + +function windowsPathDirectories(path: string): string[] { + const directories: string[] = [] + let start = 0 + while (start < path.length) { + if (path.charAt(start) === ';') { + start += 1 + continue + } + const quote = path.charAt(start) + const quoted = quote === '"' || quote === "'" + const quoteEnd = quoted + ? path.indexOf(quote, start + 1) + : -1 + const separator = path.indexOf(';', quoted ? quoteEnd < 0 ? path.length : quoteEnd : start) + const end = separator < 0 ? path.length : separator + let directory = path.slice(start, end) + if (directory.startsWith('"') || directory.startsWith("'")) directory = directory.slice(1) + if (directory.endsWith('"') || directory.endsWith("'")) directory = directory.slice(0, -1) + if (directory.length > 0) directories.push(directory) + start = end + 1 + } + return directories +} + +function windowsFileNameStart(command: string): number { + let start = command.length + while (start > 0 && !/[\\/:]/u.test(command.charAt(start - 1))) start -= 1 + return start +} + +function windowsSearchPathJoin(directory: string, name: string, cwd: string): string { + let prefix = cwd + let adjustedDirectory = directory + const slash = (value: string): boolean => value === '\\' || value === '/' + if (directory.length > 2 && slash(directory.charAt(0)) && slash(directory.charAt(1))) { + prefix = '' + } else if (directory.length >= 1 && slash(directory.charAt(0))) { + prefix = cwd.slice(0, 2) + } else if ( + directory.length >= 2 + && directory.charAt(1) === ':' + && (directory.length < 3 || !slash(directory.charAt(2))) + ) { + if (cwd.length < 2 || cwd.slice(0, 2).toLowerCase() !== directory.slice(0, 2).toLowerCase()) { + prefix = '' + } else { + adjustedDirectory = directory.slice(2) + } + } else if (directory.length > 2 && directory.charAt(1) === ':') { + prefix = '' + } + + const append = (base: string, part: string): string => { + if (base.length === 0 || part.length === 0) return base + part + return /[\\/:]$/u.test(base) ? base + part : `${base}\\${part}` + } + return append(append(prefix, adjustedDirectory), name) +} + +function windowsExecutableNames(command: string, name: string): string[] { + const dot = name.indexOf('.') + const hasExtension = dot >= 0 && dot < name.length - 1 + const separator = name.endsWith('.') ? '' : '.' + return [ + ...hasExtension ? [command] : [], + `${command}${separator}com`, + `${command}${separator}exe`, + ] +} + +/** + * Resolve the executable path with libuv/Node Windows spawn search order while + * preserving the caller's original command-line argv entry separately. + * @param command - original target argv[0]. + * @param cwd - final target working directory used for relative search roots. + * @param env - final target environment containing the child PATH. + * @param exists - injectable non-directory candidate probe used by tests. + * @param currentEnv - runner environment supplying PATH fallback and cwd-search policy. + * @returns a resolved application name suitable for `CreateProcessW`. + */ +export function resolveWindowsExecutable( + command: string, + cwd: string, + env: Readonly>, + exists: (candidate: string) => boolean = executableCandidateExists, + currentEnv: Readonly> = process.env, +): string { + const nameStart = windowsFileNameStart(command) + const directory = command.slice(0, nameStart) + const name = command.slice(nameStart) + const hasPath = nameStart !== 0 + const roots: string[] = [] + if (hasPath) { + roots.push(directory) + } else { + if (windowsEnvironmentValue(currentEnv, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined) { + roots.push('') + } + const path = windowsEnvironmentValue(env, 'PATH') ?? windowsEnvironmentValue(currentEnv, 'PATH') ?? '' + roots.push(...windowsPathDirectories(path)) + } + + for (const root of roots) { + const base = windowsSearchPathJoin(root, name, cwd) + for (const candidate of windowsExecutableNames(base, name)) { + if (exists(candidate)) return candidate + } + } + + const unresolved = windowsSearchPathJoin(directory, name, cwd) + if (hasPath) return unresolved + const dot = name.indexOf('.') + return dot >= 0 && dot < name.length - 1 ? unresolved : `${unresolved}.exe` +} + function throwNullByteError(property: string, value: string, argument: boolean): never { const subject = argument ? `The argument '${property}'` : `The property '${property}'` const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 967f98cdbd..bfccd155d0 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,8 +1,8 @@ /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */ -import { delimiter, resolve } from 'node:path' +import { posix } from 'node:path' import { - closeCurrentProcessStandardHandles, + closeCurrentProcessStandardStreams, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, @@ -28,6 +28,7 @@ import type { } from './runner-protocol.ts' import { parseRunnerTargetArgv, + resolveWindowsExecutable, SUBPROCESS_RUNNER_ENV, WINDOWS_RUNNER_SELECTION, } from './runner-launch.ts' @@ -41,7 +42,8 @@ export interface SpawnRunnerInternals { execve(file: string, argv: string[], env: Record): never loadWin32ProcessBindings(): Win32ProcessBindings spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess - closeCurrentProcessStandardHandles: typeof closeCurrentProcessStandardHandles + closeCurrentProcessStandardStreams: typeof closeCurrentProcessStandardStreams + resolveWindowsExecutable: typeof resolveWindowsExecutable pollProcessExit: typeof pollProcessExit isJobEmpty: typeof isJobEmpty terminateJob: typeof terminateJob @@ -53,7 +55,8 @@ const defaultInternals: SpawnRunnerInternals = { execve: (file, argv, env) => (process.execve as NonNullable)(file, argv, env), loadWin32ProcessBindings, spawnCurrentTokenJobProcess, - closeCurrentProcessStandardHandles, + closeCurrentProcessStandardStreams, + resolveWindowsExecutable, pollProcessExit, isJobEmpty, terminateJob, @@ -106,8 +109,8 @@ function execLinuxTarget( if (program.includes('/')) return internals.execve(program, argv, request.env) const path = request.env.PATH ?? '/usr/bin:/bin' let permissionFailure: Error | undefined - for (const directory of path.split(delimiter)) { - const candidate = resolve(request.cwd, directory, program) + for (const directory of path.split(':')) { + const candidate = posix.resolve(request.cwd, directory, program) try { return internals.execve(candidate, argv, request.env) } catch (error) { @@ -238,18 +241,26 @@ class WindowsJobRunner { return } try { + const [command, ...args] = this.argv + const applicationName = this.internals.resolveWindowsExecutable( + command as string, + request.cwd, + request.env, + undefined, + { ...this.host.env }, + ) replaceEnvironment(this.host.env, request.env) this.api = this.internals.loadWin32ProcessBindings() - const [command, ...args] = this.argv const spawned = this.internals.spawnCurrentTokenJobProcess(this.api, { command: command as string, + applicationName, args, cwd: request.cwd, }) this.processHandle = spawned.process this.jobHandle = spawned.job this.committed = true - this.internals.closeCurrentProcessStandardHandles(this.api) + this.internals.closeCurrentProcessStandardStreams() if (this.startCancellationPending()) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) this.poll() diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index d4c6dadcc2..e45b9eb125 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -3,11 +3,19 @@ import type { Buffer } from 'node:buffer' import { existsSync } from 'node:fs' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { cleanupLinuxLaunchFiles, createLinuxLaunchFiles, } from '../src/runner-protocol.ts' -import { runnerEnvironment, SUBPROCESS_RUNNER_ENV } from '../src/runner-launch.ts' +import { + runnerEnvironment, + SUBPROCESS_RUNNER_ENV, + targetEnvironment, +} from '../src/runner-launch.ts' +import type { RunnerInvocation } from '../src/runner-launch.ts' +import { bindManagedProcess } from '../src/spawn.ts' +import { launchWindowsJob } from '../src/windows-job.ts' const repoRoot = resolve(import.meta.dirname, '../../../..') const sourceRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/src/bin.ts') @@ -20,10 +28,10 @@ function targetEnv(): Record { } } -async function execute(invocation: string[]): Promise<{ status: number | null; stdout: string; stderr: string }> { +async function executePosix(invocation: RunnerInvocation): Promise<{ status: number | null; stdout: string; stderr: string }> { const files = createLinuxLaunchFiles({ cwd: repoRoot, env: targetEnv() }) try { - const child = spawn(invocation[0] as string, [ + const child = spawn(invocation[0], [ ...invocation.slice(1), '--', process.execPath, @@ -48,6 +56,41 @@ async function execute(invocation: string[]): Promise<{ status: number | null; s } } +async function executeWindows(invocation: RunnerInvocation): Promise<{ status: number | null; stdout: string; stderr: string }> { + const request: SubprocessSpawnSpec = { + argv: [ + process.execPath, + '--input-type=module', + '--eval', + `process.stdout.write(process.argv[0]+'|'+process.cwd()+'|'+process.env.${SUBPROCESS_RUNNER_ENV})`, + ], + cwd: repoRoot, + env: targetEnv(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000 }, + stderr: { maxBytes: 64_000 }, + }, + graceMs: 3_000, + } + const handle = bindManagedProcess(request, launchWindowsJob( + request, + targetEnvironment(request), + { runnerInvocation: invocation }, + )) + const outcome = await handle.done + await handle.waitForExit() + const stdout = handle.collected.stdout?.readFrom(0).text ?? '' + const stderr = handle.collected.stderr?.readFrom(0).text ?? '' + return { status: outcome.exitCode, stdout, stderr } +} + +async function execute(invocation: RunnerInvocation): Promise<{ status: number | null; stdout: string; stderr: string }> { + return process.platform === 'win32' + ? executeWindows(invocation) + : executePosix(invocation) +} + describe('subprocess-local runner artifacts', () => { it('executes the source entry through the provider-owned core', async () => { const result = await execute([process.execPath, '--import', 'tsx/esm', sourceRunner]) @@ -56,7 +99,7 @@ describe('subprocess-local runner artifacts', () => { stdout: `${process.execPath}|${repoRoot}|target-collision-restored`, stderr: '', }) - }) + }, 30_000) it.skipIf(!existsSync(builtRunner))('executes the built ./runner subpath through the same core', async () => { const result = await execute([process.execPath, builtRunner]) @@ -65,5 +108,5 @@ describe('subprocess-local runner artifacts', () => { stdout: `${process.execPath}|${repoRoot}|target-collision-restored`, stderr: '', }) - }) + }, 30_000) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 37c59ba112..9732cab128 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -34,6 +34,7 @@ import { runnerEnvironment, runnerInvocationAvailable, runnerStdio, + resolveWindowsExecutable, spawnRunnerInvocation, SUBPROCESS_RUNNER_ENV, targetEnvironment, @@ -95,7 +96,8 @@ function internals(overrides: Partial = {}): SpawnRunnerIn process: 10n as NativePtr, job: 20n as NativePtr, })), - closeCurrentProcessStandardHandles: vi.fn(), + closeCurrentProcessStandardStreams: vi.fn(), + resolveWindowsExecutable: vi.fn(() => 'C:\\resolved\\tool.exe'), pollProcessExit: vi.fn(() => 0), isJobEmpty: vi.fn(() => true), terminateJob: vi.fn(), @@ -122,8 +124,10 @@ async function runWindows( describe('closed runner protocol', () => { it('creates, consumes, reports through, and cleans one private Linux exchange', () => { const files = track(createLinuxLaunchFiles({ cwd: '/target', env: { A: '1' } })) - expect(statSync(files.directory).mode & 0o777).toBe(0o700) - expect(statSync(files.requestPath).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect(statSync(files.directory).mode & 0o777).toBe(0o700) + expect(statSync(files.requestPath).mode & 0o777).toBe(0o600) + } expect(linuxLaunchFilesFromLocator(files.requestPath)).toEqual(files) expect(consumeLinuxLaunchRequest(files.requestPath)).toEqual({ cwd: '/target', env: { A: '1' } }) expect(existsSync(files.requestPath)).toBe(false) @@ -132,7 +136,9 @@ describe('closed runner protocol', () => { name: 'SpawnError', code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['x'], }) writeLinuxStartupError(files, { type: 'spawn-error', error: serializeRunnerError(failure) }) - expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600) + } const result = readLinuxStartupError(files.startupErrorPath) expect(result).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } }) expect(deserializeRunnerError(result!.error)).toMatchObject({ @@ -301,6 +307,42 @@ describe('runner launch inputs', () => { expect(minimal).not.toHaveProperty('path') expect(minimal).not.toHaveProperty('spawnargs') }) + + it('resolves Windows executables with target-cwd and PATH search semantics', () => { + const probed: string[] = [] + const exists = (candidate: string): boolean => { + probed.push(candidate) + return candidate === 'C:\\tools\\git\\bin\\bash.exe' + } + expect(resolveWindowsExecutable('bash', 'C:\\target', { + Path: 'relative;"C:\\semi;colon";"C:\\tools\\git\\bin";C:\\later', + }, exists)).toBe('C:\\tools\\git\\bin\\bash.exe') + expect(probed).toEqual([ + 'C:\\target\\bash.com', + 'C:\\target\\bash.exe', + 'C:\\target\\relative\\bash.com', + 'C:\\target\\relative\\bash.exe', + 'C:\\semi;colon\\bash.com', + 'C:\\semi;colon\\bash.exe', + 'C:\\tools\\git\\bin\\bash.com', + 'C:\\tools\\git\\bin\\bash.exe', + ]) + + expect(resolveWindowsExecutable('local.exe', 'C:\\target', {}, candidate => + candidate === 'C:\\target\\local.exe')).toBe('C:\\target\\local.exe') + expect(resolveWindowsExecutable('tool', 'C:\\target', { + PATH: 'C:\\bin', + }, candidate => candidate === 'C:\\bin\\tool.com', { + NoDefaultCurrentDirectoryInExePath: '1', + })).toBe('C:\\bin\\tool.com') + expect(resolveWindowsExecutable('tool', 'C:\\target', { + PATH: 'D:relative', + }, candidate => candidate === 'D:relative\\tool.exe')).toBe('D:relative\\tool.exe') + expect(resolveWindowsExecutable('tool.', 'C:\\target', {}, candidate => + candidate === 'C:\\target\\tool.exe')).toBe('C:\\target\\tool.exe') + expect(resolveWindowsExecutable('.\\missing', 'C:\\target', {}, () => false)) + .toBe('C:\\target\\.\\missing') + }) }) describe('Linux one-shot exec bootstrap', () => { @@ -417,10 +459,17 @@ describe('Windows Job runner protocol owner', () => { const host = new FakeRunnerHost() const native = internals() await runWindows(host, native) + expect(native.resolveWindowsExecutable).toHaveBeenCalledWith( + 'tool.exe', + 'C:\\target', + { TARGET: 'yes', dsh_subprocess_runner: 'restored' }, + undefined, + { SAFE: 'bootstrap' }, + ) expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), { - command: 'tool.exe', args: ['literal arg'], cwd: 'C:\\target', + command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target', }) - expect(native.closeCurrentProcessStandardHandles).toHaveBeenCalledOnce() + expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledOnce() expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process') expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job') expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) @@ -518,7 +567,7 @@ describe('Windows Job runner protocol owner', () => { it('handles commit-time termination reentrancy and termination failure', async () => { const reentrantHost = new FakeRunnerHost() const reentrant = internals({ - closeCurrentProcessStandardHandles: vi.fn(() => { + closeCurrentProcessStandardStreams: vi.fn(() => { reentrantHost.emit('message', { type: 'terminate' }) }), pollProcessExit: vi.fn(() => undefined), diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index a3e3e7b55a..c225c4f3c3 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: ebffba7780bbc3888c36e5afdca1f3cf387b03ab -README.zh.md: 3ed6aedfc4e845361e8fc26f7599851d430a5d97 +README.md: 9b4f8eab236fd73f6b4f5dfc36e34df4d52f697a +README.zh.md: d9f9313f3eecc31ed03a445501fdd637b3250bf7 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index ebffba7780..9b4f8eab23 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -28,7 +28,7 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardHandles()` then closes the runner's copies so target exit can produce EOF at the parent. +- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardStreams()` then destroys the runner's Node/libuv owners so target exit can produce EOF at the parent. - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 3ed6aedfc4..d9f9313f3e 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardHandles()` 随后关闭 runner 的副本,使 target 退出可以在 parent 产生 EOF。 +- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardStreams()` 随后销毁 runner 的 Node/libuv owner,使 target 退出可以在 parent 产生 EOF。 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b1adeff700..b9977ade36 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -70,7 +70,7 @@ export interface Win32ProcessBindings { setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( token: NativePtr, - applicationName: null, + applicationName: string | null, commandLine: string, processAttributes: null, threadAttributes: null, @@ -82,7 +82,7 @@ export interface Win32ProcessBindings { processInfo: NativePtr, ): number createProcessW( - applicationName: null, + applicationName: string | null, commandLine: string, processAttributes: null, threadAttributes: null, diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 17960f94c4..5d8b8a29cf 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -19,7 +19,7 @@ export type { } from './ffi.ts' export { closeHandleChecked, - closeCurrentProcessStandardHandles, + closeCurrentProcessStandardStreams, drainPipe, isJobEmpty, pollProcessExit, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index a609b2d08c..cbd7580316 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -53,8 +53,7 @@ export function buildCommandLine(program: string, args: readonly string[]): stri return [program, ...args].map(quoteArg).join(' ') } -/** Ordinary process creation inputs used by the local Win32 runner. */ -export interface CurrentTokenProcessSpawnOptions { +interface ProcessSpawnOptions { /** Executable argv entry passed through CreateProcess. */ command: string /** Arguments excluding the executable. */ @@ -63,8 +62,14 @@ export interface CurrentTokenProcessSpawnOptions { cwd: string } +/** Ordinary process creation inputs used by the local Win32 runner. */ +export interface CurrentTokenProcessSpawnOptions extends ProcessSpawnOptions { + /** Resolved executable path passed separately from the preserved argv entry. */ + applicationName?: string +} + /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ -export interface RestrictedProcessSpawnOptions extends CurrentTokenProcessSpawnOptions { +export interface RestrictedProcessSpawnOptions extends ProcessSpawnOptions { /** Restricted primary token supplied by sandbox policy. */ token: NativePtr } @@ -457,7 +462,7 @@ export function spawnCurrentTokenJobProcess( const commandLine = buildCommandLine(options.command, options.args) return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( - null, + options.applicationName ?? null, commandLine, null, null, @@ -480,22 +485,25 @@ export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void { } /** - * Close the runner's inherited standard-handle copies after target creation. - * The target retains its inherited copies; closing these permits parent pipe - * EOF to follow the target rather than the longer-lived runner. - * @param api - active binding table. + * Release the runner's Node-owned standard streams after target creation. + * The target retains its inherited handle copies; destroying the runner's + * libuv owners permits parent pipe EOF to follow the target rather than the + * longer-lived runner. Raw `CloseHandle` is insufficient because Node may own + * a duplicated libuv handle that remains live until its stream is destroyed. + * @param streams - injectable current-process streams used by tests. */ -export function closeCurrentProcessStandardHandles(api: Win32ProcessBindings): void { - const handles: NativePtr[] = [] - for (const selector of [abi.STD_INPUT_HANDLE, abi.STD_OUTPUT_HANDLE, abi.STD_ERROR_HANDLE]) { - const handle = api.getStdHandle(selector) - if (isNullPtr(handle) || handles.includes(handle)) continue - handles.push(handle) - } +export function closeCurrentProcessStandardStreams( + streams: ReadonlyArray<{ readonly destroyed: boolean; destroy(): unknown }> = [ + process.stdin, + process.stdout, + process.stderr, + ], +): void { const failures: Error[] = [] - for (const handle of handles) { + for (const stream of new Set(streams)) { + if (stream.destroyed) continue try { - closeHandleChecked(api, handle, 'runner standard handle') + stream.destroy() } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))) } @@ -503,7 +511,7 @@ export function closeCurrentProcessStandardHandles(api: Win32ProcessBindings): v if (failures.length === 1) { for (const failure of failures) throw failure } - if (failures.length > 1) throw new AggregateError(failures, 'closing runner standard handles failed') + if (failures.length > 1) throw new AggregateError(failures, 'closing runner standard streams failed') } /** diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 99d90d8267..a9903ac648 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,7 +2,7 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, - closeCurrentProcessStandardHandles, + closeCurrentProcessStandardStreams, isJobEmpty, pollProcessExit, probeCurrentTokenJobSupport, @@ -23,10 +23,6 @@ import { import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' -function nativePtr(value: bigint): NativePtr { - return value as NativePtr -} - function api(overrides: Partial = {}): Win32ProcessBindings { return { createJobObjectW: vi.fn(() => 50n), @@ -89,11 +85,12 @@ describe('ordinary Job process operations', () => { }) expect(spawnCurrentTokenJobProcess(bindings, { command: 'probe.exe', + applicationName: 'C:\\resolved\\probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', })).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(createProcessW).toHaveBeenCalledWith( - null, + 'C:\\resolved\\probe.exe', 'probe.exe "literal $VALUE" "a b"', null, null, @@ -216,57 +213,38 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('closes unique non-null inherited standard handles', () => { - const getStdHandle = vi.fn((_selector: number): NativePtr => nativePtr(0n)) - .mockReturnValueOnce(nativePtr(0n)) - .mockReturnValueOnce(nativePtr(72n)) - .mockReturnValueOnce(nativePtr(72n)) - const closeHandle = vi.fn(() => 1) - const bindings = api({ getStdHandle, closeHandle }) + it('destroys each unique live runner standard stream', () => { + const alreadyClosed = { destroyed: true, destroy: vi.fn() } + const live = { destroyed: false, destroy: vi.fn() } - expect(() => { closeCurrentProcessStandardHandles(bindings) }).not.toThrow() - expect(getStdHandle.mock.calls.map(([selector]) => selector)).toEqual([ - STD_INPUT_HANDLE, - STD_OUTPUT_HANDLE, - STD_ERROR_HANDLE, - ]) - expect(closeHandle).toHaveBeenCalledExactlyOnceWith(72n) + expect(() => { closeCurrentProcessStandardStreams([alreadyClosed, live, live]) }).not.toThrow() + expect(alreadyClosed.destroy).not.toHaveBeenCalled() + expect(live.destroy).toHaveBeenCalledOnce() }) - it('reports one or several inherited standard-handle close failures', () => { - const singleFailure = api({ - getStdHandle: vi.fn() - .mockReturnValueOnce(nativePtr(71n)) - .mockReturnValueOnce(nativePtr(72n)) - .mockReturnValueOnce(nativePtr(73n)), - closeHandle: vi.fn((handle: NativePtr) => handle === 72n ? 0 : 1), - }) - expect(() => { closeCurrentProcessStandardHandles(singleFailure) }).toThrow(Win32Error) + it('reports one or several runner standard-stream close failures', () => { + expect(() => { closeCurrentProcessStandardStreams([{ + destroyed: false, + destroy: () => { throw new Error('single close failure') }, + }]) }).toThrow('single close failure') - const severalFailures = api({ - getStdHandle: vi.fn() - .mockReturnValueOnce(nativePtr(71n)) - .mockReturnValueOnce(nativePtr(72n)) - .mockReturnValueOnce(nativePtr(73n)), - closeHandle: vi.fn((handle: NativePtr) => { - if (handle === 71n) throw 'raw close failure' - return handle === 72n ? 0 : 1 - }), - }) let failure: unknown try { - closeCurrentProcessStandardHandles(severalFailures) + closeCurrentProcessStandardStreams([ + { destroyed: false, destroy: () => { throw 'raw close failure' } }, + { destroyed: false, destroy: () => { throw new Error('second close failure') } }, + ]) } catch (error) { failure = error } expect(failure).toMatchObject({ name: 'AggregateError', - message: 'closing runner standard handles failed', + message: 'closing runner standard streams failed', }) const errors = (failure as { errors: unknown }).errors expect(errors).toEqual(expect.arrayContaining([ expect.objectContaining({ message: 'raw close failure' }), - expect.any(Win32Error), + expect.objectContaining({ message: 'second close failure' }), ])) }) }) From 32b2b2409915ffeffaa086e8a1343b9aa37f7433 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 20:30:11 +0800 Subject: [PATCH 076/110] fix(subprocess): close runner stdio handles --- .../tests/spawn-runner.spec.ts | 35 +++++++++++++ .../subprocess/win32-process/src/process.ts | 49 +++++++++++++++---- .../tests/ordinary-process.spec.ts | 47 +++++++++++++----- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 9732cab128..32f2bdb90b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -342,6 +342,41 @@ describe('runner launch inputs', () => { candidate === 'C:\\target\\tool.exe')).toBe('C:\\target\\tool.exe') expect(resolveWindowsExecutable('.\\missing', 'C:\\target', {}, () => false)) .toBe('C:\\target\\.\\missing') + + expect(resolveWindowsExecutable('tool', 'C:\\target', { + PATH: ';;C:\\bin', + }, candidate => candidate === 'C:\\bin\\tool.exe')).toBe('C:\\bin\\tool.exe') + expect(resolveWindowsExecutable('tool', 'C:\\target', { + PATH: '"";C:\\bin', + }, candidate => candidate === 'C:\\bin\\tool.exe')).toBe('C:\\bin\\tool.exe') + expect(resolveWindowsExecutable('tool', 'C:\\target', { + PATH: '"unterminated', + }, candidate => candidate === 'C:\\target\\unterminated\\tool.exe')) + .toBe('C:\\target\\unterminated\\tool.exe') + expect(resolveWindowsExecutable('\\\\server\\share\\tool', 'C:\\target', {}, candidate => + candidate === '\\\\server\\share\\tool.exe')).toBe('\\\\server\\share\\tool.exe') + expect(resolveWindowsExecutable('\\tools\\tool', 'C:\\target', {}, candidate => + candidate === 'C:\\tools\\tool.exe')).toBe('C:\\tools\\tool.exe') + expect(resolveWindowsExecutable('C:tools\\tool', 'C:\\target', {}, candidate => + candidate === 'C:\\target\\tools\\tool.exe')).toBe('C:\\target\\tools\\tool.exe') + + const noSearchEnvironment = { NoDefaultCurrentDirectoryInExePath: '1' } + expect(resolveWindowsExecutable('missing', 'C:\\target', {}, () => false, noSearchEnvironment)) + .toBe('C:\\target\\missing.exe') + expect(resolveWindowsExecutable('missing.cmd', 'C:\\target', {}, () => false, noSearchEnvironment)) + .toBe('C:\\target\\missing.cmd') + + const directory = mkdtempSync(join(tmpdir(), 'dsh-windows-resolver-')) + scratch.push(directory) + const executable = join(directory, 'direct.exe') + const directoryCandidate = join(directory, 'directory') + const missingExecutable = join(directory, 'missing.exe') + writeFileSync(executable, '') + mkdirSync(`${directoryCandidate}.com`) + writeFileSync(`${directoryCandidate}.exe`, '') + expect(resolveWindowsExecutable(executable, '', {})).toBe(executable) + expect(resolveWindowsExecutable(directoryCandidate, '', {})).toBe(`${directoryCandidate}.exe`) + expect(resolveWindowsExecutable(missingExecutable, '', {})).toBe(missingExecutable) }) }) diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index cbd7580316..ca1cedd028 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -1,6 +1,7 @@ /** Typed Win32 process operations over the shared binding table. */ import koffi from 'koffi' +import { closeSync } from 'node:fs' import * as abi from './abi.ts' import { allocProcessInfo, @@ -484,28 +485,56 @@ export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void { closeHandleChecked(api, job, 'current-token Job capability probe') } +interface MaterializedStdioStream { + readonly destroyed: boolean + destroy(): unknown + readonly _handle?: { close(): void } | null +} + /** * Release the runner's Node-owned standard streams after target creation. - * The target retains its inherited handle copies; destroying the runner's - * libuv owners permits parent pipe EOF to follow the target rather than the - * longer-lived runner. Raw `CloseHandle` is insufficient because Node may own - * a duplicated libuv handle that remains live until its stream is destroyed. - * @param streams - injectable current-process streams used by tests. + * The target retains its inherited handle copies. Node deliberately makes + * `process.stdout.destroy()` and `process.stderr.destroy()` leave their libuv + * handles open, so the runner must close both the descriptors and materialized + * output handles for parent pipe EOF to follow the target. + * @param streams - stdin, stdout, and stderr used by the current process. + * @param closeDescriptor - injectable descriptor close used by tests. */ export function closeCurrentProcessStandardStreams( - streams: ReadonlyArray<{ readonly destroyed: boolean; destroy(): unknown }> = [ + streams: readonly [MaterializedStdioStream, MaterializedStdioStream, MaterializedStdioStream] = [ process.stdin, process.stdout, process.stderr, ], + closeDescriptor: (fd: number) => void = closeSync, ): void { const failures: Error[] = [] - for (const stream of new Set(streams)) { - if (stream.destroyed) continue + const recordFailure = (error: unknown): void => { + failures.push(error instanceof Error ? error : new Error(String(error))) + } + const [stdin, ...outputs] = streams + const outputHandles = new Set(outputs.flatMap(stream => stream.destroyed || stream._handle == null + ? [] + : [stream._handle])) + if (!stdin.destroyed) { try { - stream.destroy() + stdin.destroy() } catch (error) { - failures.push(error instanceof Error ? error : new Error(String(error))) + recordFailure(error) + } + } + for (const fd of [0, 1, 2]) { + try { + closeDescriptor(fd) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EBADF') recordFailure(error) + } + } + for (const handle of outputHandles) { + try { + handle.close() + } catch (error) { + recordFailure(error) } } if (failures.length === 1) { diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index a9903ac648..76ea688f9f 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -213,27 +213,49 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('destroys each unique live runner standard stream', () => { - const alreadyClosed = { destroyed: true, destroy: vi.fn() } - const live = { destroyed: false, destroy: vi.fn() } + it('closes each runner descriptor and materialized output handle', () => { + const closeDescriptor = vi.fn() + const input = { destroyed: false, destroy: vi.fn() } + const sharedHandle = { close: vi.fn() } + const output = { destroyed: false, destroy: vi.fn(), _handle: sharedHandle } + const alreadyClosed = { destroyed: true, destroy: vi.fn(), _handle: { close: vi.fn() } } - expect(() => { closeCurrentProcessStandardStreams([alreadyClosed, live, live]) }).not.toThrow() + expect(() => { + closeCurrentProcessStandardStreams([input, output, output], closeDescriptor) + }).not.toThrow() + expect(input.destroy).toHaveBeenCalledOnce() + expect(output.destroy).not.toHaveBeenCalled() + expect(sharedHandle.close).toHaveBeenCalledOnce() + expect(closeDescriptor.mock.calls).toEqual([[0], [1], [2]]) + + expect(() => { + closeCurrentProcessStandardStreams([alreadyClosed, alreadyClosed, alreadyClosed], closeDescriptor) + }).not.toThrow() expect(alreadyClosed.destroy).not.toHaveBeenCalled() - expect(live.destroy).toHaveBeenCalledOnce() + expect(alreadyClosed._handle.close).not.toHaveBeenCalled() }) it('reports one or several runner standard-stream close failures', () => { - expect(() => { closeCurrentProcessStandardStreams([{ - destroyed: false, - destroy: () => { throw new Error('single close failure') }, - }]) }).toThrow('single close failure') + const closed = { destroyed: true, destroy: vi.fn() } + expect(() => { + closeCurrentProcessStandardStreams([ + { destroyed: false, destroy: () => { throw new Error('single close failure') } }, + closed, + closed, + ], vi.fn((fd: number) => { + if (fd === 0) throw Object.assign(new Error('already closed'), { code: 'EBADF' }) + })) + }).toThrow('single close failure') let failure: unknown try { closeCurrentProcessStandardStreams([ - { destroyed: false, destroy: () => { throw 'raw close failure' } }, - { destroyed: false, destroy: () => { throw new Error('second close failure') } }, - ]) + closed, + { destroyed: false, destroy: vi.fn(), _handle: { close: () => { throw 'raw close failure' } } }, + { destroyed: false, destroy: vi.fn(), _handle: { close: () => { throw new Error('second close failure') } } }, + ], (fd) => { + if (fd === 1) throw new Error('descriptor close failure') + }) } catch (error) { failure = error } @@ -245,6 +267,7 @@ describe('ordinary Job process operations', () => { expect(errors).toEqual(expect.arrayContaining([ expect.objectContaining({ message: 'raw close failure' }), expect.objectContaining({ message: 'second close failure' }), + expect.objectContaining({ message: 'descriptor close failure' }), ])) }) }) From aca5c0cb2b76a17978b6b05f7848a7600d373573 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 21:29:09 +0800 Subject: [PATCH 077/110] fix(subprocess): release Windows runner stdio owners --- .../subprocess-local/src/spawn-runner.ts | 2 +- .../tests/spawn-runner.spec.ts | 3 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 2 +- .../subprocess/win32-process/README.zh.md | 2 +- .../subprocess/win32-process/src/process.ts | 57 ++++++++---- .../tests/ordinary-process.spec.ts | 91 +++++++++++++------ 7 files changed, 105 insertions(+), 56 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index bfccd155d0..096c783371 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -260,7 +260,7 @@ class WindowsJobRunner { this.processHandle = spawned.process this.jobHandle = spawned.job this.committed = true - this.internals.closeCurrentProcessStandardStreams() + this.internals.closeCurrentProcessStandardStreams(this.api) if (this.startCancellationPending()) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) this.poll() diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 32f2bdb90b..187682dea1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -504,7 +504,8 @@ describe('Windows Job runner protocol owner', () => { expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), { command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target', }) - expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledOnce() + expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledTimes(1) + expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledWith(expect.anything()) expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process') expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job') expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index c225c4f3c3..d997f83d41 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 9b4f8eab236fd73f6b4f5dfc36e34df4d52f697a -README.zh.md: d9f9313f3eecc31ed03a445501fdd637b3250bf7 +README.md: a90b33e52fc97be4f70f718c8847e604137a683f +README.zh.md: 85b6a00313e6ec1a0afb731e8eff59b32f2d0431 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 9b4f8eab23..a90b33e52f 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -28,7 +28,7 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardStreams()` then destroys the runner's Node/libuv owners so target exit can produce EOF at the parent. +- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardStreams()` then closes each unique raw standard handle and destroys the corresponding Node/libuv stream owner so target exit can produce EOF at the parent. - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index d9f9313f3e..85b6a00313 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardStreams()` 随后销毁 runner 的 Node/libuv owner,使 target 退出可以在 parent 产生 EOF。 +- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardStreams()` 随后关闭每个唯一的原始标准句柄,并销毁对应的 Node/libuv stream owner,使 target 退出可以在 parent 产生 EOF。 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index ca1cedd028..7b87a36dae 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -1,7 +1,6 @@ /** Typed Win32 process operations over the shared binding table. */ import koffi from 'koffi' -import { closeSync } from 'node:fs' import * as abi from './abi.ts' import { allocProcessInfo, @@ -488,34 +487,49 @@ export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void { interface MaterializedStdioStream { readonly destroyed: boolean destroy(): unknown - readonly _handle?: { close(): void } | null + _destroy?: unknown } /** * Release the runner's Node-owned standard streams after target creation. - * The target retains its inherited handle copies. Node deliberately makes - * `process.stdout.destroy()` and `process.stderr.destroy()` leave their libuv - * handles open, so the runner must close both the descriptors and materialized - * output handles for parent pipe EOF to follow the target. + * The target retains its inherited handle copies. On Windows, libuv duplicates + * fd 0/1/2 for its stream owners while Node gives stdout and stderr an own + * no-op `_destroy`. Close the raw handles and then restore the real Socket + * destruction path so parent pipe EOF can follow the target. + * @param api - active binding table used to release raw standard handles. * @param streams - stdin, stdout, and stderr used by the current process. - * @param closeDescriptor - injectable descriptor close used by tests. */ export function closeCurrentProcessStandardStreams( + api: Win32ProcessBindings, streams: readonly [MaterializedStdioStream, MaterializedStdioStream, MaterializedStdioStream] = [ process.stdin, process.stdout, process.stderr, ], - closeDescriptor: (fd: number) => void = closeSync, ): void { const failures: Error[] = [] const recordFailure = (error: unknown): void => { failures.push(error instanceof Error ? error : new Error(String(error))) } - const [stdin, ...outputs] = streams - const outputHandles = new Set(outputs.flatMap(stream => stream.destroyed || stream._handle == null - ? [] - : [stream._handle])) + + const handles: NativePtr[] = [] + for (const selector of [abi.STD_INPUT_HANDLE, abi.STD_OUTPUT_HANDLE, abi.STD_ERROR_HANDLE]) { + try { + const handle = api.getStdHandle(selector) + if (!isNullPtr(handle) && !handles.includes(handle)) handles.push(handle) + } catch (error) { + recordFailure(error) + } + } + for (const handle of handles) { + try { + closeHandleChecked(api, handle, 'runner standard handle') + } catch (error) { + recordFailure(error) + } + } + + const [stdin, stdout, stderr] = streams if (!stdin.destroyed) { try { stdin.destroy() @@ -523,16 +537,19 @@ export function closeCurrentProcessStandardStreams( recordFailure(error) } } - for (const fd of [0, 1, 2]) { - try { - closeDescriptor(fd) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EBADF') recordFailure(error) + for (const output of [stdout, stderr]) { + if (output.destroyed) continue + if (Object.hasOwn(output, '_destroy')) { + try { + if (!Reflect.deleteProperty(output, '_destroy')) { + throw new Error('deleting runner standard-stream destroy override failed') + } + } catch (error) { + recordFailure(error) + } } - } - for (const handle of outputHandles) { try { - handle.close() + output.destroy() } catch (error) { recordFailure(error) } diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 76ea688f9f..41008b1d7b 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -213,49 +213,78 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('closes each runner descriptor and materialized output handle', () => { - const closeDescriptor = vi.fn() + it('closes unique raw handles and restores output Socket destruction', () => { const input = { destroyed: false, destroy: vi.fn() } - const sharedHandle = { close: vi.fn() } - const output = { destroyed: false, destroy: vi.fn(), _handle: sharedHandle } - const alreadyClosed = { destroyed: true, destroy: vi.fn(), _handle: { close: vi.fn() } } + const socketDestroy = vi.fn() + const dummyDestroy = vi.fn() + const makeOutput = (): { + stream: { destroyed: boolean; destroy(): void; _destroy?: () => void } + destroy: ReturnType + } => { + const destroy = vi.fn(function (this: { _destroy(): void }) { this._destroy() }) + const stream = Object.assign(Object.create({ _destroy: socketDestroy }), { + destroyed: false, + destroy, + _destroy: dummyDestroy, + }) as { destroyed: boolean; destroy(): void; _destroy?: () => void } + return { stream, destroy } + } + const { stream: stdout, destroy: stdoutDestroy } = makeOutput() + const { stream: stderr, destroy: stderrDestroy } = makeOutput() + const closeHandle = vi.fn(() => 1) + const getStdHandle = vi.fn((selector: number) => + (selector === STD_INPUT_HANDLE ? 10n : 11n) as NativePtr) + const bindings = api({ + getStdHandle, + closeHandle, + }) expect(() => { - closeCurrentProcessStandardStreams([input, output, output], closeDescriptor) + closeCurrentProcessStandardStreams(bindings, [input, stdout, stderr]) }).not.toThrow() + expect(getStdHandle).toHaveBeenCalledTimes(3) + expect(closeHandle.mock.calls).toEqual([[10n], [11n]]) expect(input.destroy).toHaveBeenCalledOnce() - expect(output.destroy).not.toHaveBeenCalled() - expect(sharedHandle.close).toHaveBeenCalledOnce() - expect(closeDescriptor.mock.calls).toEqual([[0], [1], [2]]) - - expect(() => { - closeCurrentProcessStandardStreams([alreadyClosed, alreadyClosed, alreadyClosed], closeDescriptor) - }).not.toThrow() - expect(alreadyClosed.destroy).not.toHaveBeenCalled() - expect(alreadyClosed._handle.close).not.toHaveBeenCalled() + expect(stdoutDestroy).toHaveBeenCalledOnce() + expect(stderrDestroy).toHaveBeenCalledOnce() + expect(dummyDestroy).not.toHaveBeenCalled() + expect(socketDestroy).toHaveBeenCalledTimes(2) + expect(Object.hasOwn(stdout, '_destroy')).toBe(false) + expect(Object.hasOwn(stderr, '_destroy')).toBe(false) }) it('reports one or several runner standard-stream close failures', () => { const closed = { destroyed: true, destroy: vi.fn() } + let getStdHandleCalls = 0 + const getStdHandle = vi.fn(() => { + getStdHandleCalls += 1 + if (getStdHandleCalls === 1) throw new Error('single close failure') + return 0n as NativePtr + }) + const singleFailure = api({ getStdHandle }) expect(() => { - closeCurrentProcessStandardStreams([ - { destroyed: false, destroy: () => { throw new Error('single close failure') } }, + closeCurrentProcessStandardStreams(singleFailure, [ closed, closed, - ], vi.fn((fd: number) => { - if (fd === 0) throw Object.assign(new Error('already closed'), { code: 'EBADF' }) - })) + closed, + ]) }).toThrow('single close failure') + expect(getStdHandle).toHaveBeenCalledTimes(3) + const rawCloseFailure = api({ closeHandle: vi.fn(() => 0) }) + const nonConfigurableDestroy = vi.fn() + const nonConfigurable = { + destroyed: false, + destroy: nonConfigurableDestroy, + } as { destroyed: boolean; destroy(): void; _destroy?: unknown } + Object.defineProperty(nonConfigurable, '_destroy', { value: vi.fn(), configurable: false }) let failure: unknown try { - closeCurrentProcessStandardStreams([ - closed, - { destroyed: false, destroy: vi.fn(), _handle: { close: () => { throw 'raw close failure' } } }, - { destroyed: false, destroy: vi.fn(), _handle: { close: () => { throw new Error('second close failure') } } }, - ], (fd) => { - if (fd === 1) throw new Error('descriptor close failure') - }) + closeCurrentProcessStandardStreams(rawCloseFailure, [ + { destroyed: false, destroy: () => { throw 'input close failure' } }, + nonConfigurable, + { destroyed: false, destroy: () => { throw new Error('output close failure') } }, + ]) } catch (error) { failure = error } @@ -265,9 +294,11 @@ describe('ordinary Job process operations', () => { }) const errors = (failure as { errors: unknown }).errors expect(errors).toEqual(expect.arrayContaining([ - expect.objectContaining({ message: 'raw close failure' }), - expect.objectContaining({ message: 'second close failure' }), - expect.objectContaining({ message: 'descriptor close failure' }), + expect.objectContaining({ api: 'CloseHandle' }), + expect.objectContaining({ message: 'input close failure' }), + expect.objectContaining({ message: 'deleting runner standard-stream destroy override failed' }), + expect.objectContaining({ message: 'output close failure' }), ])) + expect(nonConfigurableDestroy).toHaveBeenCalledOnce() }) }) From a0bc2ea803c80976257df7498e62f6ce35db16c2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 22:34:09 +0800 Subject: [PATCH 078/110] fix(subprocess): preserve native runner launch semantics --- .../subprocess-local/src/spawn-runner.ts | 21 +++- .../subprocess/subprocess-local/src/spawn.ts | 21 ++-- .../tests/spawn-runner.spec.ts | 112 +++++++++++++++--- .../subprocess-local/tests/spawn.spec.ts | 35 ++++++ 4 files changed, 160 insertions(+), 29 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 096c783371..c33ba5570a 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -100,19 +100,33 @@ function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { }) } +function execLinuxFile( + file: string, + argv: string[], + env: Record, + internals: SpawnRunnerInternals, +): never { + try { + return internals.execve(file, argv, env) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOEXEC') throw error + return internals.execve('/bin/sh', ['/bin/sh', file, ...argv.slice(1)], env) + } +} + function execLinuxTarget( request: { cwd: string; env: Record }, argv: string[], internals: SpawnRunnerInternals, ): never { const program = argv[0] as string - if (program.includes('/')) return internals.execve(program, argv, request.env) + if (program.includes('/')) return execLinuxFile(program, argv, request.env, internals) const path = request.env.PATH ?? '/usr/bin:/bin' let permissionFailure: Error | undefined for (const directory of path.split(':')) { const candidate = posix.resolve(request.cwd, directory, program) try { - return internals.execve(candidate, argv, request.env) + return execLinuxFile(candidate, argv, request.env, internals) } catch (error) { const code = (error as NodeJS.ErrnoException).code if (code === 'EACCES') { @@ -143,7 +157,7 @@ function runLinux( } try { host.chdir(request.cwd) - execLinuxTarget(request, argv, internals) + execLinuxTarget({ ...request, cwd: host.cwd() }, argv, internals) } catch (error) { writeLinuxStartupError(files, { type: 'spawn-error', @@ -263,7 +277,6 @@ class WindowsJobRunner { this.internals.closeCurrentProcessStandardStreams(this.api) if (this.startCancellationPending()) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) - this.poll() } catch (error) { if (!this.committed && error instanceof Win32Error && error.api === 'CreateProcessW') { await this.publishTerminalResult({ diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index deaf77d2e6..5a3772c3a8 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -485,10 +485,19 @@ export function bindManagedProcess( let rangeExitObservation: Promise | undefined let settled = false + const scheduleOwnerCleanup = (): boolean => { + if (launch.owner.cleanup === undefined) return false + queueMicrotask(() => { void done.finally(() => { launch.owner.cleanup?.() }).catch(() => {}) }) + return true + } + /** - * Start or reuse the handle's managed-range exit observer. A failed read can - * be retried; the first confirmed absence is the permanent no-more-signals - * boundary and cancels pending escalation before stale identity can be used. + * Start or reuse the handle's managed-range exit observer. A failed read + * before direct settlement can be retried. Once direct settlement permits + * cleanup, retain a failed observation because removing its private evidence + * must not turn a later wait into a false success. The first confirmed + * absence is the permanent no-more-signals boundary and cancels pending + * escalation before stale identity can be used. */ const observeRangeExit = (): Promise => { rangeExitObservation ??= (async () => { @@ -497,11 +506,9 @@ export function bindManagedProcess( if (graceTimer !== undefined) clearTimeout(graceTimer) graceTimer = undefined spec.signal?.removeEventListener('abort', onAbort) - if (launch.owner.cleanup !== undefined) { - queueMicrotask(() => { void done.finally(() => { launch.owner.cleanup?.() }).catch(() => {}) }) - } + scheduleOwnerCleanup() })().catch((error: unknown) => { - rangeExitObservation = undefined + if (!settled || !scheduleOwnerCleanup()) rangeExitObservation = undefined throw error }) return rangeExitObservation diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 187682dea1..0109d0e0be 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -11,7 +11,7 @@ import { writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, posix } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' @@ -69,7 +69,7 @@ class FakeRunnerHost extends EventEmitter { sendThrown: unknown cwd(): string { return this.directory } - chdir(path: string): void { this.directory = path } + chdir(path: string): void { this.directory = posix.resolve(this.directory, path) } disconnect(): void { if (!this.connected) return this.connected = false @@ -405,6 +405,39 @@ describe('Linux one-shot exec bootstrap', () => { }) }) + it('resolves relative PATH entries from the cwd after chdir', async () => { + const files = track(createLinuxLaunchFiles({ cwd: 'work', env: { PATH: 'bin:' } })) + const host = new FakeRunnerHost() + host.directory = '/base' + const execve = vi.fn(() => { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) }) + await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(host), internals({ execve })) + expect(host.directory).toBe('/base/work') + expect(execve.mock.calls.map(call => call[0])).toEqual([ + '/base/work/bin/tool', + '/base/work/tool', + ]) + }) + + it('retries ENOEXEC through /bin/sh with the resolved file and original arguments', async () => { + const files = track(createLinuxLaunchFiles({ cwd: '/work', env: { PATH: 'bin' } })) + const execve = vi.fn() + .mockImplementationOnce(() => { throw Object.assign(new Error('exec format'), { code: 'ENOEXEC' }) }) + .mockImplementationOnce(() => { throw Object.assign(new Error('shell failed'), { code: 'EIO' }) }) + await runSpawnRunner( + files.requestPath, + ['--', 'tool', 'literal arg'], + hostArgument(new FakeRunnerHost()), + internals({ execve: execve as never }), + ) + expect(execve.mock.calls).toEqual([ + ['/work/bin/tool', ['tool', 'literal arg'], { PATH: 'bin' }], + ['/bin/sh', ['/bin/sh', '/work/bin/tool', 'literal arg'], { PATH: 'bin' }], + ]) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ + type: 'spawn-error', error: { code: 'EIO', path: 'tool' }, + }) + }) + it('uses the default PATH and stops on a non-search error', async () => { const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) const execve = vi.fn((_file: string) => { throw Object.assign(new Error('denied'), { code: 'EACCES' }) }) @@ -513,6 +546,39 @@ describe('Windows Job runner protocol owner', () => { expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' }) }) + it('lets asynchronous runner stdio close before the first Windows poll', async () => { + let tick: (() => void) | undefined + const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => { + tick = callback + return 1 as unknown as ReturnType + }) + try { + const events: string[] = [] + let closeComplete = false + const host = new FakeRunnerHost() + const native = internals({ + closeCurrentProcessStandardStreams: vi.fn(() => { + events.push('close-start') + queueMicrotask(() => { + closeComplete = true + events.push('close-complete') + tick?.() + }) + }), + pollProcessExit: vi.fn(() => { + events.push(`poll:${String(closeComplete)}`) + return 0 + }), + }) + await runWindows(host, native) + expect(events).toEqual(['close-start', 'close-complete', 'poll:true']) + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(host.exitCode).toBe(0) + } finally { + interval.mockRestore() + } + }) + it('exhausts spawn-error, runner-error, and payload-free start-cancelled', async () => { const spawnHost = new FakeRunnerHost() await runWindows(spawnHost, internals({ @@ -691,22 +757,32 @@ describe('Windows Job runner protocol owner', () => { }) it('cleans a direct handle after the Job identity was already cleared', async () => { - const host = new FakeRunnerHost() - const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => true) }) - const running = runSpawnRunner( - WINDOWS_RUNNER_SELECTION, - ['--', 'tool.exe'], - hostArgument(host), - native, - ) - host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) - await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) - host.emit('message', { type: 'terminate' }) - host.disconnect() - await running - expect(native.closeHandleChecked).toHaveBeenCalledWith( - expect.anything(), 10n, 'ordinary direct process cleanup', - ) + let tick: (() => void) | undefined + const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => { + tick = callback + return 1 as unknown as ReturnType + }) + try { + const host = new FakeRunnerHost() + const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => true) }) + const running = runSpawnRunner( + WINDOWS_RUNNER_SELECTION, + ['--', 'tool.exe'], + hostArgument(host), + native, + ) + host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) + await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) + tick?.() + host.emit('message', { type: 'terminate' }) + host.disconnect() + await running + expect(native.closeHandleChecked).toHaveBeenCalledWith( + expect.anything(), 10n, 'ordinary direct process cleanup', + ) + } finally { + interval.mockRestore() + } }) it('fails closed for malformed or duplicate start messages and disconnected reporting', async () => { diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index e839201d65..9d78af0b4b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -814,6 +814,41 @@ describe('coverage seams', () => { 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 () => { + const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() + const earlyFailure = new Error('temporary range read failure') + const terminalFailure = new Error('scope ended before launch request consumption') + const waitForExit = vi.fn() + .mockRejectedValueOnce(earlyFailure) + .mockRejectedValueOnce(terminalFailure) + const cleanup = vi.fn() + const handle = bindManagedProcess(spec('true', { + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }), { + stdin: null, + stdout: null, + stderr: null, + direct: direct.promise, + owner: { + signal: vi.fn(), + waitForExit, + terminateForHostExit: vi.fn(), + cleanup, + }, + }) + + await expect(handle.waitForExit()).rejects.toBe(earlyFailure) + expect(cleanup).not.toHaveBeenCalled() + + direct.resolve({ exitCode: 0, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await expect(handle.waitForExit()).rejects.toBe(terminalFailure) + await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() }) + + await expect(handle.waitForExit()).rejects.toBe(terminalFailure) + expect(waitForExit).toHaveBeenCalledTimes(2) + }) + it('does not deliver a stale escalation after range exit wins the timer race', async () => { vi.useFakeTimers() const clearTimer = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}) From 635c35991ce18ac09c4237e2edcfd4396ee48c11 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 28 Aug 2026 22:38:18 +0800 Subject: [PATCH 079/110] test(subprocess): type Linux exec mock --- .../subprocess/subprocess-local/tests/spawn-runner.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 0109d0e0be..0c35bcfb33 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -409,7 +409,9 @@ describe('Linux one-shot exec bootstrap', () => { const files = track(createLinuxLaunchFiles({ cwd: 'work', env: { PATH: 'bin:' } })) const host = new FakeRunnerHost() host.directory = '/base' - const execve = vi.fn(() => { throw Object.assign(new Error('not found'), { code: 'ENOENT' }) }) + const execve = vi.fn((_file: string, _argv: string[], _env: Record): never => { + throw Object.assign(new Error('not found'), { code: 'ENOENT' }) + }) await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(host), internals({ execve })) expect(host.directory).toBe('/base/work') expect(execve.mock.calls.map(call => call[0])).toEqual([ From 40279aea1708ac3d06e7744814050cbe7971fa64 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 00:03:12 +0800 Subject: [PATCH 080/110] fix(subprocess): preserve native runner stdio --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 8 +- ...-08-28-subprocess-native-containment.zh.md | 8 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 5 +- .../subprocess/subprocess-local/README.zh.md | 5 +- .../subprocess-local/src/linux-execve.ts | 71 ++++++++ .../subprocess-local/src/linux-scope.ts | 9 +- .../subprocess-local/src/runner-launch.ts | 19 ++- .../subprocess-local/src/spawn-runner.ts | 23 ++- .../subprocess-local/src/windows-job.ts | 14 +- .../tests/linux-execve.spec.ts | 104 +++++++++++ .../tests/linux-scope.spec.ts | 19 ++- .../tests/spawn-runner.spec.ts | 53 +++--- .../tests/windows-job.spec.ts | 14 +- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 6 +- .../subprocess/win32-process/README.zh.md | 6 +- packages/subprocess/win32-process/src/ffi.ts | 23 ++- .../subprocess/win32-process/src/index.ts | 3 +- .../subprocess/win32-process/src/process.ts | 161 +++++++----------- .../tests/ordinary-process.spec.ts | 137 ++++----------- 22 files changed, 404 insertions(+), 296 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/linux-execve.ts create mode 100644 packages/subprocess/subprocess-local/tests/linux-execve.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 1f1ab7d4de..58cf47ce3e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: e323bb98658a1cfc8373283e0e95fbd7f752afad -2026-08-28-subprocess-native-containment.zh.md: fc60c3d449974e7558013b4afd95d31d751f8689 +2026-08-28-subprocess-native-containment.md: c4fad5a11be450a382a2fc66dbae81e30a0e84c8 +2026-08-28-subprocess-native-containment.zh.md: c8185e0e90a69440c2441fc95258261bb091300b diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index e323bb9865..c4fad5a11b 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -18,9 +18,9 @@ An ordinary `SubprocessHandle` has no PID or public startup state. `.done` repor ### Linux scope and one-shot bootstrap -Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, `process.execve()`, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback. +Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback. -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, and calls `execve()` with the original argv. The bootstrap becomes the target in place; it does not remain as a supervisor. +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 the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. After establishment, an inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. 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. @@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. The runner resolves a separate `CreateProcessW` application path with Node's target-cwd/PATH search order while preserving the original argv entry. Target stdin, stdout, and stderr remain real Node-created standard handles: the runner temporarily makes its inherited handles inheritable, creates the target with `STARTF_USESTDHANDLES`, and then destroys its own Node/libuv standard streams so only target copies keep parent pipes open. User bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. The shared Win32 layer resolves those CRT descriptors through UCRT `_get_osfhandle`, passes the resulting OS handles through `STARTF_USESTDHANDLES`, and preserves a separately resolved `CreateProcessW` application path without changing the original argv entry. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. @@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification -- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, stdio settlement, active-process quiescence, and unique handle cleanup. +- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, isolated carrier-descriptor closure, 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index fc60c3d449..c8185e0e90 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -18,9 +18,9 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只 ### Linux scope 与 one-shot bootstrap -每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、`process.execve()`、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。 +每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。 -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 规则解析裸可执行文件,并使用原始 argv 调用 `execve()`。bootstrap 会原地成为 target,不作为常驻 supervisor。 +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 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。建立之后,inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 @@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 按 Node 的 target-cwd/PATH 搜索顺序解析独立的 `CreateProcessW` application path,同时保留原始 argv 项。target stdin、stdout 与 stderr 继续使用 Node 创建的真实标准句柄:runner 临时把继承的句柄设为可继承,通过 `STARTF_USESTDHANDLES` 原样传递,随后销毁自己持有的 Node/libuv 标准流,使 parent pipe 只由 target 副本保持打开。用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。共享 Win32 层通过 UCRT `_get_osfhandle` 解析这些 CRT 描述符,经 `STARTF_USESTDHANDLES` 传递对应 OS handle,并保留单独解析的 `CreateProcessW` application path,而不改变原始 argv 项。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 @@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification -- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、stdio settlement、active-process 完全停稳,以及唯一 handle cleanup。 +- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、隔离 carrier 描述符关闭、stdio settlement、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 保留。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 6c586d7e32..588493cc45 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: eb38e2f45bad8e4b985177c656e5cb2762a86654 -README.zh.md: e7fb4d083bc52c27e39750486d98a41b404121dc +README.md: 4fcc0812819f2960d9cacd3ef07b1e47da4126e1 +README.zh.md: c5b36637dca64587981aa6a349c9bf85cfb6acba diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index eb38e2f45b..4fcc081281 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -78,6 +78,7 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux | [`src/spawn.ts`](src/spawn.ts) | Shared process plumbing: direct outcomes, tail-keep collection, spill files, and fallback spawning | | [`src/managed-owner.ts`](src/managed-owner.ts) | Private signal-and-wait owner used by each ordinary handle | | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd capability checks, scope launch, signalling, and quiescence | +| [`src/linux-execve.ts`](src/linux-execve.ts) | Linux libc image replacement and inherited-standard-descriptor preservation | | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job capability checks and helper launch | | [`src/runner-launch.ts`](src/runner-launch.ts) | Source, built, and packaged private-runner selection | | [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux one-shot exec bootstrap and Windows Job runner | @@ -89,7 +90,7 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux ### Main flow -A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment before replacing itself with the target. Windows ordinary launches use one IPC channel for the start request, termination, and strict direct result; the runner creates the target suspended, assigns it to the Job, and only then resumes it. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty. +A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment, resolves the executable, clears close-on-exec on fd 0 through fd 2, and enters libc `execve()` with the original argv. Windows ordinary launches isolate runner fd 0 through fd 2, reserve fd 3 for IPC, and carry target stdio on fd 4 through fd 6; the runner resolves those CRT descriptors to OS handles, creates the target suspended, assigns it to the Job, resumes it, and closes only the carrier descriptors. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty. ### Safety invariants @@ -129,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. +- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index e7fb4d083b..c5b36637dc 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -78,6 +78,7 @@ kind: "package-reference" | [`src/spawn.ts`](src/spawn.ts) | 共享进程管道:直接结果、保尾收集、spill 文件与 fallback spawn | | [`src/managed-owner.ts`](src/managed-owner.ts) | 每个普通句柄使用的私有信号与等待 owner | | [`src/linux-scope.ts`](src/linux-scope.ts) | Linux user-systemd 能力检查、scope 启动、信号发送与完全停稳 | +| [`src/linux-execve.ts`](src/linux-execve.ts) | Linux libc 进程映像替换与继承标准文件描述符保留 | | [`src/windows-job.ts`](src/windows-job.ts) | Windows Job 能力检查与 helper 启动 | | [`src/runner-launch.ts`](src/runner-launch.ts) | source、built 与 packaged 私有 runner 选择 | | [`src/spawn-runner.ts`](src/spawn-runner.ts) | Linux 一次性 exec bootstrap 与 Windows Job runner | @@ -89,7 +90,7 @@ kind: "package-reference" ### 主流程 -一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境,再用目标程序替换自身。Windows 普通命令使用同一条 IPC 通道传递启动请求、终止命令与严格的直接结果;runner 以 suspended 状态创建目标,将其加入 Job 后才恢复运行。`done` 会在直接命令及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。 +一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境、解析可执行文件、清除 fd 0 至 fd 2 的 close-on-exec 标记,再以原始 argv 进入 libc `execve()`。Windows 普通命令会隔离 runner 的 fd 0 至 fd 2、把 fd 3 留给 IPC,并用 fd 4 至 fd 6 承载 target stdio;runner 把这些 CRT 描述符解析成 OS handle,以 suspended 状态创建 target,将其加入 Job、恢复运行,再只关闭 carrier 描述符。`done` 会在 direct command 及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察 session 变空。 ### 安全不变式 @@ -129,7 +130,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。 - **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 +- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 - **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。 - **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。 diff --git a/packages/subprocess/subprocess-local/src/linux-execve.ts b/packages/subprocess/subprocess-local/src/linux-execve.ts new file mode 100644 index 0000000000..b3b40ba64b --- /dev/null +++ b/packages/subprocess/subprocess-local/src/linux-execve.ts @@ -0,0 +1,71 @@ +/** Lazy libc execve and descriptor bindings used by the one-shot Linux bootstrap. */ + +import { getSystemErrorMessage, getSystemErrorName } from 'node:util' +import koffi from 'koffi' + +/** Replace the current process image while preserving the supplied argv and environment. */ +export type LinuxExecve = ( + file: string, + argv: string[], + env: Record, +) => never + +type NativeExecve = ( + file: string, + argv: Array, + envp: Array, +) => number + +type NativeFcntl = (fd: number, command: number, argument: number) => number + +const STANDARD_FILE_DESCRIPTORS = [0, 1, 2] as const +const F_GETFD = 1 +const F_SETFD = 2 +const FD_CLOEXEC = 1 + +let cachedExecve: LinuxExecve | undefined + +function systemError(errno: number, syscall: string, path?: string): Error { + const uvError = -errno + const code = getSystemErrorName(uvError) + const detail = getSystemErrorMessage(uvError) + const subject = path === undefined ? syscall : `${syscall} '${path}'` + const error = Object.assign(new Error(`${code}: ${detail}, ${subject}`), { + code, + errno, + syscall, + }) + return path === undefined ? error : Object.assign(error, { path }) +} + +/** + * Load libc's execve and fcntl symbols on first use and retain the native bindings. + * @returns a process-replacing execve operation that throws Node-style errors on failure. + */ +export function loadLinuxExecve(): LinuxExecve { + if (cachedExecve !== undefined) return cachedExecve + const libc = koffi.load(null) + const nativeExecve = libc.func( + 'int execve(const char *pathname, const char **argv, const char **envp)', + ) as NativeExecve + const nativeFcntl = libc.func( + 'int fcntl(int fd, int cmd, int arg)', + ) as NativeFcntl + cachedExecve = (file, argv, env) => { + for (const fd of STANDARD_FILE_DESCRIPTORS) { + const flags = nativeFcntl(fd, F_GETFD, 0) + if (flags === -1) throw systemError(koffi.errno(), 'fcntl') + if ((flags & FD_CLOEXEC) === 0) continue + if (nativeFcntl(fd, F_SETFD, flags & ~FD_CLOEXEC) === -1) { + throw systemError(koffi.errno(), 'fcntl') + } + } + nativeExecve( + file, + [...argv, null], + [...Object.entries(env).map(([key, value]) => `${key}=${value}`), null], + ) + throw systemError(koffi.errno(), 'execve', file) + } + return cachedExecve +} diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index b53a38cad9..92d7841aa6 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -9,6 +9,7 @@ import type { SubprocessSpawnSpec, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +import { loadLinuxExecve } from './linux-execve.ts' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { cleanupLinuxLaunchFiles, @@ -36,7 +37,7 @@ export interface LinuxScopeInternals { runnerInvocation?: RunnerInvocation resolveRunnerInvocation?: () => RunnerInvocation runnerAvailable?: (invocation: RunnerInvocation) => boolean - execveAvailable?: boolean + loadLinuxExecve?: typeof loadLinuxExecve } interface SystemctlResult { @@ -91,13 +92,13 @@ export function probeLinuxUserManager(internals: LinuxScopeInternals = {}): bool } /** - * Confirm this exact runner entry and Node execve support without a probe mode. - * @param internals - optional runner and execve seams used by tests. + * Confirm this exact runner entry and libc execve binding without a probe mode. + * @param internals - optional runner and libc-binding seams used by tests. * @returns whether the bootstrap can enter the final target. */ export function probeLinuxBootstrap(internals: LinuxScopeInternals = {}): boolean { - if (!(internals.execveAvailable ?? typeof process.execve === 'function')) return false try { + ;(internals.loadLinuxExecve ?? loadLinuxExecve)() const invocation = internals.runnerInvocation ?? (internals.resolveRunnerInvocation ?? spawnRunnerInvocation)() return (internals.runnerAvailable ?? runnerInvocationAvailable)(invocation) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 96ab71cf96..94576a1a0d 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -90,22 +90,31 @@ export function parseRunnerTargetArgv(argv: readonly string[]): string[] { } /** - * Build the stdio inherited unchanged by the target, optionally with Node IPC on fd 3. + * Build direct Linux target stdio, or isolated Windows runner stdio with IPC + * 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 append the private Node IPC descriptor. + * @param ipc - whether to isolate the runner and add its private Node IPC descriptor. * @returns child-process stdio options for the runner. */ export function runnerStdio( spec: SubprocessSpawnSpec, ipc: boolean, ): StdioOptions { - const stdio: StdioOptions = [ + const targetStdio: StdioOptions = [ spec.stdio.stdin === 'ignore' ? 'ignore' : 'pipe', spec.stdio.stdout === 'inherit' ? 'inherit' : 'pipe', spec.stdio.stderr === 'inherit' ? 'inherit' : 'pipe', ] - if (ipc) (stdio as Array).push('ipc') - return stdio + if (!ipc) return targetStdio + return [ + 'ignore', + 'ignore', + 'ignore', + 'ipc', + targetStdio[0], + spec.stdio.stdout === 'inherit' ? 1 : 'pipe', + spec.stdio.stderr === 'inherit' ? 2 : 'pipe', + ] } function windowsEnvironmentValue( diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index c33ba5570a..1ec4f04dd6 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,8 +1,8 @@ /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */ +import { closeSync } from 'node:fs' import { posix } from 'node:path' import { - closeCurrentProcessStandardStreams, closeHandleChecked, isJobEmpty, loadWin32ProcessBindings, @@ -11,7 +11,11 @@ import { terminateJob, Win32Error, } from '@deepseek-ai/dsh-win32-process' -import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' +import type { + CurrentTokenProcessBindings, + NativePtr, +} from '@deepseek-ai/dsh-win32-process' +import { loadLinuxExecve } from './linux-execve.ts' import { consumeLinuxLaunchRequest, isWindowsTerminateRequest, @@ -40,9 +44,9 @@ type RunnerHost = Pick): never - loadWin32ProcessBindings(): Win32ProcessBindings + loadWin32ProcessBindings(): CurrentTokenProcessBindings spawnCurrentTokenJobProcess: typeof spawnCurrentTokenJobProcess - closeCurrentProcessStandardStreams: typeof closeCurrentProcessStandardStreams + closeFileDescriptor(fileDescriptor: number): void resolveWindowsExecutable: typeof resolveWindowsExecutable pollProcessExit: typeof pollProcessExit isJobEmpty: typeof isJobEmpty @@ -52,10 +56,10 @@ export interface SpawnRunnerInternals { const defaultInternals: SpawnRunnerInternals = { /* v8 ignore next -- source/built/packaged subprocess smoke executes this only in a replaceable child process. */ - execve: (file, argv, env) => (process.execve as NonNullable)(file, argv, env), + execve: (file, argv, env) => loadLinuxExecve()(file, argv, env), loadWin32ProcessBindings, spawnCurrentTokenJobProcess, - closeCurrentProcessStandardStreams, + closeFileDescriptor: closeSync, resolveWindowsExecutable, pollProcessExit, isJobEmpty, @@ -187,7 +191,7 @@ function sendMessage(host: RunnerHost, result: WindowsRunnerResult): Promise | undefined @@ -270,11 +274,14 @@ class WindowsJobRunner { applicationName, args, cwd: request.cwd, + stdio: { stdin: 4, stdout: 5, stderr: 6 }, }) this.processHandle = spawned.process this.jobHandle = spawned.job this.committed = true - this.internals.closeCurrentProcessStandardStreams(this.api) + for (const fileDescriptor of [4, 5, 6]) { + this.internals.closeFileDescriptor(fileDescriptor) + } if (this.startCancellationPending()) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) } catch (error) { diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index db6c1094b7..845fb7e43a 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -1,6 +1,7 @@ /** Windows parent-side launch and ownership for the private Job runner. */ import { spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' import type { SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { loadWin32ProcessBindings, @@ -31,8 +32,9 @@ export interface WindowsJobInternals { probeCurrentTokenJobSupport?: typeof probeCurrentTokenJobSupport } -type RunnerProcess = Omit, 'send'> & { +type RunnerProcess = Omit, 'send' | 'stdio'> & { send?: ReturnType['send'] + stdio: Array } /** @@ -108,7 +110,7 @@ export function launchWindowsJob( ): ManagedProcessLaunch { const invocation = internals.runnerInvocation ?? spawnRunnerInvocation() const [command, ...prefix] = invocation - const child: RunnerProcess = (internals.spawn ?? spawn)(command, [ + const child = (internals.spawn ?? spawn)(command, [ ...prefix, '--', ...spec.argv, @@ -116,7 +118,7 @@ export function launchWindowsJob( cwd: process.cwd(), env: runnerEnvironment(WINDOWS_RUNNER_SELECTION), stdio: runnerStdio(spec, true), - }) + }) as RunnerProcess const direct = Promise.withResolvers() const infrastructure = Promise.withResolvers() @@ -199,9 +201,9 @@ export function launchWindowsJob( } return { - stdin: child.stdin, - stdout: child.stdout, - stderr: child.stderr, + stdin: child.stdio[4] as Writable | null, + stdout: child.stdio[5] as Readable | null, + stderr: child.stdio[6] as Readable | null, direct: direct.promise, owner, infrastructureFailure: infrastructure.promise, diff --git a/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts b/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts new file mode 100644 index 0000000000..1b29fa0e09 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.doUnmock('koffi') + vi.resetModules() +}) + +describe('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) => { + if (command === 2) return 0 + return [1, 0, 5][fd] + }) + const func = vi.fn((declaration: string) => declaration.includes('execve') + ? nativeExecve + : nativeFcntl) + const load = vi.fn(() => ({ func })) + const errno = vi.fn(() => 2) + vi.doMock('koffi', () => ({ default: { errno, load } })) + + const { loadLinuxExecve } = await import('../src/linux-execve.ts') + const execve = loadLinuxExecve() + expect(loadLinuxExecve()).toBe(execve) + expect(load).toHaveBeenCalledExactlyOnceWith(null) + expect(func.mock.calls).toEqual([ + ['int execve(const char *pathname, const char **argv, const char **envp)'], + ['int fcntl(int fd, int cmd, int arg)'], + ]) + + let failure: unknown + try { + execve('/missing/tool', ['tool', 'literal arg'], { A: '1', EMPTY: '' }) + } catch (error) { + failure = error + } + expect(nativeFcntl.mock.calls).toEqual([ + [0, 1, 0], + [0, 2, 0], + [1, 1, 0], + [2, 1, 0], + [2, 2, 4], + ]) + expect(nativeExecve).toHaveBeenCalledExactlyOnceWith( + '/missing/tool', + ['tool', 'literal arg', null], + ['A=1', 'EMPTY=', null], + ) + expect(errno).toHaveBeenCalledOnce() + expect(failure).toMatchObject({ + code: 'ENOENT', + errno: 2, + syscall: 'execve', + path: '/missing/tool', + }) + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toContain("ENOENT: no such file or directory, execve '/missing/tool'") + }) + + it('reports failure to read descriptor flags before replacing the process', async () => { + const nativeExecve = vi.fn() + const nativeFcntl = vi.fn(() => -1) + const func = vi.fn((declaration: string) => declaration.includes('execve') + ? nativeExecve + : nativeFcntl) + const errno = vi.fn(() => 9) + vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } })) + + const { loadLinuxExecve } = await import('../src/linux-execve.ts') + expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({ + code: 'EBADF', + errno: 9, + syscall: 'fcntl', + })) + expect(nativeFcntl).toHaveBeenCalledExactlyOnceWith(0, 1, 0) + expect(nativeExecve).not.toHaveBeenCalled() + expect(errno).toHaveBeenCalledOnce() + }) + + it('reports failure to clear close-on-exec before replacing the process', async () => { + const nativeExecve = vi.fn() + const nativeFcntl = vi.fn() + .mockReturnValueOnce(1) + .mockReturnValueOnce(-1) + const func = vi.fn((declaration: string) => declaration.includes('execve') + ? nativeExecve + : nativeFcntl) + const errno = vi.fn(() => 5) + vi.doMock('koffi', () => ({ default: { errno, load: () => ({ func }) } })) + + const { loadLinuxExecve } = await import('../src/linux-execve.ts') + expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({ + code: 'EIO', + errno: 5, + syscall: 'fcntl', + })) + expect(nativeFcntl.mock.calls).toEqual([ + [0, 1, 0], + [0, 2, 0], + ]) + expect(nativeExecve).not.toHaveBeenCalled() + expect(errno).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 356b4f5e9c..091f55a4b6 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -105,7 +105,7 @@ function launch( systemctl: overrides.systemctl ?? '/bin/systemctl', runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'], ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable }, - ...overrides.execveAvailable === undefined ? {} : { execveAvailable: overrides.execveAvailable }, + ...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve }, }) const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV] if (requestPath === undefined) throw new Error('launch did not publish a request locator') @@ -117,19 +117,24 @@ describe('Linux native capability selection', () => { it('rechecks bootstrap, user manager, and literal transient-scope support', () => { const spawnSync = vi.fn(() => ({ status: 0, error: undefined })) const runnerAvailable = vi.fn(() => true) + const loadLinuxExecve = vi.fn(() => vi.fn() as never) const inputs = { spawnSync: spawnSync as never, runnerAvailable, runnerInvocation: ['/usr/bin/node', '/runner.js'] as [string, ...string[]], - execveAvailable: true, + loadLinuxExecve, systemdRun: '/bin/systemd-run', systemctl: '/bin/systemctl', } expect(probeLinuxNative(inputs)).toBe(true) expect(probeLinuxNative(inputs)).toBe(true) expect(runnerAvailable).toHaveBeenCalledTimes(2) + expect(loadLinuxExecve).toHaveBeenCalledTimes(2) expect(spawnSync).toHaveBeenCalledTimes(4) - expect(probeLinuxBootstrap({ ...inputs, execveAvailable: false })).toBe(false) + expect(probeLinuxBootstrap({ + ...inputs, + loadLinuxExecve: () => { throw new Error('libc execve missing') }, + })).toBe(false) }) it('reports each failed dynamic prerequisite without executing a target', () => { @@ -140,12 +145,12 @@ describe('Linux native capability selection', () => { spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never, })).toBe(false) expect(probeLinuxBootstrap({ - execveAvailable: true, + loadLinuxExecve: () => vi.fn() as never, runnerInvocation: ['/missing'], runnerAvailable: () => false, })).toBe(false) expect(probeLinuxBootstrap({ - execveAvailable: true, + loadLinuxExecve: () => vi.fn() as never, resolveRunnerInvocation: () => { throw new Error('runner resolution failed') }, })).toBe(false) }) @@ -155,11 +160,11 @@ describe('Linux native capability selection', () => { expect(probeLinuxUserManager()).toBe(true) expect(probeLinuxScope()).toBe(true) expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2) - expect(probeLinuxBootstrap({ execveAvailable: true })).toBe(true) + expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true) expect(probeLinuxBootstrap({ runnerInvocation: [process.execPath], runnerAvailable: () => true, - })).toBe(typeof process.execve === 'function') + })).toBe(process.platform !== 'win32') }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 0c35bcfb33..1a3a0681a9 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -14,7 +14,10 @@ import { tmpdir } from 'node:os' import { join, posix } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Win32Error } from '@deepseek-ai/dsh-win32-process' -import type { NativePtr, Win32ProcessBindings } from '@deepseek-ai/dsh-win32-process' +import type { + CurrentTokenProcessBindings, + NativePtr, +} from '@deepseek-ai/dsh-win32-process' import { cleanupLinuxLaunchFiles, consumeLinuxLaunchRequest, @@ -90,13 +93,13 @@ function hostArgument(host: FakeRunnerHost): Parameters[2 function internals(overrides: Partial = {}): SpawnRunnerInternals { return { execve: vi.fn(() => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }) }), - loadWin32ProcessBindings: vi.fn(() => ({} as Win32ProcessBindings)), + loadWin32ProcessBindings: vi.fn(() => ({} as CurrentTokenProcessBindings)), spawnCurrentTokenJobProcess: vi.fn(() => ({ pid: 123, process: 10n as NativePtr, job: 20n as NativePtr, })), - closeCurrentProcessStandardStreams: vi.fn(), + closeFileDescriptor: vi.fn(), resolveWindowsExecutable: vi.fn(() => 'C:\\resolved\\tool.exe'), pollProcessExit: vi.fn(() => 0), isJobEmpty: vi.fn(() => true), @@ -249,11 +252,17 @@ describe('runner launch inputs', () => { expect(parseRunnerTargetArgv(['--', 'node', 'a'])).toEqual(['node', 'a']) expect(() => parseRunnerTargetArgv(['node'])).toThrow('private -- delimiter') expect(runnerStdio(spec, false)).toEqual(['pipe', 'pipe', 'inherit']) - expect(runnerStdio(spec, true)).toEqual(['pipe', 'pipe', 'inherit', 'ipc']) + expect(runnerStdio(spec, true)).toEqual([ + 'ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2, + ]) expect(runnerStdio({ ...spec, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' }, }, false)).toEqual(['ignore', 'inherit', 'pipe']) + expect(runnerStdio({ + ...spec, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' }, + }, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'ignore', 1, 'pipe']) }) it('validates every Node-baseline NUL location before launch', () => { @@ -527,7 +536,8 @@ describe('Windows Job runner protocol owner', () => { it('sends target-exit only after suspended Job launch and closes runner stdio', async () => { const host = new FakeRunnerHost() - const native = internals() + const closeFileDescriptor = vi.fn() + const native = internals({ closeFileDescriptor }) await runWindows(host, native) expect(native.resolveWindowsExecutable).toHaveBeenCalledWith( 'tool.exe', @@ -538,9 +548,12 @@ describe('Windows Job runner protocol owner', () => { ) expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), { command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target', + stdio: { stdin: 4, stdout: 5, stderr: 6 }, }) - expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledTimes(1) - expect(native.closeCurrentProcessStandardStreams).toHaveBeenCalledWith(expect.anything()) + expect(closeFileDescriptor).toHaveBeenCalledTimes(3) + expect(closeFileDescriptor).toHaveBeenNthCalledWith(1, 4) + expect(closeFileDescriptor).toHaveBeenNthCalledWith(2, 5) + expect(closeFileDescriptor).toHaveBeenNthCalledWith(3, 6) expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process') expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job') expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) @@ -548,32 +561,24 @@ describe('Windows Job runner protocol owner', () => { expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' }) }) - it('lets asynchronous runner stdio close before the first Windows poll', async () => { - let tick: (() => void) | undefined + it('closes every target carrier before the first Windows poll', async () => { + const events: string[] = [] const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => { - tick = callback + events.push('interval') + queueMicrotask(callback) return 1 as unknown as ReturnType }) try { - const events: string[] = [] - let closeComplete = false const host = new FakeRunnerHost() const native = internals({ - closeCurrentProcessStandardStreams: vi.fn(() => { - events.push('close-start') - queueMicrotask(() => { - closeComplete = true - events.push('close-complete') - tick?.() - }) - }), + closeFileDescriptor: vi.fn((fileDescriptor) => { events.push(`close:${String(fileDescriptor)}`) }), pollProcessExit: vi.fn(() => { - events.push(`poll:${String(closeComplete)}`) + events.push('poll') return 0 }), }) await runWindows(host, native) - expect(events).toEqual(['close-start', 'close-complete', 'poll:true']) + expect(events).toEqual(['close:4', 'close:5', 'close:6', 'interval', 'poll']) expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) expect(host.exitCode).toBe(0) } finally { @@ -671,8 +676,8 @@ describe('Windows Job runner protocol owner', () => { it('handles commit-time termination reentrancy and termination failure', async () => { const reentrantHost = new FakeRunnerHost() const reentrant = internals({ - closeCurrentProcessStandardStreams: vi.fn(() => { - reentrantHost.emit('message', { type: 'terminate' }) + closeFileDescriptor: vi.fn((fileDescriptor) => { + if (fileDescriptor === 4) reentrantHost.emit('message', { type: 'terminate' }) }), pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false), diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 49f4bc35d8..fc268d159c 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -13,6 +13,10 @@ class FakeChild extends EventEmitter { stdin = new PassThrough() stdout = new PassThrough() stderr = new PassThrough() + targetStdin = new PassThrough() + targetStdout = new PassThrough() + targetStderr = new PassThrough() + stdio = [null, null, null, null, this.targetStdin, this.targetStdout, this.targetStderr] sent: unknown[] = [] killed: NodeJS.Signals[] = [] sendError: Error | undefined @@ -116,18 +120,18 @@ describe('Windows Job capability', () => { }) describe('Windows parent runner contract', () => { - it('launches with real stdio plus IPC and sends cwd/env through the strict start message', () => { + it('isolates runner stdio, carries target stdio on fd 4 through fd 6, and sends cwd/env', () => { const { child, result, spawn } = launch() expect(spawn).toHaveBeenCalledWith('C:\\node.exe', [ 'C:\\runner.js', '--', 'tool.exe', 'literal arg', ], expect.objectContaining({ cwd: process.cwd(), - stdio: ['pipe', 'pipe', 'inherit', 'ipc'], + stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 2], })) expect(child.sent).toEqual([{ type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes' } }]) - expect(result.stdin).toBe(child.stdin) - expect(result.stdout).toBe(child.stdout) - expect(result.stderr).toBe(child.stderr) + expect(result.stdin).toBe(child.targetStdin) + expect(result.stdout).toBe(child.targetStdout) + expect(result.stderr).toBe(child.targetStderr) }) it('maps target-exit to direct outcome and clean close to range quiescence', async () => { diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index d997f83d41..de6dd4a638 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: a90b33e52fc97be4f70f718c8847e604137a683f -README.zh.md: 85b6a00313e6ec1a0afb731e8eff59b32f2d0431 +README.md: 1affe4e0ba0622be02edc6f06f1c6dd88b24a60c +README.zh.md: 6d908142cc6e721c40225773c14dcdd07e196e50 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index a90b33e52f..1affe4e0ba 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -24,11 +24,11 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll`, `advapi32.dll`, and `ucrtbase.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `spawnCurrentTokenJobProcess()` temporarily marks the runner's standard handles inheritable, passes those exact handles through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the same runner; `closeCurrentProcessStandardStreams()` then closes each unique raw standard handle and destroys the corresponding Node/libuv stream owner so target exit can produce EOF at the parent. +- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions.stdio` names three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` resolves their OS handles through UCRT `_get_osfhandle`, temporarily marks those handles inheritable, passes them through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the runner, which can close its carrier descriptors without touching Node's own standard streams. - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. @@ -52,7 +52,7 @@ The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their #### What the model sees -Nothing directly. The package exposes `Win32ProcessBindings` and process primitives to the sandbox and ordinary runner, which own all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. +Nothing directly. The package exposes `Win32ProcessBindings`, `CurrentTokenProcessBindings`, and process primitives to the sandbox and ordinary runner, which own all model-visible tools, output, and diagnostics; this package contributes no prompt text or tool schema. #### Token effect diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 85b6a00313..6d908142cc 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -24,11 +24,11 @@ kind: "package-library" ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll`、`advapi32.dll` 与 `ucrtbase.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `spawnCurrentTokenJobProcess()` 临时把 runner 的标准句柄设为可继承,通过 `STARTF_USESTDHANDLES` 传入这些准确句柄,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给同一个 runner;`closeCurrentProcessStandardStreams()` 随后关闭每个唯一的原始标准句柄,并销毁对应的 Node/libuv stream owner,使 target 退出可以在 parent 产生 EOF。 +- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions.stdio` 指定三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 通过 UCRT `_get_osfhandle` 解析对应 OS handle,临时把这些 handle 设为可继承,通过 `STARTF_USESTDHANDLES` 传入它们,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给 runner,后者可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 @@ -52,7 +52,7 @@ Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载 #### 模型看到什么 -没有直接内容。本包向 sandbox 与 ordinary runner 提供 `Win32ProcessBindings` 与进程原语;两者拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 +没有直接内容。本包向 sandbox 与 ordinary runner 提供 `Win32ProcessBindings`、`CurrentTokenProcessBindings` 与进程原语;两者拥有全部模型可见工具、输出与诊断,本包不贡献提示词或工具 schema。 #### Token 影响 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index b9977ade36..2664d9b130 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -18,6 +18,8 @@ export interface Win32BindingContext { readonly kernel32: ReturnType /** Token and security APIs. */ readonly advapi32: ReturnType + /** Universal CRT file-descriptor operations. */ + readonly ucrtbase: ReturnType /** Bind one stdcall function from a loaded Win32 library. */ readonly bind: ( library: ReturnType, @@ -120,6 +122,11 @@ export interface Win32ProcessBindings { getStdHandle(stdHandle: number): NativePtr } +/** Generic Win32 calls plus current-process CRT descriptor lookup. */ +export interface CurrentTokenProcessBindings extends Win32ProcessBindings { + getOsfHandle(fileDescriptor: number): number | bigint +} + /** Koffi STARTUPINFOW layout. */ export const STARTUPINFOW = koffi.struct('DSH_STARTUPINFOW', { cb: 'uint32', @@ -229,26 +236,27 @@ export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { } let cachedContext: Win32BindingContext | undefined -let cached: Win32ProcessBindings | undefined +let cached: CurrentTokenProcessBindings | undefined /* v8 ignore start -- exercised by native Windows ABI and sandbox jobs. */ function bindingContext(): Win32BindingContext { if (cachedContext !== undefined) return cachedContext const kernel32 = koffi.load('kernel32.dll') const advapi32 = koffi.load('advapi32.dll') + const ucrtbase = koffi.load('ucrtbase.dll') const bind = ( lib: ReturnType, name: string, result: Ptr | string, args: Array, ): unknown => lib.func('__stdcall', name, result, args) - cachedContext = { kernel32, advapi32, bind } + cachedContext = { kernel32, advapi32, ucrtbase, bind } return cachedContext } -function bindings(): Win32ProcessBindings { +function bindings(): CurrentTokenProcessBindings { if (cached !== undefined) return cached - const { kernel32, advapi32, bind } = bindingContext() + const { kernel32, advapi32, ucrtbase, bind } = bindingContext() cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), @@ -281,7 +289,8 @@ function bindings(): Win32ProcessBindings { terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), terminateJobObject: bind(kernel32, 'TerminateJobObject', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), - } as unknown as Win32ProcessBindings + getOsfHandle: ucrtbase.func('_get_osfhandle', 'intptr_t', ['int']), + } as unknown as CurrentTokenProcessBindings return cached } @@ -292,7 +301,7 @@ function bindings(): Win32ProcessBindings { */ export function extendWin32ProcessBindings( create: (context: Win32BindingContext) => Extension, -): Win32ProcessBindings & Extension { +): CurrentTokenProcessBindings & Extension { return { ...bindings(), ...create(bindingContext()) } } @@ -300,7 +309,7 @@ export function extendWin32ProcessBindings( * Load the generic process binding table without policy-specific extensions. * @returns shared Win32 process, stdio, and Job operations. */ -export function loadWin32ProcessBindings(): Win32ProcessBindings { +export function loadWin32ProcessBindings(): CurrentTokenProcessBindings { return bindings() } /* v8 ignore stop */ diff --git a/packages/subprocess/win32-process/src/index.ts b/packages/subprocess/win32-process/src/index.ts index 5d8b8a29cf..5fc6de85ce 100644 --- a/packages/subprocess/win32-process/src/index.ts +++ b/packages/subprocess/win32-process/src/index.ts @@ -14,12 +14,12 @@ export { throwWin32, } from './ffi.ts' export type { + CurrentTokenProcessBindings, NativePtr, Win32ProcessBindings, } from './ffi.ts' export { closeHandleChecked, - closeCurrentProcessStandardStreams, drainPipe, isJobEmpty, pollProcessExit, @@ -31,6 +31,7 @@ export { waitForProcessExit, } from './process.ts' export type { + CurrentTokenStdioFileDescriptors, CurrentTokenProcessSpawnOptions, SpawnedJobProcess, SpawnedPipedProcess, diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 7b87a36dae..ad37f9abce 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -15,7 +15,7 @@ import { throwLastError, throwWin32, } from './ffi.ts' -import type { NativePtr, Win32ProcessBindings } from './ffi.ts' +import type { CurrentTokenProcessBindings, NativePtr, Win32ProcessBindings } from './ffi.ts' /** * Quote one argument according to CommandLineToArgvW parsing. @@ -66,6 +66,15 @@ interface ProcessSpawnOptions { export interface CurrentTokenProcessSpawnOptions extends ProcessSpawnOptions { /** Resolved executable path passed separately from the preserved argv entry. */ applicationName?: string + /** Runner CRT descriptors carrying target stdin, stdout, and stderr. */ + stdio: CurrentTokenStdioFileDescriptors +} + +/** Runner CRT descriptors whose OS handles become the target standard handles. */ +export interface CurrentTokenStdioFileDescriptors { + stdin: number + stdout: number + stderr: number } /** Restricted-token process creation inputs owned by the Windows ACL sandbox. */ @@ -325,34 +334,61 @@ function createKillOnCloseJob(api: Win32ProcessBindings): NativePtr { return job } +interface ProcessStandardHandles { + stdin: NativePtr + stdout: NativePtr + stderr: NativePtr +} + +function inheritedStandardHandles(api: Win32ProcessBindings): ProcessStandardHandles { + const get = (selector: number, label: string): NativePtr => { + const handle = api.getStdHandle(selector) + if (!isNullPtr(handle)) return handle + throwLastError(api, 'GetStdHandle', `null ${label} handle`) + } + return { + stdin: get(abi.STD_INPUT_HANDLE, 'stdin'), + stdout: get(abi.STD_OUTPUT_HANDLE, 'stdout'), + stderr: get(abi.STD_ERROR_HANDLE, 'stderr'), + } +} + +function targetCarrierHandles( + api: CurrentTokenProcessBindings, + descriptors: CurrentTokenStdioFileDescriptors, +): ProcessStandardHandles { + const get = (fileDescriptor: number, label: string): NativePtr => { + const handle = api.getOsfHandle(fileDescriptor) + if (handle !== -1 && handle !== -1n) return BigInt(handle) as NativePtr + throw new Error(`_get_osfhandle failed for target ${label} fd ${String(fileDescriptor)}`) + } + return { + stdin: get(descriptors.stdin, 'stdin'), + stdout: get(descriptors.stdout, 'stdout'), + stderr: get(descriptors.stderr, 'stderr'), + } +} + /** Shared suspended-create, Job-assignment, and resume lifecycle. */ function spawnJobProcess( api: Win32ProcessBindings, - options: CurrentTokenProcessSpawnOptions, + options: ProcessSpawnOptions, + resolveStdio: () => ProcessStandardHandles, createName: 'CreateProcessAsUserW' | 'CreateProcessW', create: (startupInfo: NativePtr, processInfo: NativePtr) => number, ): SpawnedJobProcess { const job = createKillOnCloseJob(api) - const getStdHandle = (selector: number, label: string): NativePtr => { - const handle = api.getStdHandle(selector) - if (!isNullPtr(handle)) return handle - const win32Code = api.getLastError() - api.closeHandle(job) - throwWin32(api, 'GetStdHandle', win32Code, `null ${label} handle`) - } - const stdIn = getStdHandle(abi.STD_INPUT_HANDLE, 'stdin') - const stdOut = getStdHandle(abi.STD_OUTPUT_HANDLE, 'stdout') - const stdErr = getStdHandle(abi.STD_ERROR_HANDLE, 'stderr') const enabled: NativePtr[] = [] let startupInfo: NativePtr | undefined let processInfo: NativePtr | undefined let created = 0 let createFailureCode = 0 try { + const stdio = resolveStdio() for (const [handle, label] of [ - [stdIn, 'stdin'], - [stdOut, 'stdout'], - [stdErr, 'stderr'], + [stdio.stdin, 'stdin'], + [stdio.stdout, 'stdout'], + [stdio.stderr, 'stderr'], ] as const) { if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) @@ -363,9 +399,9 @@ function spawnJobProcess( encodeStartupInfo(startupInfo, { cb: abi.STARTUPINFOW_SIZE, dwFlags: abi.STARTF_USESTDHANDLES, - hStdInput: stdIn, - hStdOutput: stdOut, - hStdError: stdErr, + hStdInput: stdio.stdin, + hStdOutput: stdio.stdout, + hStdError: stdio.stderr, }) processInfo = allocProcessInfo() created = create(startupInfo, processInfo) @@ -438,7 +474,7 @@ export function spawnInheritedJobProcess( options: RestrictedProcessSpawnOptions, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessAsUserW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, () => inheritedStandardHandles(api), 'CreateProcessAsUserW', (startupInfo, processInfo) => createRestrictedProcess( api, options, @@ -452,15 +488,15 @@ export function spawnInheritedJobProcess( /** * Spawn an ordinary process suspended, assign its Job, then resume it. * @param api - active binding table. - * @param options - command, cwd, and argv. + * @param options - command, cwd, argv, and target carrier descriptors. * @returns caller-owned process and Job handles after successful resume. */ export function spawnCurrentTokenJobProcess( - api: Win32ProcessBindings, + api: CurrentTokenProcessBindings, options: CurrentTokenProcessSpawnOptions, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) - return spawnJobProcess(api, options, 'CreateProcessW', (startupInfo, processInfo) => + return spawnJobProcess(api, options, () => targetCarrierHandles(api, options.stdio), 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( options.applicationName ?? null, commandLine, @@ -479,87 +515,14 @@ export function spawnCurrentTokenJobProcess( * Verify that an unnamed kill-on-close Job can be created and released now. * @param api - active binding table. */ -export function probeCurrentTokenJobSupport(api: Win32ProcessBindings): void { +export function probeCurrentTokenJobSupport(api: CurrentTokenProcessBindings): void { + if (typeof api.getOsfHandle !== 'function') { + throw new Error('current-token Job support requires UCRT _get_osfhandle') + } const job = createKillOnCloseJob(api) closeHandleChecked(api, job, 'current-token Job capability probe') } -interface MaterializedStdioStream { - readonly destroyed: boolean - destroy(): unknown - _destroy?: unknown -} - -/** - * Release the runner's Node-owned standard streams after target creation. - * The target retains its inherited handle copies. On Windows, libuv duplicates - * fd 0/1/2 for its stream owners while Node gives stdout and stderr an own - * no-op `_destroy`. Close the raw handles and then restore the real Socket - * destruction path so parent pipe EOF can follow the target. - * @param api - active binding table used to release raw standard handles. - * @param streams - stdin, stdout, and stderr used by the current process. - */ -export function closeCurrentProcessStandardStreams( - api: Win32ProcessBindings, - streams: readonly [MaterializedStdioStream, MaterializedStdioStream, MaterializedStdioStream] = [ - process.stdin, - process.stdout, - process.stderr, - ], -): void { - const failures: Error[] = [] - const recordFailure = (error: unknown): void => { - failures.push(error instanceof Error ? error : new Error(String(error))) - } - - const handles: NativePtr[] = [] - for (const selector of [abi.STD_INPUT_HANDLE, abi.STD_OUTPUT_HANDLE, abi.STD_ERROR_HANDLE]) { - try { - const handle = api.getStdHandle(selector) - if (!isNullPtr(handle) && !handles.includes(handle)) handles.push(handle) - } catch (error) { - recordFailure(error) - } - } - for (const handle of handles) { - try { - closeHandleChecked(api, handle, 'runner standard handle') - } catch (error) { - recordFailure(error) - } - } - - const [stdin, stdout, stderr] = streams - if (!stdin.destroyed) { - try { - stdin.destroy() - } catch (error) { - recordFailure(error) - } - } - for (const output of [stdout, stderr]) { - if (output.destroyed) continue - if (Object.hasOwn(output, '_destroy')) { - try { - if (!Reflect.deleteProperty(output, '_destroy')) { - throw new Error('deleting runner standard-stream destroy override failed') - } - } catch (error) { - recordFailure(error) - } - } - try { - output.destroy() - } catch (error) { - recordFailure(error) - } - } - if (failures.length === 1) { - for (const failure of failures) throw failure - } - if (failures.length > 1) throw new AggregateError(failures, 'closing runner standard streams failed') -} - /** * Poll one process handle without blocking the runner event loop. * @param api - active binding table. diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 41008b1d7b..7885782548 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -2,7 +2,6 @@ import koffi from 'koffi' import { describe, expect, it, vi } from 'vitest' import { closeHandleChecked, - closeCurrentProcessStandardStreams, isJobEmpty, pollProcessExit, probeCurrentTokenJobSupport, @@ -15,15 +14,15 @@ import { JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, - STD_ERROR_HANDLE, - STD_INPUT_HANDLE, - STD_OUTPUT_HANDLE, WAIT_TIMEOUT, } from '../src/abi.ts' import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' -import type { NativePtr, Win32ProcessBindings } from '../src/index.ts' +import type { + CurrentTokenProcessBindings, + NativePtr, +} from '../src/index.ts' -function api(overrides: Partial = {}): Win32ProcessBindings { +function api(overrides: Partial = {}): CurrentTokenProcessBindings { return { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), @@ -32,6 +31,7 @@ function api(overrides: Partial = {}): Win32ProcessBinding return 1 }), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), + getOsfHandle: vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor)), setHandleInformation: vi.fn(() => 1), createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { @@ -55,7 +55,7 @@ function api(overrides: Partial = {}): Win32ProcessBinding getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), ...overrides, - } as unknown as Win32ProcessBindings + } as unknown as CurrentTokenProcessBindings } describe('ordinary Job process operations', () => { @@ -88,6 +88,7 @@ describe('ordinary Job process operations', () => { applicationName: 'C:\\resolved\\probe.exe', args: ['literal $VALUE', 'a b'], cwd: 'C:\\work', + stdio: { stdin: 4, stdout: 5, stderr: 6 }, })).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(createProcessW).toHaveBeenCalledWith( 'C:\\resolved\\probe.exe', @@ -110,24 +111,24 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnCurrentTokenJobProcess(bindings, { command: 'missing.exe', args: [], cwd: 'C:\\work' }) + spawnCurrentTokenJobProcess(bindings, { + command: 'missing.exe', + args: [], + cwd: 'C:\\work', + stdio: { stdin: 4, stdout: 5, stderr: 6 }, + }) } catch (error) { caught = error } expect(caught).toMatchObject({ api: 'CreateProcessW', win32Code: 5 }) }) - it('inherits the runner standard handles and restores their flags', () => { + it('resolves the target carrier descriptors and restores their handle flags', () => { let startup: Record | undefined - const handles = new Map([ - [STD_INPUT_HANDLE, 71n as NativePtr], - [STD_OUTPUT_HANDLE, 72n as NativePtr], - [STD_ERROR_HANDLE, 73n as NativePtr], - ]) - const getStdHandle = vi.fn((selector: number) => handles.get(selector) as NativePtr) + const getOsfHandle = vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor)) const setHandleInformation = vi.fn(() => 1) const bindings = api({ - getStdHandle, + getOsfHandle, setHandleInformation, createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { startup = koffi.decode(infoPtr, STARTUPINFOW) as Record @@ -144,12 +145,9 @@ describe('ordinary Job process operations', () => { command: 'probe.exe', args: [], cwd: 'C:\\work', + stdio: { stdin: 4, stdout: 5, stderr: 6 }, })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(getStdHandle.mock.calls.map(([selector]) => selector)).toEqual([ - STD_INPUT_HANDLE, - STD_OUTPUT_HANDLE, - STD_ERROR_HANDLE, - ]) + expect(getOsfHandle.mock.calls.map(([fileDescriptor]) => fileDescriptor)).toEqual([4, 5, 6]) expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) expect(setHandleInformation.mock.calls).toEqual([ [71n, 1, 1], [72n, 1, 1], [73n, 1, 1], @@ -213,92 +211,19 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('closes unique raw handles and restores output Socket destruction', () => { - const input = { destroyed: false, destroy: vi.fn() } - const socketDestroy = vi.fn() - const dummyDestroy = vi.fn() - const makeOutput = (): { - stream: { destroyed: boolean; destroy(): void; _destroy?: () => void } - destroy: ReturnType - } => { - const destroy = vi.fn(function (this: { _destroy(): void }) { this._destroy() }) - const stream = Object.assign(Object.create({ _destroy: socketDestroy }), { - destroyed: false, - destroy, - _destroy: dummyDestroy, - }) as { destroyed: boolean; destroy(): void; _destroy?: () => void } - return { stream, destroy } - } - const { stream: stdout, destroy: stdoutDestroy } = makeOutput() - const { stream: stderr, destroy: stderrDestroy } = makeOutput() + it('rejects an unavailable carrier descriptor or CRT binding before target creation', () => { const closeHandle = vi.fn(() => 1) - const getStdHandle = vi.fn((selector: number) => - (selector === STD_INPUT_HANDLE ? 10n : 11n) as NativePtr) - const bindings = api({ - getStdHandle, - closeHandle, - }) + const missingDescriptor = api({ closeHandle, getOsfHandle: vi.fn(() => -1) }) + expect(() => spawnCurrentTokenJobProcess(missingDescriptor, { + command: 'probe.exe', + args: [], + cwd: 'C:\\work', + stdio: { stdin: 4, stdout: 5, stderr: 6 }, + })).toThrow('_get_osfhandle failed for target stdin fd 4') + expect(closeHandle).toHaveBeenCalledWith(50n) - expect(() => { - closeCurrentProcessStandardStreams(bindings, [input, stdout, stderr]) - }).not.toThrow() - expect(getStdHandle).toHaveBeenCalledTimes(3) - expect(closeHandle.mock.calls).toEqual([[10n], [11n]]) - expect(input.destroy).toHaveBeenCalledOnce() - expect(stdoutDestroy).toHaveBeenCalledOnce() - expect(stderrDestroy).toHaveBeenCalledOnce() - expect(dummyDestroy).not.toHaveBeenCalled() - expect(socketDestroy).toHaveBeenCalledTimes(2) - expect(Object.hasOwn(stdout, '_destroy')).toBe(false) - expect(Object.hasOwn(stderr, '_destroy')).toBe(false) - }) - - it('reports one or several runner standard-stream close failures', () => { - const closed = { destroyed: true, destroy: vi.fn() } - let getStdHandleCalls = 0 - const getStdHandle = vi.fn(() => { - getStdHandleCalls += 1 - if (getStdHandleCalls === 1) throw new Error('single close failure') - return 0n as NativePtr - }) - const singleFailure = api({ getStdHandle }) - expect(() => { - closeCurrentProcessStandardStreams(singleFailure, [ - closed, - closed, - closed, - ]) - }).toThrow('single close failure') - expect(getStdHandle).toHaveBeenCalledTimes(3) - - const rawCloseFailure = api({ closeHandle: vi.fn(() => 0) }) - const nonConfigurableDestroy = vi.fn() - const nonConfigurable = { - destroyed: false, - destroy: nonConfigurableDestroy, - } as { destroyed: boolean; destroy(): void; _destroy?: unknown } - Object.defineProperty(nonConfigurable, '_destroy', { value: vi.fn(), configurable: false }) - let failure: unknown - try { - closeCurrentProcessStandardStreams(rawCloseFailure, [ - { destroyed: false, destroy: () => { throw 'input close failure' } }, - nonConfigurable, - { destroyed: false, destroy: () => { throw new Error('output close failure') } }, - ]) - } catch (error) { - failure = error - } - expect(failure).toMatchObject({ - name: 'AggregateError', - message: 'closing runner standard streams failed', - }) - const errors = (failure as { errors: unknown }).errors - expect(errors).toEqual(expect.arrayContaining([ - expect.objectContaining({ api: 'CloseHandle' }), - expect.objectContaining({ message: 'input close failure' }), - expect.objectContaining({ message: 'deleting runner standard-stream destroy override failed' }), - expect.objectContaining({ message: 'output close failure' }), - ])) - expect(nonConfigurableDestroy).toHaveBeenCalledOnce() + const missingBinding = api({ getOsfHandle: undefined as never }) + expect(() => { probeCurrentTokenJobSupport(missingBinding) }) + .toThrow('current-token Job support requires UCRT _get_osfhandle') }) }) From a825b8d0427f8312c600ded8b78964e9ace18c0b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 05:56:29 +0800 Subject: [PATCH 081/110] fix(subprocess): close native containment contract gaps --- .../2026-06-13-capability-seams.i18n.yaml | 4 +- .../2026-06-13-capability-seams.md | 2 +- .../2026-06-13-capability-seams.zh.md | 2 +- ...ash-stdin-env-trusted-plugin-api.i18n.yaml | 4 +- ...06-30-bash-stdin-env-trusted-plugin-api.md | 4 +- ...30-bash-stdin-env-trusted-plugin-api.zh.md | 4 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 14 +- .../2026-07-06-timeout-deadline-library.zh.md | 14 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 2 +- ...rtable-execution-world-consumers.i18n.yaml | 4 +- ...7-28-portable-execution-world-consumers.md | 6 +- ...8-portable-execution-world-consumers.zh.md | 6 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 2 +- ...8-22-single-dsh-application-launcher.zh.md | 2 +- ...26-08-27-process-table-snapshots.i18n.yaml | 4 +- .../2026-08-27-process-table-snapshots.md | 8 +- .../2026-08-27-process-table-snapshots.zh.md | 8 +- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 9 +- ...-08-28-subprocess-native-containment.zh.md | 9 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +- ...2026-07-22-cross-platform-test-fixtures.md | 4 +- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 +- .../fs/tool-fs-search/tests/tools.spec.ts | 1 - .../subprocess/subprocess-local/src/index.ts | 1 - .../subprocess-local/src/linux-execve.ts | 2 +- .../subprocess-local/src/runner-launch.ts | 2 +- .../subprocess-local/src/runner-protocol.ts | 30 ++-- .../subprocess-local/src/spawn-runner.ts | 41 +++--- .../subprocess/subprocess-local/src/spawn.ts | 13 +- .../subprocess-local/src/terminal.ts | 10 +- .../subprocess-local/src/windows-job.ts | 32 +++-- .../tests/linux-execve.spec.ts | 6 +- .../tests/linux-scope.spec.ts | 4 +- .../tests/spawn-runner.spec.ts | 104 ++++++++++---- .../subprocess-local/tests/spawn.spec.ts | 82 +++++++++-- .../subprocess-local/tests/terminal.spec.ts | 29 +++- .../tests/windows-job.spec.ts | 67 ++++++--- .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 10 +- .../subprocess/win32-process/README.zh.md | 10 +- packages/subprocess/win32-process/src/abi.ts | 10 ++ packages/subprocess/win32-process/src/ffi.ts | 30 ++-- .../subprocess/win32-process/src/process.ts | 73 ++++++++-- .../tests/ordinary-process.spec.ts | 133 +++++++++++++----- .../win32-process/verify/abi-probe.cpp | 6 + .../generator/tests/cordis-catalog.spec.ts | 3 +- scripts/smoke-python-runtime.py | 9 +- 58 files changed, 586 insertions(+), 280 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 1c4eadabc1..a54457715b 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.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-06-13-capability-seams.md -2026-06-13-capability-seams.md: 46a2c39e927e859c7eb95956d8586f3bf04c7b1c -2026-06-13-capability-seams.zh.md: f44e3e68d2153149435b0fd0aaa5fd121cf3ecad +2026-06-13-capability-seams.md: 3c552c474b499b9f1c9f60242f4773750faadafb +2026-06-13-capability-seams.zh.md: 4e6100a2ce557d91fa18b5630c267f37f1bc0f00 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md index 46a2c39e92..3c552c474b 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md @@ -15,7 +15,7 @@ This is distinct from "who provides vs. needs a capability at runtime", which Co A swappable capability has **three roles**: 1. **Service Definition** — the Cordis `Service` and vocabulary types owning `ctx.` and depending only on the vocabulary the contract needs (e.g. `dsh-shell`: `ShellExecutor`, `ShellRunResult`, `ShellProcess`). A definition may be an abstract class or a concrete registry service; it is never a TypeScript `interface`. -2. **Service Provider** — a plugin that supplies or registers an implementation (e.g. `dsh-bash-local`: subprocesses, process-group kills, spill-file truncation). Sandboxed and remote providers are sibling packages implementing or registering against the same Service Definition. +2. **Service Provider** — a plugin that supplies or registers an implementation (e.g. `dsh-bash-local`: subprocesses, provider-managed range termination, spill-file truncation). The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the local provider's OS-specific range mechanics. Sandboxed and remote providers are sibling packages implementing or registering against the same Service Definition. 3. **Consumer** — what the model and plugins program against (e.g. `dsh-tool-bash`: the `bash` schema, with background handles registered into the generic job runtime). Consumers inject the service key and never import provider-specific types. The role names use title case: **Service Definition**, **Service Provider**, and **Consumer**. Generic uses of `provider` and `consumer` remain lowercase. diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index f44e3e68d2..4e6100a2ce 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -15,7 +15,7 @@ harness 具有可替换的能力,包括 shell 执行和模型提供方。一 一项可替换的能力包含**三个角色**: 1. **Service Definition**——拥有 `ctx.` 的 Cordis `Service` 和词汇类型,仅依赖约定所需的词汇(例如 `dsh-shell`:`ShellExecutor`、`ShellRunResult`、`ShellProcess`)。Service Definition 可以是抽象类,也可以是具体的注册表服务;绝不是 TypeScript `interface`。 -2. **Service Provider**——提供或注册实现的插件(例如 `dsh-bash-local`:子进程、进程组 kill、spill 文件截断)。沙箱化和远程 Service Provider 是依据同一 Service Definition 实现或注册的兄弟包。 +2. **Service Provider**——提供或注册实现的插件(例如 `dsh-bash-local`:子进程、由提供方管理的范围终止、spill 文件截断)。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地提供方的 OS 特有范围机制。沙箱化和远程 Service Provider 是依据同一 Service Definition 实现或注册的兄弟包。 3. **Consumer**——模型和插件编程所面向的内容(例如 `dsh-tool-bash`:`bash` schema,后台句柄注册到通用任务运行时)。Consumer 注入服务键,从不导入 Service Provider 特有的类型。 角色名使用标题式大小写:**Service Definition**、**Service Provider** 和 **Consumer**。泛指的 `provider` 和 `consumer` 仍使用小写。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml index 04eec8e0e1..6209091467 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.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-06-30-bash-stdin-env-trusted-plugin-api.md -2026-06-30-bash-stdin-env-trusted-plugin-api.md: 41be63fff598587ee9b873cf7edf51da788bc02a -2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 60721539d9d37857e145e289bb262482588139bf +2026-06-30-bash-stdin-env-trusted-plugin-api.md: 05501e0f0a5ff38df1e5e719574cd8bdada4f4d1 +2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 99ca2b9dddd710d9c79fc6e0ad726f68aca994ac diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md index 41be63fff5..05501e0f0a 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md) ## Problem -The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.shell` capability seam ([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This change adds those two inputs. +The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.shell` capability seam ([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)), with [provider-managed range termination](2026-08-28-subprocess-native-containment.md), output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This change adds those two inputs. `stdin` and `env` do not create a new model capability because ordinary shell syntax already supplies both. Ambient credentials are protected by `dsh-bash-local`'s child-environment scrub, not by hiding these Service Definition fields; model tool arguments are static JSON and do not expand shell variables. The fields therefore serve trusted in-process callers, such as hook bridges, that need to pass structured input and `CLAUDE_*` variables without embedding them in model-visible shell text. See [defensive-patterns.md](../../../../docs/defensive-patterns.md) for the ambient-environment rule. @@ -30,4 +30,4 @@ Three deliberate choices: ## Consequences -Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model-facing behavior remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/shell.md). +Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its managed-range termination, truncation, and spill behavior. The model-facing behavior remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/shell.md). diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md index 60721539d9..99ca2b9ddd 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备进程组终止、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 +钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备[由提供方管理的范围终止](2026-08-28-subprocess-native-containment.zh.md)、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 `stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.zh.md)。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其受管范围终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 66dd29f3a4..5e782d97b1 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 95adc41bffff6d7711685ebc52cb73b2b455df41 -2026-07-06-timeout-deadline-library.zh.md: 8b7b18a2d1e7757102afc81bea03245de2707d86 +2026-07-06-timeout-deadline-library.md: f87036ff344cc8af2e0f0b90fabb962b6d45e2c0 +2026-07-06-timeout-deadline-library.zh.md: 2744b4897e76058fb252d45cd5f89c4e344b5b92 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 95adc41bff..f87036ff34 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,11 +8,11 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that drove the subprocess termination path, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) -Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash kills an OS process group (work runs in a child process, outside this runtime, reachable only by signal), while web aborts an in-process `fetch` (undici tears down the socket). There is no single mechanism that can stop all of them. +Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash asks its subprocess provider to terminate an OS-owned range, while web aborts an in-process `fetch` and lets undici tear down the socket. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the local scope, Job, and fallback mechanisms; there is no single mechanism that can stop every capability's work. ## Decision @@ -87,17 +87,17 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): | The default/max *values* | the capability's config | | The timeout `code` string | the capability (`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) | -The signal only *notifies*; termination is always the listener's job, and the listener differs by capability. bash writes its own `addEventListener('abort', kill)` because the OS process lives outside this runtime and nothing else will kill it; web hands `d.signal` to `fetch` and undici tears down the socket. This is why file read/write/edit take **no** `timeoutMs`: a local syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. Both reference agents leave file I/O untimed for the same reason. +The signal only *notifies*; termination is always the listener's job, and the listener differs by capability. bash writes its own `addEventListener('abort', kill)` because the OS process lives outside this runtime and its subprocess provider must drive the owned range to settlement; web hands `d.signal` to `fetch` and undici tears down the socket. This is why file read/write/edit take **no** `timeoutMs`: a local syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. Both reference agents leave file I/O untimed for the same reason. ### How each capability consumes it - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. -- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. +- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose abort listener calls `SubprocessHandle.terminate()` and awaits the same provider-managed range. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. - **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The direct DeepSeek adapter also pulses that outstanding demand when its SSE parser observes a comment, without yielding the comment as a `StreamChunk` or writing it to the session log. The pi-ai SDK does not expose comment activity to its adapter, so that path can rearm only when the SDK yields. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`. ## Consequences -- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. +- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. Timeout classification does not alter provider-owned termination: local POSIX ranges use TERM→grace→KILL, while Windows ordinary ranges terminate immediately. The Service Definition type `ShellRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). @@ -107,10 +107,10 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode ## Alternatives considered -**A unified timeout *plugin* / `ctx.timeout` service.** Rejected on microkernel grounds. A service that could stop any tool's work would have to understand every capability's termination mechanism (process-group SIGKILL, socket teardown, syscall-boundary checks) — the "kernel knows too much" the architecture forbids. Codex's `ExecExpiration` is scoped to the exec family precisely because the kill it drives (`killpg`) is process-family-specific; MCP and model-stream keep their own. There is no coherent middle layer that owns termination for everything, so the shared piece can only be the pure timing/classification half — a library, not a service. +**A unified timeout *plugin* / `ctx.timeout` service.** Rejected on microkernel grounds. A service that could stop any tool's work would have to understand every capability's termination mechanism (native scope or Job termination, fallback process-group signalling, socket teardown, syscall-boundary checks) — the "kernel knows too much" the architecture forbids. Codex's `ExecExpiration` is scoped to the exec family precisely because the kill it drives (`killpg`) is process-family-specific; MCP and model-stream keep their own. There is no coherent middle layer that owns termination for everything, so the shared piece can only be the pure timing/classification half — a library, not a service. **Per-tool ad-hoc timeout, no shared code (the prior status quo, and Claude Code's choice).** Rejected because it was already producing divergence and duplicated correctness burden: web_fetch hand-rolled the exact controller/reason logic that future network/process-backed tools would each have to re-derive, and the fusion + `signal.reason` recovery are the error-prone parts. Claude Code tolerates full duplication; this repo has a single shared abort channel (`exec.signal` on every `execute`) that makes a small shared primitive strictly cleaner, so the cost/benefit differs. **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the provider-owned termination path is independent of which cause won. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 8b7b18a2d1..2744b4897e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,11 +8,11 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包驱动子进程终止路径,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) -每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「已取消」。而融合与原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*操作不可归约地不同:bash 杀死一个 OS 进程组(工作运行在子进程中,在本运行时之外,只能通过信号触达),而 web 中止一个进程内的 `fetch`(undici 拆除 socket)。不存在一个能停止所有能力工作的单一机制。 +每个新的外部进程或网络工具都要重新推导同样四件事:钳位请求值、启动定时器、将超时与上游取消融合、在出口处区分「超时」与「已取消」。而融合与原因恢复恰恰是最容易出微妙错误的部分(web_fetch 的 `signal.reason` 处理就是证据)。与此同时,各能力执行的*终止*操作不可归约地不同:bash 请求其子进程提供方终止由 OS 拥有的范围,而 web 中止一个进程内的 `fetch`,由 undici 拆除 socket。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地 scope、Job 与 fallback 机制;不存在一个能停止所有能力工作的单一机制。 ## 决策 @@ -87,17 +87,17 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): | 默认值/最大值*数值* | 各能力的配置 | | 超时 `code` 字符串 | 各能力(`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) | -信号只*通知*;终止始终是监听方的职责,而监听方因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,没有别的东西会杀死它;web 将 `d.signal` 交给 `fetch`,由 undici 拆除 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent(智能体)出于同样的原因对文件 I/O 不设超时。 +信号只*通知*;终止始终是监听方的职责,而监听方因能力而异。bash 自行编写 `addEventListener('abort', kill)`,因为 OS 进程存在于本运行时之外,必须由子进程提供方驱动其拥有的范围达到完全停稳;web 将 `d.signal` 交给 `fetch`,由 undici 拆除 socket。这也是文件读/写/编辑**不接受** `timeoutMs` 的原因:本地系统调用最多只能尽力中止,超时无法强制 `fsync`/`rename` 停止,添加超时将是一个违反「显式优于隐式」的隐式默认值。两个参考 agent(智能体)出于同样的原因对文件 I/O 不设超时。 ### 各能力如何消费该库 - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码约定不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 -- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 +- **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者的 abort 监听器调用 `SubprocessHandle.terminate()`,并等待同一个由提供方管理的范围。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 - **LLM(大语言模型)适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。DeepSeek 直连适配器还会在其 SSE(Server-Sent Events)解析器观察到注释时,对该项尚未结算的 demand 调用 `pulse()`;该注释既不会作为 `StreamChunk` 产出,也不会写入会话日志。pi-ai SDK 不会向其适配器暴露注释活动,因此该路径只能在 SDK 产出值时重新启动定时器。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 ## 后果 -- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 +- `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。超时分类不改变由提供方管理的终止:本地 POSIX 范围使用 TERM→宽限期→KILL,Windows 普通范围则立即终止。Service Definition 类型 `ShellRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 @@ -107,10 +107,10 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): ## 曾考虑的替代方案 -**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(进程组 SIGKILL、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 +**统一的超时*插件* / `ctx.timeout` 服务。** 基于微内核原则否决。一个能停止任何工具工作的服务必须理解每个能力的终止机制(原生 scope 或 Job 终止、fallback 进程组信号、socket 拆除、系统调用边界检查),这正是架构所禁止的「内核知道太多」。Codex 的 `ExecExpiration` 被限定于 exec 族,正是因为它驱动的 kill(`killpg`)是进程族特有的;MCP 和模型流各自保有自己的。不存在一个连贯的中间层能为所有东西拥有终止权,因此共享部分只能是纯计时/分类那一半——一个库,而非服务。 **每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得采用一个小型共享原语明显更简洁,因此成本/收益不同。 **用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。 -**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,而既有的 SIGTERM→SIGKILL 终止路径保持不变。 +**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,由提供方管理的终止路径不受哪个原因先胜出的影响。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 83638635ad..3146f667bf 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 847e174b87b3e37f2c818dfb00bf51648812deeb +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 9f9c5b5a6f20e707e6a40cba1ced0ddd6c44fa97 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index c152345772..847e174b87 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,7 +42,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The packaging-owned bootstrap imports the public CLI for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ea5e457afd..9f9c5b5a6f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,7 +42,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。由打包层拥有的 bootstrap 在普通启动时导入公开 CLI,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index fcca895513..6da06a71ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.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-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954 -2026-07-16-explicit-turn-cancellation.zh.md: acf56e0629668a227324045ffd0521619dc45f33 +2026-07-16-explicit-turn-cancellation.md: 2d611d4f67b911fddea1e374f604277217dd7d54 +2026-07-16-explicit-turn-cancellation.zh.md: a87c45a2fbdc1e654665486fd340d034d70a9b98 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 86faad9929..2d611d4f67 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -20,7 +20,7 @@ AgentLoop privately owns one `TurnCancellation` per prospective turn. It install The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. -The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. +The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's provider-managed termination and join boundary; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the supported local scope, Job, and fallback mechanics. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index acf56e0629..a87c45a2fb 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -20,7 +20,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器由提供方管理的终止与等待边界;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责受支持本地路径上的 scope、Job 与 fallback 机制。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml index 40454e9a56..441e67f823 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.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-07-28-portable-execution-world-consumers.md -2026-07-28-portable-execution-world-consumers.md: 760005f1460890222c5af1ea0ec4eaf9cb29f360 -2026-07-28-portable-execution-world-consumers.zh.md: d78b04a5af785be0707d06e519819f95c524d92d +2026-07-28-portable-execution-world-consumers.md: 787fe341e58cc212c99e0f35f07eea8e83daf000 +2026-07-28-portable-execution-world-consumers.zh.md: a558a5af64437b8743e741ace4ccf27079501721 diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md index 760005f146..787fe341e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md @@ -18,13 +18,13 @@ Ordinary pipes do not cover one requirement. A persistent terminal needs PTY all The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, and containment. Existing whole and streaming text operations remain filesystem-owned; protocol consumers enforce their own retention limits while consuming the stream. -The subprocess interface owns executable lookup and process primitives: ordinary raw or collected process spawning and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns text I/O, foreground groups, signalling, and one awaited TERM-to-KILL operation that settles in-flight handle calls and reaches quiescence for every session member the provider can still observe. Its signal cancels allocation only; the published handle owns its lifetime. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer. +The subprocess interface owns executable lookup and process primitives: ordinary raw or collected process spawning and `spawnTerminal()`. An ordinary handle keeps target identity private: `.done` reports the direct target, while `terminate()` and `waitForExit()` control and observe the same provider-managed range. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns local Linux scopes, Windows Jobs, and their disclosed fallbacks. The terminal operation is one deep primitive whose handle owns text I/O, foreground groups, signalling, and one awaited TERM-to-KILL operation that settles in-flight handle calls and reaches quiescence for every member of its provider-owned range; an observational fallback limits that range to identities it can still observe. Its signal cancels allocation only; the published handle owns its lifetime. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer. Generic consumers use that execution world: - `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`. - `dsh-lsp-stdio` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. One provider-lifetime signal aborts filesystem and protocol work during disposal, including workspace lookup before queue ownership; its JSON-RPC, pooling, synchronization, and normalization stay unchanged. -- `dsh-terminal-bash` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; an in-flight readiness poll cannot release that reservation, and a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Startup cancellation begins terminal rollback without waiting for a stalled readiness or signalling call. Close rejects new public signals and delegates provider-observable session quiescence to the handle's awaited termination operation. +- `dsh-terminal-bash` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. `danger-full-access` needs no `ctx.sandbox`; a confined mode requires a same-world sandbox provider and fails before spawn when none is mounted. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; an in-flight readiness poll cannot release that reservation, and a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Startup cancellation begins terminal rollback without waiting for a stalled readiness or signalling call. Close rejects new public signals and delegates provider-managed session quiescence to the handle's awaited termination operation. ## E2B POC boundary @@ -68,6 +68,6 @@ A remote execution provider implements only its shared sandbox owner plus filesy The fundamental interfaces are wider, and a filesystem/subprocess pair must agree on one execution world. The added operations are limited to facts and lifecycle mechanics that current generic consumers require; model schemas, protocol framing, readiness policy, and presentation do not leak into the providers. -The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer. +The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. On supported Linux hosts, the user-systemd scope retains descendants that call `setsid` or reparent, while process inspection continues to own foreground attribution and synchronous fallback evidence. Other hosts use the observational teardown: disposal sweeps descendants before and after terminating the top-level shell, waits for exact PID-identity-fenced descendants retained during foreground inspection, and retains Linux session members that survive top-level exit. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer. The E2B composition demonstrates that a shared sandbox owner plus filesystem and subprocess adapters are sufficient to move the mutable coding world off-host while leaving higher capabilities provider-neutral. Its POC limits remain explicit: the SDK retains complete command transport in host memory, remote startup cannot publish a PID synchronously, exact terminal stdin-wait and independent signal facts are unavailable, numeric PID/PGID operations are not identity-fenced, the initial environment probe cannot hide unknown sandbox-default secrets from already-running same-UID processes, and adapter artifacts remain until sandbox deletion. These are provider constraints, not justification for compatibility shims or more E2B packages. diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md index d78b04a5af..a558a5af64 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md @@ -18,13 +18,13 @@ Status: implemented 文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI 和包含关系。现有完整文本与流式文本操作仍归文件系统负责;协议消费方在消费流时执行各自的保留上限。 -进程管理接口负责可执行文件查找与进程原语:以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使提供方仍可观察到的每个会话成员完全停稳。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。 +进程管理接口负责可执行文件查找与进程原语:以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。普通句柄把 target identity 保持为私有事实:`.done` 报告 direct target,`terminate()` 与 `waitForExit()` 则控制并观察同一个由提供方管理的范围。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责本地 Linux scope、Windows Job 及其已声明的 fallback。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使提供方拥有的范围中每个成员完全停稳;观察型 fallback 只能把该范围限制为它仍可观察到的 identity。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。 通用消费方使用该执行世界: - `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。 - `dsh-lsp-stdio` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。一个提供方生命周期信号会在资源释放期间中止文件系统与协议操作,包括取得队列所有权之前的工作区查找;其 JSON-RPC、池化、同步和规范化保持不变。 -- `dsh-terminal-bash` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;在途就绪检查无法释放该预留,写入被拒绝时也不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。启动取消会立即开始终端回滚,而不等待停滞的就绪检查或信号发送调用。关闭操作会拒绝新的公开信号,并把提供方可观察会话成员的完全停稳委托给句柄上须等待的终止操作。 +- `dsh-terminal-bash` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。`danger-full-access` 不需要 `ctx.sandbox`;受限模式要求同一执行世界中存在沙箱提供方,未挂载时会在 spawn 前失败。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;在途就绪检查无法释放该预留,写入被拒绝时也不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。启动取消会立即开始终端回滚,而不等待停滞的就绪检查或信号发送调用。关闭操作会拒绝新的公开信号,并把由提供方管理的会话完全停稳委托给句柄上须等待的终止操作。 ## E2B POC 边界 @@ -68,6 +68,6 @@ E2B 负责可变文件系统、受管命令与 Bash 进程、终端分配与终 基础接口更宽,一对文件系统/进程管理提供方必须在同一个执行世界上保持一致。新增操作仅限当前通用消费方所需的事实与生命周期机制;模型 schema、协议分帧、就绪策略和呈现不会渗入提供方。 -本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。 +本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。在受支持的 Linux 宿主上,user-systemd scope 会保留调用 `setsid` 或发生 reparent 的后代,进程检查则继续负责前台归属与同步 fallback 证据。其他宿主使用观察型拆卸:dispose(资源释放)会在终止顶层 shell 前后清理后代进程,等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程,并继续追踪在顶层进程退出后仍存活的 Linux 会话成员。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。 E2B 组合证明,共享沙箱所有者加上文件系统与进程管理适配器,就足以在保持上层能力与提供方无关的同时,把可变编码世界移出宿主。其 POC 限制仍明确在案:SDK 会把完整命令传输内容保留在宿主内存中;远程启动无法同步发布 PID;无法获得精确的终端 stdin 等待状态与独立信号事实;基于数值 PID/PGID 的操作没有身份围栏;初始环境探测无法向已在运行的同 UID 进程隐藏未知的沙箱默认 secret;适配器产物会一直保留到沙箱删除。这些是提供方限制,不是引入兼容性 shim 或更多 E2B 包的理由。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 48ceb0a573..6d05137fba 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: feac31b3eafced8158a8d79a0e5967a8de5e2f87 -2026-08-22-single-dsh-application-launcher.zh.md: 88fe4ef4d4e0436ecb450e3f5319be7acee6882b +2026-08-22-single-dsh-application-launcher.md: 352b6076191b03493f75df469b7f7fd8e3df098a +2026-08-22-single-dsh-application-launcher.zh.md: ae3b8c1984f973560a083bc046d868058db8d9ef diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index feac31b3ea..352b607619 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. +The Python runtime wheel packages `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` through the private `dsh-python-runtime-closure` deploy manifest. Its ordinary branch imports the public CLI; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index 88fe4ef4d4..ae3b8c1984 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 +Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest 打包 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js`。其普通分支导入公开 CLI;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个载体。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml index 138ca27a49..94b9f8d54c 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.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-27-process-table-snapshots.md -2026-08-27-process-table-snapshots.md: 27c364607ca1e03a926c309f26007477a8785636 -2026-08-27-process-table-snapshots.zh.md: 9c5fe65bb83a0d04fe5639b3ffefcf377c3588ef +2026-08-27-process-table-snapshots.md: 9b702fb16bad531fdc3d0ba46f436844afc8184b +2026-08-27-process-table-snapshots.zh.md: 1d63aca3f5bd123311c9eccc4d24d2f067bc3485 diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md index 27c364607c..9b702fb16b 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md @@ -22,18 +22,20 @@ Measured by driving the production `MacProcessInspector` against a real process Any command spawning two or more children — a pipeline, `make`, `pnpm`, `git` — saturates the host event loop until it exits. -Teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N members costs N table reads. +Fallback terminal teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N observed members costs N table reads. ## Decision `ProcessInspector.snapshot()` returns a `ProcessSnapshot`, one observation of the process table that answers `tree(rootPid)`, `session(sessionId)`, and `alive(identity)`. It replaces the three per-question methods; the inspector's remaining surface is `foregroundPgid`, `isStdinWaiting`, `signalGroup`, and `signalProcess`. -Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. +Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. Fallback `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. Signalling does not share that observation. `ProcessInspector.isAlive(identity)` answers current state from the narrowest per-identity source a platform offers — one `/proc//stat` read on Linux, one `ps` table on macOS, one process-handle check on Windows — and `signalProcess` takes that fence immediately before delivering the signal. An observation cannot stand in for it: the observation preserves the original PID-to-start-time pairing, so a recycled PID would still match it and take a signal meant for the process that exited. Reading the fence per target also keeps a failed read costing one target instead of the rest of a teardown round, which is what the [synchronous exit-cleanup contract](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) requires. `signalMembers` and `waitForMembers` return before capturing anything when a round has no members, so a command that spawned no descendants pays no table read for its teardown sweeps. +Supported Linux terminal teardown bypasses those observational sweeps after binding a user-systemd scope and waits on manager-owned membership instead. Process-table snapshots remain authoritative for foreground/readiness inspection and for fallback or synchronous host-exit cleanup. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that split. + Platform differences live in how a snapshot is built, not in what it promises: - **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity. @@ -62,7 +64,7 @@ Platform differences live in how a snapshot is built, not in what it promises: A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count. -Teardown keeps its previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state. +Fallback and synchronous host-exit teardown keep the previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state. Supported Linux normal teardown uses scope membership instead of this scan. A snapshot is a point-in-time view, and the type's documentation says so. `waitForMembers` re-captures per iteration because observing change is its purpose, and no signal is ever decided from a captured view. diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md index 9c5fe65bb8..1d63aca3f5 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md @@ -22,18 +22,20 @@ Status: implemented 任何派生两个及以上子进程的命令——一条管道、`make`、`pnpm`、`git`——都会把宿主事件循环打满,直到它退出。 -拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个成员发信号要读 N 次表。 +fallback 终端拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个已观察成员发信号要读 N 次表。 ## Decision `ProcessInspector.snapshot()` 返回一个 `ProcessSnapshot`,即对进程表的一次观察,由它回答 `tree(rootPid)`、`session(sessionId)` 和 `alive(identity)`。它取代了那三个按问题划分的方法;检查器剩下的接口是 `foregroundPgid`、`isStdinWaiting`、`signalGroup` 和 `signalProcess`。 -每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 +每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。fallback `waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 发信号不共用这份观察。`ProcessInspector.isAlive(identity)` 用各平台最窄的按标识来源回答当前状态——Linux 读一个 `/proc//stat`、macOS 读一次 `ps` 表、Windows 查一次进程句柄——`signalProcess` 在投递信号前就地取这道围栏。观察无法代替它:观察把原始的「PID 与起始时间」配对保留了下来,因此被复用的 PID 仍会与之匹配,并领走本该发给已退出进程的信号。逐目标读取围栏还让一次失败的读取只损失一个目标,而不是整轮拆卸的其余部分,这正是[同步退出清理约定](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)的要求。 `signalMembers` 与 `waitForMembers` 在一轮没有成员时直接返回、不做任何捕获,因此没有派生子进程的命令,其拆卸扫描不付表读取代价。 +受支持的 Linux 终端在绑定 user-systemd scope 后,正常拆卸会绕过这些观察扫描,改为等待 manager 拥有的成员事实。进程表快照继续负责前台/就绪检查,以及 fallback 或同步宿主退出清理。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这项分工。 + 平台差异体现在快照如何构建,而不在它承诺什么: - **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。 @@ -62,7 +64,7 @@ Status: implemented 一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。 -拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。 +fallback 与同步宿主退出拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。受支持的 Linux 正常拆卸使用 scope 成员事实,不执行这项扫描。 快照是一个时间点视图,该类型的文档也这样声明。`waitForMembers` 每轮重新捕获是因为观察变化正是它的用途;任何信号都不会从一份已捕获的视图上做决定。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 58cf47ce3e..07de32b014 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: c4fad5a11be450a382a2fc66dbae81e30a0e84c8 -2026-08-28-subprocess-native-containment.zh.md: c8185e0e90a69440c2441fc95258261bb091300b +2026-08-28-subprocess-native-containment.md: 937c148101388aa49aac4826120218ff4a67a0e8 +2026-08-28-subprocess-native-containment.zh.md: 156d3a84d3a3a92a605866ee94a07616d18a09ee diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index c4fad5a11b..937c148101 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -28,17 +28,17 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one direct-result branch. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. The shared Win32 layer resolves those CRT descriptors through UCRT `_get_osfhandle`, passes the resulting OS handles through `STARTF_USESTDHANDLES`, and preserves a separately resolved `CreateProcessW` application path without changing the original argv entry. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 is always a pipe; when stdin is ignored, the parent destroys its writer before sending the start request so the target receives EOF without changing descriptor positions. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. -The parent settles `.done` only after a strict direct-result message and the existing stdout/stderr close or bounded-drain barrier. A later Job query or range-settlement failure rejects only `waitForExit()`. IPC loss before that `.done` barrier rejects `.done` as runner infrastructure failure; IPC loss afterward leaves the completed direct result unchanged but still rejects range settlement. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof. +The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof. ### Private dispatch and protocol 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, terminate, `target-exit`, `spawn-error`, `runner-error`, and `start-cancelled` messages. Errors carry only bounded Node-shaped fields. 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 three result branches: `target-exit` with a numeric `exitCode`, `error` with bounded Node-shaped fields, and payload-free `start-cancelled`; the parent derives `signal: null`. A cancellation reason never crosses the wire, so the parent preserves 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. ### Fallback and cleanup @@ -54,7 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification -- Provider and protocol suites pin synchronous NUL rejection before launch side effects, strict request/result decoding, target cwd and complete environment restoration, private-variable collision, Linux PATH lookup with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, the three scope-establishment states, all four Windows result branches, start cancellation, result-send and IPC-disconnect failures, isolated carrier-descriptor closure, 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, the three scope-establishment states, and exactly-once PTY managed-owner cleanup. +- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the always-piped ignored-stdin carrier, 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index c8185e0e90..156d3a84d3 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -28,17 +28,17 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 direct-result 分支。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。共享 Win32 层通过 UCRT `_get_osfhandle` 解析这些 CRT 描述符,经 `STARTF_USESTDHANDLES` 传递对应 OS handle,并保留单独解析的 `CreateProcessW` application path,而不改变原始 argv 项。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。fd 4 始终是 pipe;忽略 stdin 时,parent 会在发送 start request 前销毁写端,使 target 在不改变描述符位置的情况下收到 EOF。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 -parent 只有在收到严格 direct-result message,并且既有 stdout/stderr close 或有界 drain barrier 完成后才结算 `.done`。后续 Job query 或 range settlement failure 只会使 `waitForExit()` reject。在该 `.done` barrier 之前发生 IPC loss 会使 `.done` 以 runner infrastructure failure reject;之后发生 IPC loss 会保留已经完成的 direct result,但仍使 range settlement reject。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。 +parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdout/stderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。 ### 私有分派与协议 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、`target-exit`、`spawn-error`、`runner-error` 与 `start-cancelled` 消息。错误只携带有界的 Node-shaped 字段。缺失、额外、类型错误或未知字段都会 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`、携带有界 Node-shaped 字段的 `error`,以及无载荷的 `start-cancelled`;parent 会派生 `signal: null`。取消 reason 不跨 wire 传递,因此 parent 会原样保留第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。 ### Fallback 与 cleanup @@ -54,7 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification -- provider 与协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/result 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 的 Linux PATH 查找、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、全部 4 个 Windows result 分支、startup cancellation、result-send 与 IPC-disconnect failure、隔离 carrier 描述符关闭、stdio settlement、active-process 完全停稳,以及唯一 handle cleanup。 +- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。 +- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、始终使用 pipe 的 ignored-stdin carrier、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 保留。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 587e1ba665..2ce2a2cff6 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.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/feature/2026-06-30-hook-protocol-lib.md -2026-06-30-hook-protocol-lib.md: db00d0f880a073fbdd53a591e5d2412f620ecedc -2026-06-30-hook-protocol-lib.zh.md: 7b0208ec95a8e6ecdf9ebca5badab871a9747b43 +2026-06-30-hook-protocol-lib.md: c2549fb26fe5dfbf97362bc595bc77a599800fc7 +2026-06-30-hook-protocol-lib.zh.md: d9d7f018295950fde41ddb09caf8e2b481b85079 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index db00d0f880..c2549fb26f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -16,7 +16,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo **Shared (here):** - **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. -- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.shell` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-shell`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin API an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). +- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.shell` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, [provider-managed range termination](../architecture/2026-08-28-subprocess-native-containment.md), and timeout the protocol needs, and `dsh-shell`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin API an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. - **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compaction/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and owner-defined execution relation stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 7b0208ec95..d9d7f01829 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -16,7 +16,7 @@ Status: implemented **共享(本库):** - **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符表示多个精确匹配备选项),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件上的 matcher 字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。 -- **执行** — `runHook(bash, hook, options)`。通过 `ctx.shell` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-shell` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件 API。它将桥接插件构建的 payload 序列化到 stdin(仅 CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 +- **执行** — `runHook(bash, hook, options)`。通过 `ctx.shell` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、[由提供方管理的范围终止](../architecture/2026-08-28-subprocess-native-containment.zh.md)和超时,正是协议所需的能力;`dsh-shell` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件 API。它将桥接插件构建的 payload 序列化到 stdin(仅 CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。 - **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议约定 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.zh.md))。 - **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,停止状态从首个 `continue:false` 起保持不变,阻止原因以 `\n\n` 拼接,上下文/system-messages 按序累积。 - **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compaction/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与由所有者定义的执行关系在各桥接插件间保持一致。`appendHookResult` 还负责定义持久化记录的语义:decision 字符串(钩子解析出的 decision,否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。 diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index dcc666c622..889c7fb4e7 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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/testing/2026-07-22-cross-platform-test-fixtures.md -2026-07-22-cross-platform-test-fixtures.md: 9114481543d6cae1661cbed70868eddb2faa09fc -2026-07-22-cross-platform-test-fixtures.zh.md: 710ec5887f02b5c3c71d69f16ab726323501ac92 +2026-07-22-cross-platform-test-fixtures.md: 603b3f0bcce62bd6ed7f7e89e3f2d65493532045 +2026-07-22-cross-platform-test-fixtures.zh.md: 89faa55a1e39ba1d9ea6b8b5e2c50a47d278942a diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 9114481543..603b3f0bcc 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown calls `SubprocessHandle.terminate()` and then awaits `waitForExit()`, so it neither inspects a PID nor chooses a platform kill command. The local provider's [native-containment decision](../architecture/2026-08-28-subprocess-native-containment.md) owns Linux scope, Windows Job, and disclosed fallback mechanics. A read-only provider query retries once only when its selected pooled transport fails before or during that query and teardown completes; errors from a still-live server are not replayed, and a query failure plus a teardown failure remain visible together. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer hook. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Managed-range failures enter through the subprocess handle's wait operation rather than platform kill commands, so disposal keeps a failed quiescence proof visible to the caller. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 710ec5887f..89faa55a1e 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会触发重试。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会调用 `SubprocessHandle.terminate()`,然后等待 `waitForExit()`,因此它既不检查 PID,也不选择平台终止命令。本地提供方的[原生 containment 决策](../architecture/2026-08-28-subprocess-native-containment.zh.md)负责 Linux scope、Windows Job 与已声明的 fallback 机制。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效,且资源清理完成时重试一次;服务器仍存活时返回的错误不会触发重试,查询失败与资源清理失败会一并保留。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,可确保 dispose(资源释放)在有限时间内完成,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,资源释放逻辑仍能观察到该失败。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器钩子注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。受管范围失败通过子进程句柄的等待操作注入,而不是平台终止命令,因此资源释放会向调用方暴露无法证明完全停稳的失败。 diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 465000ba37..b0f4e02816 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -96,7 +96,6 @@ class FakeReader implements SubprocessOutputReader { * abort→terminate escalation. */ class FakeHandle implements SubprocessHandle { - readonly pid = 4242 readonly stdin = undefined readonly stdout = undefined readonly stderr = undefined diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 39f7f561b7..aa0f3bb11b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -273,7 +273,6 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { this.internals.platform ?? process.platform, owner, scope?.resolveOutcome, - scope?.cleanup, ) this.terminals.add(handle) const release = async (): Promise => { diff --git a/packages/subprocess/subprocess-local/src/linux-execve.ts b/packages/subprocess/subprocess-local/src/linux-execve.ts index b3b40ba64b..a5bed81059 100644 --- a/packages/subprocess/subprocess-local/src/linux-execve.ts +++ b/packages/subprocess/subprocess-local/src/linux-execve.ts @@ -32,7 +32,7 @@ function systemError(errno: number, syscall: string, path?: string): Error { const subject = path === undefined ? syscall : `${syscall} '${path}'` const error = Object.assign(new Error(`${code}: ${detail}, ${subject}`), { code, - errno, + errno: uvError, syscall, }) return path === undefined ? error : Object.assign(error, { path }) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 94576a1a0d..7e6966969b 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -111,7 +111,7 @@ export function runnerStdio( 'ignore', 'ignore', 'ipc', - targetStdio[0], + 'pipe', spec.stdio.stdout === 'inherit' ? 1 : 'pipe', spec.stdio.stderr === 'inherit' ? 2 : 'pipe', ] diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 606bea11e8..2e12f9f808 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -11,7 +11,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' -import { constants as osConstants, tmpdir } from 'node:os' +import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join } from 'node:path' const STARTUP_ERROR_TEMPORARY = '.startup-error.tmp' @@ -36,8 +36,7 @@ export interface SerializedRunnerError { /** A Linux pre-exec failure published atomically beside its consumed request. */ export type LinuxStartupError = - | { type: 'spawn-error'; error: SerializedRunnerError } - | { type: 'runner-error'; error: SerializedRunnerError } + { type: 'error'; error: SerializedRunnerError } /** The only parent-to-runner start message on Windows. */ export interface WindowsStartRequest { @@ -53,9 +52,8 @@ export interface WindowsTerminateRequest { /** Exactly one direct-result branch is sent by a connected Windows runner. */ export type WindowsRunnerResult = - | { type: 'target-exit'; exitCode: number | null; signal: NodeJS.Signals | null } - | { type: 'spawn-error'; error: SerializedRunnerError } - | { type: 'runner-error'; error: SerializedRunnerError } + | { type: 'target-exit'; exitCode: number } + | { type: 'error'; error: SerializedRunnerError } | { type: 'start-cancelled' } /** Private paths owned by one Linux ordinary or PTY spawn. */ @@ -100,10 +98,10 @@ function parseErrorResult(value: Record): LinuxStartupError { if (!hasExactKeys(value, ['type', 'error']) || !isSerializedRunnerError(value.error)) { throw new Error('subprocess runner emitted an invalid error result') } - if (value.type !== 'spawn-error' && value.type !== 'runner-error') { + if (value.type !== 'error') { throw new Error('subprocess runner emitted an unknown error result') } - return { type: value.type, error: value.error } + return { type: 'error', error: value.error } } /** @@ -203,7 +201,7 @@ export function isWindowsTerminateRequest(value: unknown): value is WindowsTermi } /** - * Strictly parse one of the four Windows direct-result branches. + * Strictly parse one of the three Windows direct-result branches. * @param value - untrusted IPC payload. * @returns validated direct-result message. */ @@ -215,19 +213,17 @@ export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult { if (!hasExactKeys(value, ['type'])) throw new Error('subprocess runner emitted an invalid start-cancelled result') return { type: 'start-cancelled' } } - if (value.type === 'spawn-error' || value.type === 'runner-error') return parseErrorResult(value) + if (value.type === 'error') return parseErrorResult(value) if (value.type === 'target-exit') { - const validExitCode = value.exitCode === null - || (typeof value.exitCode === 'number' && Number.isSafeInteger(value.exitCode) && value.exitCode >= 0) - const validSignal = value.signal === null - || (typeof value.signal === 'string' && Object.hasOwn(osConstants.signals, value.signal)) - if (!hasExactKeys(value, ['type', 'exitCode', 'signal']) || !validExitCode || !validSignal) { + const validExitCode = typeof value.exitCode === 'number' + && Number.isSafeInteger(value.exitCode) + && value.exitCode >= 0 + if (!hasExactKeys(value, ['type', 'exitCode']) || !validExitCode) { throw new Error('subprocess runner emitted an invalid target-exit result') } return { type: 'target-exit', - exitCode: value.exitCode as number | null, - signal: value.signal as NodeJS.Signals | null, + exitCode: value.exitCode as number, } } throw new Error(`subprocess runner emitted an unknown Windows result: ${value.type}`) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 1ec4f04dd6..e2abddce32 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/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 { posix } from 'node:path' import { closeHandleChecked, isJobEmpty, @@ -67,27 +66,24 @@ const defaultInternals: SpawnRunnerInternals = { closeHandleChecked, } -function replaceEnvironment(target: NodeJS.ProcessEnv, env: Record): void { - for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) - Object.assign(target, env) -} - function asSpawnError(error: unknown, program: string, args: readonly string[]): SerializedRunnerError { const serialized = serializeRunnerError(error) - const code = error instanceof Win32Error - ? error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + const win32Code = error instanceof Win32Error ? error.win32Code : undefined + const code = win32Code === undefined + ? serialized.code + : win32Code === 2 || win32Code === 3 || win32Code === 267 ? 'ENOENT' - : error.win32Code === 5 - ? 'EACCES' - : error.win32Code === 193 + : win32Code === 5 + ? 'EPERM' + : win32Code === 193 ? 'EFTYPE' : 'UNKNOWN' - : serialized.code if (code === undefined) return serialized return { ...serialized, message: `spawn ${program} ${code}: ${serialized.message}`, code, + ...win32Code === 5 ? { errno: -4048 } : {}, syscall: `spawn ${program}`, path: program, spawnargs: [...args], @@ -128,7 +124,10 @@ function execLinuxTarget( const path = request.env.PATH ?? '/usr/bin:/bin' let permissionFailure: Error | undefined for (const directory of path.split(':')) { - const candidate = posix.resolve(request.cwd, directory, program) + const root = directory.startsWith('/') + ? directory + : `${request.cwd}${request.cwd.endsWith('/') ? '' : '/'}${directory}` + const candidate = `${root}${root.endsWith('/') ? '' : '/'}${program}` try { return execLinuxFile(candidate, argv, request.env, internals) } catch (error) { @@ -155,7 +154,7 @@ function runLinux( try { request = consumeLinuxLaunchRequest(files.requestPath) } catch (error) { - writeLinuxStartupError(files, { type: 'runner-error', error: serializeRunnerError(error) }) + writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(error) }) host.exitCode = 127 return } @@ -164,7 +163,7 @@ function runLinux( execLinuxTarget({ ...request, cwd: host.cwd() }, argv, internals) } catch (error) { writeLinuxStartupError(files, { - type: 'spawn-error', + type: 'error', error: asSpawnError(error, argv[0] as string, argv.slice(1)), }) host.exitCode = 127 @@ -267,13 +266,13 @@ class WindowsJobRunner { undefined, { ...this.host.env }, ) - replaceEnvironment(this.host.env, request.env) this.api = this.internals.loadWin32ProcessBindings() const spawned = this.internals.spawnCurrentTokenJobProcess(this.api, { command: command as string, applicationName, args, cwd: request.cwd, + env: request.env, stdio: { stdin: 4, stdout: 5, stderr: 6 }, }) this.processHandle = spawned.process @@ -287,7 +286,7 @@ class WindowsJobRunner { } catch (error) { if (!this.committed && error instanceof Win32Error && error.api === 'CreateProcessW') { await this.publishTerminalResult({ - type: 'spawn-error', + type: 'error', error: asSpawnError(error, this.argv[0] as string, this.argv.slice(1)), }, 0) return @@ -331,7 +330,7 @@ class WindowsJobRunner { if (exitCode !== undefined) { this.internals.closeHandleChecked(this.api, this.processHandle, 'ordinary direct process') this.processHandle = undefined - void this.publishTerminalResult({ type: 'target-exit', exitCode, signal: null }) + void this.publishTerminalResult({ type: 'target-exit', exitCode }) } } if (this.jobHandle !== undefined && this.internals.isJobEmpty(this.api, this.jobHandle)) { @@ -370,7 +369,7 @@ class WindowsJobRunner { if (!this.resultStarted) { this.resultStarted = true try { - await sendMessage(this.host, { type: 'runner-error', error: serializeRunnerError(error) }) + await sendMessage(this.host, { type: 'error', error: serializeRunnerError(error) }) this.resultDelivered = true } catch { // The disconnected parent observes runner infrastructure failure. @@ -443,7 +442,7 @@ export async function reportSpawnRunnerFailure( host: RunnerHost = process, ): Promise { if (selection === WINDOWS_RUNNER_SELECTION) { - try { await sendMessage(host, { type: 'runner-error', error: serializeRunnerError(error) }) } catch { /* No transport remains. */ } + try { await sendMessage(host, { type: 'error', error: serializeRunnerError(error) }) } catch { /* No transport remains. */ } host.exitCode = 127 if (host.connected) host.disconnect() return @@ -451,7 +450,7 @@ export async function reportSpawnRunnerFailure( if (selection !== undefined) { try { const files: LinuxLaunchFiles = linuxLaunchFilesFromLocator(selection) - writeLinuxStartupError(files, { type: 'runner-error', error: serializeRunnerError(error) }) + writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(error) }) } catch { // The parent will report an unconsumed request or missing runner result. } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 5a3772c3a8..0a4f4564bd 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -342,7 +342,7 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) + throw spec.signal.reason } const [program] = spec.argv if (program === undefined || program.length === 0) { @@ -483,6 +483,7 @@ export function bindManagedProcess( let graceTimer: ReturnType | undefined let rangeExitObserved = false let rangeExitObservation: Promise | undefined + let directResultLatched = false let settled = false const scheduleOwnerCleanup = (): boolean => { @@ -563,17 +564,19 @@ export function bindManagedProcess( resolve(outcome) } const fail = (error: unknown): void => { - if (settled) return + if (settled || directResultLatched) return settled = true terminate() stopCollectors() cleanup() - /* v8 ignore next -- managed launch promises reject with Error instances. */ - const failure = error instanceof Error ? error : new Error(String(error)) - reject(failure) + // Preserve the exact parent-local AbortSignal reason, including null or undefined. + // 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 diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 15ec3fc7cb..d8330bf71a 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -43,6 +43,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly dataDisposable: IDisposable private readonly exitDisposable: IDisposable private cleanup: Promise | undefined + private managedOwnerCleaned = false private exited = false private trackedDescendants: ProcessIdentity[] = [] /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */ @@ -61,7 +62,6 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly platform: NodeJS.Platform = process.platform, private readonly managedOwner?: BoundProcessOwner, private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome, - private readonly cleanupManagedProtocol?: () => void, ) { this.pid = terminal.pid this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) @@ -317,7 +317,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.dataDisposable.dispose() this.exitDisposable.dispose() } finally { - void this.done.finally(() => { this.cleanupManagedProtocol?.() }).catch(() => {}) + void this.done.finally(() => { this.cleanupManagedOwner(this.managedOwner as BoundProcessOwner) }).catch(() => {}) } return } @@ -335,6 +335,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.exitDisposable.dispose() } + private cleanupManagedOwner(owner: BoundProcessOwner): void { + if (this.managedOwnerCleaned) return + this.managedOwnerCleaned = true + owner.cleanup?.() + } + private async closeManagedRange(owner: BoundProcessOwner): Promise { owner.signal('SIGTERM') const observation = owner.waitForExit() diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 845fb7e43a..74ab3206eb 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -57,18 +57,22 @@ export function probeWindowsJob(internals: WindowsJobInternals = {}): boolean { class WindowsJobOwner implements BoundProcessOwner { private cancellationReason: unknown + private cancellationReasonSet = false private terminationSent = false constructor( private readonly runner: RunnerProcess, private readonly exited: Promise, - private readonly failInfrastructure: (error: Error) => void, + private readonly failInfrastructure: (error: unknown) => void, ) { void this.exited.catch(() => {}) } signal(_signal: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void { - if (this.cancellationReason === undefined) this.cancellationReason = cancellationReason + if (!this.cancellationReasonSet) { + this.cancellationReason = cancellationReason + this.cancellationReasonSet = true + } if (this.terminationSent || !this.runner.connected) return this.terminationSent = true try { @@ -78,13 +82,15 @@ class WindowsJobOwner implements BoundProcessOwner { this.terminateForHostExit() }) } catch (error) { - this.failInfrastructure(error instanceof Error ? error : new Error(String(error))) + this.failInfrastructure(error) this.terminateForHostExit() } } startCancellationReason(): unknown { - return this.cancellationReason ?? new Error('subprocess target start was cancelled') + return this.cancellationReasonSet + ? this.cancellationReason + : new Error('subprocess target start was cancelled') } async waitForExit(): Promise { @@ -119,13 +125,15 @@ export function launchWindowsJob( env: runnerEnvironment(WINDOWS_RUNNER_SELECTION), stdio: runnerStdio(spec, true), }) as RunnerProcess + const targetStdin = child.stdio[4] as Writable | null + if (spec.stdio.stdin === 'ignore') targetStdin?.destroy() const direct = Promise.withResolvers() const infrastructure = Promise.withResolvers() const rangeExit = Promise.withResolvers() let resultSeen = false let infrastructureFailed = false - const failInfrastructure = (error: Error): void => { + const failInfrastructure = (error: unknown): void => { if (infrastructureFailed) return infrastructureFailed = true infrastructure.reject(error) @@ -144,16 +152,13 @@ export function launchWindowsJob( try { result = parseWindowsRunnerResult(value) } catch (error) { - /* v8 ignore next -- the closed parser raises Error instances for every malformed shape; - * conversion only defends future internal regressions. */ - const failure = error instanceof Error ? error : new Error(String(error)) - failInfrastructure(failure) + failInfrastructure(error) owner.terminateForHostExit() return } resultSeen = true if (result.type === 'target-exit') { - direct.resolve({ exitCode: result.exitCode, signal: result.signal }) + direct.resolve({ exitCode: result.exitCode, signal: null }) } else if (result.type === 'start-cancelled') { direct.reject(owner.startCancellationReason()) } else { @@ -194,14 +199,13 @@ export function launchWindowsJob( owner.terminateForHostExit() }) } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)) - failInfrastructure(failure) - direct.reject(failure) + failInfrastructure(error) + direct.reject(error) owner.terminateForHostExit() } return { - stdin: child.stdio[4] as Writable | null, + stdin: spec.stdio.stdin === 'ignore' ? null : targetStdin, stdout: child.stdio[5] as Readable | null, stderr: child.stdio[6] as Readable | null, direct: direct.promise, diff --git a/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts b/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts index 1b29fa0e09..c3679f60b0 100644 --- a/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts @@ -49,7 +49,7 @@ describe('Linux libc execve binding', () => { expect(errno).toHaveBeenCalledOnce() expect(failure).toMatchObject({ code: 'ENOENT', - errno: 2, + errno: -2, syscall: 'execve', path: '/missing/tool', }) @@ -69,7 +69,7 @@ describe('Linux libc execve binding', () => { const { loadLinuxExecve } = await import('../src/linux-execve.ts') expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({ code: 'EBADF', - errno: 9, + errno: -9, syscall: 'fcntl', })) expect(nativeFcntl).toHaveBeenCalledExactlyOnceWith(0, 1, 0) @@ -91,7 +91,7 @@ describe('Linux libc execve binding', () => { const { loadLinuxExecve } = await import('../src/linux-execve.ts') expect(() => loadLinuxExecve()('/bin/tool', ['tool'], {})).toThrow(expect.objectContaining({ code: 'EIO', - errno: 5, + errno: -5, syscall: 'fcntl', })) expect(nativeFcntl.mock.calls).toEqual([ diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 091f55a4b6..2422528df5 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -228,7 +228,7 @@ describe('Linux scope establishment and quiescence', () => { const files = linuxLaunchFilesFromLocator(requestPath) unlinkSync(requestPath) writeLinuxStartupError(files, { - type: 'spawn-error', + type: 'error', error: { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT' }, }) child.exit(127, null) @@ -408,7 +408,7 @@ describe('Linux PTY bootstrap reuse', () => { const files = linuxLaunchFilesFromLocator(requestPath) unlinkSync(requestPath) writeLinuxStartupError(files, { - type: 'spawn-error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' }, + type: 'error', error: { name: 'Error', message: 'bad cwd', code: 'ENOENT' }, }) expect(() => scope.resolveOutcome({ exitCode: 127, signal: null })).toThrow('bad cwd') scope.cleanup() diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 1a3a0681a9..d0b2751b56 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -138,12 +138,12 @@ describe('closed runner protocol', () => { const failure = Object.assign(new Error('spawn missing'), { name: 'SpawnError', code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['x'], }) - writeLinuxStartupError(files, { type: 'spawn-error', error: serializeRunnerError(failure) }) + writeLinuxStartupError(files, { type: 'error', error: serializeRunnerError(failure) }) if (process.platform !== 'win32') { expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600) } const result = readLinuxStartupError(files.startupErrorPath) - expect(result).toMatchObject({ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } }) + expect(result).toMatchObject({ type: 'error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } }) expect(deserializeRunnerError(result!.error)).toMatchObject({ name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', errno: -2, }) @@ -190,19 +190,21 @@ describe('closed runner protocol', () => { expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true) expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false) expect(parseWindowsRunnerResult({ type: 'start-cancelled' })).toEqual({ type: 'start-cancelled' }) - expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: null, signal: 'SIGTERM' })).toEqual({ - type: 'target-exit', exitCode: null, signal: 'SIGTERM', + expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: 7 })).toEqual({ + type: 'target-exit', exitCode: 7, }) - expect(parseWindowsRunnerResult({ type: 'spawn-error', error: { name: 'Error', message: 'bad' } })).toEqual({ - type: 'spawn-error', error: { name: 'Error', message: 'bad' }, + expect(parseWindowsRunnerResult({ type: 'error', error: { name: 'Error', message: 'bad' } })).toEqual({ + type: 'error', error: { name: 'Error', message: 'bad' }, }) for (const invalid of [ null, { type: 'unknown' }, { type: 'start-cancelled', payload: 1 }, - { type: 'target-exit', exitCode: -1, signal: null }, - { type: 'target-exit', exitCode: 0, signal: 'NOPE' }, - { type: 'runner-error', error: { name: 'Error', message: 'bad', cause: {} } }, + { type: 'target-exit', exitCode: -1 }, + { type: 'target-exit', exitCode: 0, signal: null }, + { type: 'spawn-error', error: { name: 'Error', message: 'bad' } }, + { type: 'runner-error', error: { name: 'Error', message: 'bad' } }, + { type: 'error', error: { name: 'Error', message: 'bad', cause: {} } }, ]) expect(() => parseWindowsRunnerResult(invalid)).toThrow() }) @@ -262,11 +264,13 @@ describe('runner launch inputs', () => { expect(runnerStdio({ ...spec, stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'pipe' }, - }, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'ignore', 1, 'pipe']) + }, true)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 1, 'pipe']) }) it('validates every Node-baseline NUL location before launch', () => { expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' }) + expect(targetEnvironment({ ...spec, env: { '=C:': 'C:\\target' } })) + .toMatchObject({ '=C:': 'C:\\target' }) expect(validateTerminalTarget({ ...spec, rows: 24, cols: 80 })).toMatchObject({ EXPLICIT: 'yes' }) for (const invalid of [ { ...spec, argv: ['node\0'] }, @@ -410,7 +414,7 @@ describe('Linux one-shot exec bootstrap', () => { expect(execve.mock.calls[0]?.[1]).toEqual(['tool', 'literal arg']) expect(execve.mock.calls[0]?.[2]).toMatchObject({ [SUBPROCESS_RUNNER_ENV]: 'target-value' }) expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ - type: 'spawn-error', error: { code: 'ENOENT', path: 'tool' }, + type: 'error', error: { code: 'ENOENT', path: 'tool' }, }) }) @@ -427,6 +431,40 @@ describe('Linux one-shot exec bootstrap', () => { '/base/work/bin/tool', '/base/work/tool', ]) + + const rootFiles = track(createLinuxLaunchFiles({ cwd: '/', env: { PATH: '' } })) + const rootExecve = vi.fn((): never => { + throw Object.assign(new Error('not found'), { code: 'ENOENT' }) + }) + await runSpawnRunner( + rootFiles.requestPath, + ['--', 'tool'], + hostArgument(new FakeRunnerHost()), + internals({ execve: rootExecve }), + ) + expect(rootExecve).toHaveBeenCalledWith('/tool', ['tool'], { PATH: '' }) + }) + + it('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') + const target = join(root, 'target') + mkdirSync(cwd) + mkdirSync(join(target, 'child'), { recursive: true }) + writeFileSync(join(target, 'tool'), '') + symlinkSync(join(target, 'child'), join(cwd, 'link'), 'dir') + const files = track(createLinuxLaunchFiles({ cwd, env: { PATH: 'link/..' } })) + const execve = vi.fn((file: string): never => { + throw Object.assign(new Error(existsSync(file) ? 'selected' : 'not found'), { + code: existsSync(file) ? 'EIO' : 'ENOENT', + }) + }) + await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) + expect(execve).toHaveBeenCalledWith(`${cwd}/link/../tool`, ['tool'], { PATH: 'link/..' }) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ + type: 'error', error: { code: 'EIO' }, + }) }) it('retries ENOEXEC through /bin/sh with the resolved file and original arguments', async () => { @@ -445,7 +483,7 @@ describe('Linux one-shot exec bootstrap', () => { ['/bin/sh', ['/bin/sh', '/work/bin/tool', 'literal arg'], { PATH: 'bin' }], ]) expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ - type: 'spawn-error', error: { code: 'EIO', path: 'tool' }, + type: 'error', error: { code: 'EIO', path: 'tool' }, }) }) @@ -454,7 +492,7 @@ describe('Linux one-shot exec bootstrap', () => { const execve = vi.fn((_file: string) => { throw Object.assign(new Error('denied'), { code: 'EACCES' }) }) await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool']) - expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'spawn-error', error: { code: 'EACCES' } }) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error', error: { code: 'EACCES' } }) const explicit = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) const fatal = vi.fn(() => { throw Object.assign(new Error('bad executable'), { code: 'EIO' }) }) @@ -466,7 +504,7 @@ describe('Linux one-shot exec bootstrap', () => { execve: vi.fn(() => { throw new Error('unclassified failure') }), })) expect(readLinuxStartupError(stackless.startupErrorPath)).toMatchObject({ - type: 'spawn-error', error: { message: 'unclassified failure' }, + type: 'error', error: { message: 'unclassified failure' }, }) const searched = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) @@ -477,20 +515,20 @@ describe('Linux one-shot exec bootstrap', () => { execve: searchedExecve as never, })) expect(readLinuxStartupError(searched.startupErrorPath)).toMatchObject({ - type: 'spawn-error', error: { code: 'EIO' }, + type: 'error', error: { code: 'EIO' }, }) }) - it('publishes request/protocol failures as runner errors', async () => { + it('publishes request and early protocol failures through the single error branch', async () => { const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) writeFileSync(files.requestPath, '{') await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals()) - expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'runner-error' }) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error' }) const early = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) await reportSpawnRunnerFailure(early.requestPath, new Error('delimiter failed'), hostArgument(new FakeRunnerHost())) expect(readLinuxStartupError(early.startupErrorPath)).toMatchObject({ - type: 'runner-error', error: { message: 'delimiter failed' }, + type: 'error', error: { message: 'delimiter failed' }, }) }) }) @@ -500,7 +538,7 @@ describe('Windows Job runner protocol owner', () => { for (const [win32Code, code] of [ [3, 'ENOENT'], [267, 'ENOENT'], - [5, 'EACCES'], + [5, 'EPERM'], [193, 'EFTYPE'], [999, 'UNKNOWN'], ] as const) { @@ -508,7 +546,10 @@ describe('Windows Job runner protocol owner', () => { await runWindows(host, internals({ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }), })) - expect(host.sent).toMatchObject([{ type: 'spawn-error', error: { code } }]) + expect(host.sent).toMatchObject([{ + type: 'error', + error: { code, ...win32Code === 5 ? { errno: -4048 } : {} }, + }]) } }) @@ -548,6 +589,7 @@ describe('Windows Job runner protocol owner', () => { ) expect(native.spawnCurrentTokenJobProcess).toHaveBeenCalledWith(expect.anything(), { command: 'tool.exe', applicationName: 'C:\\resolved\\tool.exe', args: ['literal arg'], cwd: 'C:\\target', + env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' }, stdio: { stdin: 4, stdout: 5, stderr: 6 }, }) expect(closeFileDescriptor).toHaveBeenCalledTimes(3) @@ -556,9 +598,9 @@ describe('Windows Job runner protocol owner', () => { expect(closeFileDescriptor).toHaveBeenNthCalledWith(3, 6) expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 10n, 'ordinary direct process') expect(native.closeHandleChecked).toHaveBeenCalledWith(expect.anything(), 20n, 'ordinary process Job') - expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }]) expect(host.exitCode).toBe(0) - expect(host.env).toEqual({ TARGET: 'yes', dsh_subprocess_runner: 'restored' }) + expect(host.env).toEqual({ SAFE: 'bootstrap' }) }) it('closes every target carrier before the first Windows poll', async () => { @@ -579,26 +621,26 @@ describe('Windows Job runner protocol owner', () => { }) await runWindows(host, native) expect(events).toEqual(['close:4', 'close:5', 'close:6', 'interval', 'poll']) - expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }]) expect(host.exitCode).toBe(0) } finally { interval.mockRestore() } }) - it('exhausts spawn-error, runner-error, and payload-free start-cancelled', async () => { + it('exhausts target-exit, error, and payload-free start-cancelled', async () => { const spawnHost = new FakeRunnerHost() await runWindows(spawnHost, internals({ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }), })) - expect(spawnHost.sent).toMatchObject([{ type: 'spawn-error', error: { code: 'ENOENT', path: 'tool.exe' } }]) + expect(spawnHost.sent).toMatchObject([{ type: 'error', error: { code: 'ENOENT', path: 'tool.exe' } }]) expect(spawnHost.exitCode).toBe(0) const runnerHost = new FakeRunnerHost() await runWindows(runnerHost, internals({ loadWin32ProcessBindings: vi.fn(() => { throw new Error('binding failed') }), })) - expect(runnerHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'binding failed' } }]) + expect(runnerHost.sent).toMatchObject([{ type: 'error', error: { message: 'binding failed' } }]) expect(runnerHost.exitCode).toBe(127) const cancelledHost = new FakeRunnerHost() @@ -710,7 +752,7 @@ describe('Windows Job runner protocol owner', () => { await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) failedHost.emit('message', { type: 'terminate' }) await failedRun - expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'terminate Job failed' } }]) + expect(failedHost.sent).toMatchObject([{ type: 'error', error: { message: 'terminate Job failed' } }]) }) it('finishes when a later poll observes Job emptiness after result delivery', async () => { @@ -727,7 +769,7 @@ describe('Windows Job runner protocol owner', () => { ) host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) await running - expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0, signal: null }]) + expect(host.sent).toEqual([{ type: 'target-exit', exitCode: 0 }]) expect(native.isJobEmpty).toHaveBeenCalledTimes(2) }) @@ -736,7 +778,7 @@ describe('Windows Job runner protocol owner', () => { await runWindows(failedHost, internals({ pollProcessExit: vi.fn(() => { throw new Error('poll failed') }), })) - expect(failedHost.sent).toMatchObject([{ type: 'runner-error', error: { message: 'poll failed' } }]) + expect(failedHost.sent).toMatchObject([{ type: 'error', error: { message: 'poll failed' } }]) let tick: (() => void) | undefined const interval = vi.spyOn(globalThis, 'setInterval').mockImplementation((callback: () => void) => { @@ -795,7 +837,7 @@ describe('Windows Job runner protocol owner', () => { it('fails closed for malformed or duplicate start messages and disconnected reporting', async () => { const malformed = new FakeRunnerHost() await runWindows(malformed, internals(), { type: 'start', cwd: 'C:\\x', env: {}, extra: true }) - expect(malformed.sent).toMatchObject([{ type: 'runner-error' }]) + expect(malformed.sent).toMatchObject([{ type: 'error' }]) const duplicate = new FakeRunnerHost() const native = internals({ pollProcessExit: vi.fn(() => undefined), isJobEmpty: vi.fn(() => false) }) @@ -803,7 +845,7 @@ describe('Windows Job runner protocol owner', () => { duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} }) duplicate.emit('message', { type: 'start', cwd: 'C:\\x', env: {} }) await running - expect(duplicate.sent).toMatchObject([{ type: 'runner-error' }]) + expect(duplicate.sent).toMatchObject([{ type: 'error' }]) const raced = new FakeRunnerHost() const racedRun = runSpawnRunner(WINDOWS_RUNNER_SELECTION, ['--', 'tool.exe'], hostArgument(raced), internals()) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 9d78af0b4b..98008dad14 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -313,11 +313,19 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('throws when the signal is already aborted before spawn', () => { - const controller = new AbortController() - controller.abort('too late') - expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) - .toThrow(/aborted before spawn: too late/) + it('throws the raw signal reason when already aborted before spawn', () => { + for (const reason of ['too late', null] as const) { + const controller = new AbortController() + controller.abort(reason) + let thrown = false + try { + validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) + } catch (error) { + thrown = true + expect(error).toBe(reason) + } + expect(thrown).toBe(true) + } }) it('rejects with a spawn error for a nonexistent cwd', async () => { @@ -778,6 +786,54 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina }) describe('coverage seams', () => { + it('preserves a non-Error managed direct rejection', async () => { + const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() + const handle = bindManagedProcess(spec('true', { + graceMs: 1, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }), { + stdin: null, + stdout: null, + stderr: null, + direct: direct.promise, + owner: { + signal: vi.fn(), + waitForExit: async () => { throw new Error('range unavailable') }, + terminateForHostExit: vi.fn(), + }, + }) + + direct.reject(null) + await expect(handle.done).rejects.toBeNull() + await Promise.resolve() + }) + + it('keeps an infrastructure failure authoritative when it precedes the direct result', async () => { + const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() + const infrastructureFailure = Promise.withResolvers() + const failure = new Error('runner failed before its result') + 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: { + signal: vi.fn(), + waitForExit: async () => {}, + terminateForHostExit: vi.fn(), + }, + }) + + 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() @@ -887,7 +943,7 @@ describe('coverage seams', () => { }) it('delivers an already-aborted managed spawn reason before target settlement', async () => { - const reason = new Error('caller cancelled') + const reason = null const controller = new AbortController() controller.abort(reason) const signal = vi.fn() @@ -1216,17 +1272,21 @@ describe('argv validation', () => { }) describe('abort edge cases', () => { - it('reports a fallback reason for reason-less pre-aborted signals', () => { - // Real AbortControllers always set a DOMException reason; signal-like - // objects from other libraries may not — the fallback covers them. + it('throws an undefined reason from a reason-less pre-aborted signal unchanged', () => { const bare = { aborted: true, reason: undefined, addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) }) - .toThrow(/aborted before spawn: aborted/) + let thrown = false + try { + validateSubprocessSpec(spec('echo hi', { signal: bare })) + } catch (error) { + thrown = true + expect(error).toBeUndefined() + } + expect(thrown).toBe(true) }) it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index e4b79ca2a4..97d35c1c97 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -220,14 +220,15 @@ describe('LocalTerminalHandle', () => { expect(pty.kills).toEqual([]) }) - it('rejects managed outcome conversion and cleans its protocol after exit', async () => { + it('rejects managed outcome conversion and cleans through its owner exactly once', async () => { const pty = new FakePty() const failure = new Error('invalid bootstrap outcome') - const cleanupManagedProtocol = vi.fn() + const cleanup = vi.fn() const owner: BoundProcessOwner = { signal: vi.fn(), waitForExit: async () => {}, terminateForHostExit: vi.fn(), + cleanup, } const handle = new LocalTerminalHandle( pty.asPty(), @@ -236,13 +237,33 @@ describe('LocalTerminalHandle', () => { 'linux', owner, () => { throw failure }, - cleanupManagedProtocol, ) pty.emitExit() await expect(handle.done).rejects.toBe(failure) await expect(handle.terminate()).resolves.toBeUndefined() - await vi.waitFor(() => { expect(cleanupManagedProtocol).toHaveBeenCalledOnce() }) + await expect(handle.terminate()).resolves.toBeUndefined() + await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() }) + }) + + it('runs owner cleanup once after repeated failed managed termination attempts', async () => { + const pty = new FakePty() + const failure = new Error('scope stayed unreadable') + const cleanup = vi.fn() + const owner: BoundProcessOwner = { + signal: vi.fn(), + waitForExit: vi.fn(async () => { throw failure }), + terminateForHostExit: vi.fn(), + cleanup, + } + const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10, 'linux', owner) + + await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed') + await expect(handle.terminate()).rejects.toThrow('terminal managed-range cleanup failed') + expect(cleanup).not.toHaveBeenCalled() + pty.emitExit() + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() }) }) it('waits for the node-pty exit event after the managed range becomes empty', async () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index fc268d159c..8e58b3492f 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -22,11 +22,15 @@ class FakeChild extends EventEmitter { sendError: Error | undefined 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) }) return true @@ -45,9 +49,12 @@ const spec = { graceMs: 100, } as const -function launch(child = new FakeChild()) { +function launch( + child = new FakeChild(), + request: Parameters[0] = spec, +) { const spawn = vi.fn(() => child) - const result = launchWindowsJob(spec, { TARGET: 'yes' }, { + const result = launchWindowsJob(request, { TARGET: 'yes' }, { spawn: spawn as never, runnerInvocation: ['C:\\node.exe', 'C:\\runner.js'], }) @@ -77,7 +84,7 @@ describe('Windows Job capability', () => { const result = isolated.launchWindowsJob(spec, { TARGET: 'yes' }) expect(spawn).toHaveBeenCalledOnce() - child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) + child.emit('message', { type: 'target-exit', exitCode: 0 }) child.connected = false child.emit('close', 0, null) await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) @@ -134,29 +141,46 @@ describe('Windows parent runner contract', () => { expect(result.stderr).toBe(child.targetStderr) }) + it('always carries fd 4 and closes ignored stdin before sending start', () => { + const child = new FakeChild() + const ignored = { + ...spec, + stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' }, + } 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], + })) + expect(child.stdinDestroyedAtStart).toBe(true) + expect(result.stdin).toBeNull() + }) + 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, signal: null }) + child.emit('message', { type: 'target-exit', exitCode: 7 }) await expect(result.direct).resolves.toEqual({ exitCode: 7, signal: null }) child.connected = false child.emit('close', 0, null) await expect(result.owner.waitForExit()).resolves.toBeUndefined() }) - it('rejects done when runner failure precedes stdio settlement', async () => { + it('latches target-exit while stdio drains and leaves later runner failure to waitForExit', async () => { const { child, result } = launch() const handle = bindManagedProcess(spec, result) - child.emit('message', { type: 'target-exit', exitCode: 7, signal: null }) + child.emit('message', { type: 'target-exit', exitCode: 7 }) await Promise.resolve() child.connected = false child.emit('close', 127, null) - await expect(handle.done).rejects.toThrow('exit code 127') + child.targetStdout.end() + child.targetStderr.end() + await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null }) + await expect(handle.waitForExit()).rejects.toThrow('exit code 127') }) - it('maps spawn-error and start-cancelled without requiring public target identity', async () => { + it('maps error and preserves raw start-cancellation reasons', async () => { const spawned = launch() spawned.child.emit('message', { - type: 'spawn-error', error: { name: 'Error', message: 'missing', code: 'ENOENT' }, + type: 'error', error: { name: 'Error', message: 'missing', code: 'ENOENT' }, }) await expect(spawned.result.direct).rejects.toMatchObject({ code: 'ENOENT' }) spawned.child.connected = false @@ -173,6 +197,15 @@ describe('Windows parent runner contract', () => { cancelled.child.emit('close', 0, null) await expect(cancelled.result.owner.waitForExit()).resolves.toBeUndefined() + const nullCancelled = launch() + nullCancelled.result.owner.signal('SIGTERM', null) + nullCancelled.result.owner.signal('SIGKILL', new Error('later reason')) + nullCancelled.child.emit('message', { type: 'start-cancelled' }) + await expect(nullCancelled.result.direct).rejects.toBeNull() + nullCancelled.child.connected = false + nullCancelled.child.emit('close', 0, null) + await expect(nullCancelled.result.owner.waitForExit()).resolves.toBeUndefined() + const implicit = launch() implicit.child.emit('message', { type: 'start-cancelled' }) await expect(implicit.result.direct).rejects.toThrow('target start was cancelled') @@ -181,10 +214,10 @@ describe('Windows parent runner contract', () => { await expect(implicit.result.owner.waitForExit()).resolves.toBeUndefined() }) - it('rejects direct and wait for runner-error or abnormal runner exit', async () => { + it('rejects direct and wait for runner error or abnormal runner exit', async () => { const failed = launch() failed.child.emit('message', { - type: 'runner-error', error: { name: 'Error', message: 'Job assignment failed' }, + type: 'error', error: { name: 'Error', message: 'Job assignment failed' }, }) await expect(failed.result.direct).rejects.toThrow('Job assignment failed') failed.child.connected = false @@ -205,13 +238,13 @@ describe('Windows parent runner contract', () => { it('fails closed on malformed/duplicate result, runner spawn error, and start-send error', async () => { const malformed = launch() - malformed.child.emit('message', { type: 'target-exit', exitCode: -1, signal: null }) + 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') const duplicate = launch() - duplicate.child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) - duplicate.child.emit('message', { type: 'target-exit', exitCode: 0, signal: null }) + 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) @@ -240,8 +273,8 @@ describe('Windows parent runner contract', () => { nonError.throwOnSendCall = 1 nonError.sendThrown = 'start send failed' const nonErrorResult = launch(nonError).result - await expect(nonErrorResult.direct).rejects.toThrow('start send failed') - await expect(nonErrorResult.infrastructureFailure).rejects.toThrow('start send failed') + await expect(nonErrorResult.direct).rejects.toBe('start send failed') + await expect(nonErrorResult.infrastructureFailure).rejects.toBe('start send failed') }) it('fails infrastructure and kills the runner when termination delivery fails', async () => { @@ -260,7 +293,7 @@ describe('Windows parent runner contract', () => { throwingChild.sendThrown = 'terminate send threw' const throwing = launch(throwingChild) throwing.result.owner.signal('SIGTERM') - await expect(throwing.result.infrastructureFailure).rejects.toThrow('terminate send threw') + await expect(throwing.result.infrastructureFailure).rejects.toBe('terminate send threw') expect(throwing.child.killed).toEqual(['SIGKILL']) const errorChild = new FakeChild() diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index de6dd4a638..4f85e01409 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: 1affe4e0ba0622be02edc6f06f1c6dd88b24a60c -README.zh.md: 6d908142cc6e721c40225773c14dcdd07e196e50 +README.md: d2c17f79e839b5ebba09a76f6f45bd3b19f8724f +README.zh.md: da639d75ec824cf45319baf83c6194ab80ef80d0 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index 1affe4e0ba..d2c17f79e8 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -24,11 +24,11 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and ## Behavior -- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll`, `advapi32.dll`, and `ucrtbase.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. -- **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the inherited environment block, checked return values, and handle cleanup. +- **One reusable ABI owner** — `abi.ts` owns the Win32 constants and x64 layout values consumed by both process paths. `ffi.ts` lazily loads `kernel32.dll` and `advapi32.dll`, verifies `STARTUPINFOW` and `PROCESS_INFORMATION`, exposes typed operations and error formatting, and lets sandbox policy bind its remaining APIs through the same loaded libraries. +- **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the restricted-token null-environment policy, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions.stdio` names three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` resolves their OS handles through UCRT `_get_osfhandle`, temporarily marks those handles inheritable, passes them through `STARTF_USESTDHANDLES`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. A separately resolved `applicationName` preserves Node's executable search without changing the original command-line argv entry. It returns the direct-process handle and Job to the runner, which can close its carrier descriptors without touching Node's own standard streams. +- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions` requires a resolved `applicationName`, the complete target environment, and three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` descriptor table to recover the three OS handles, temporarily marks them inheritable, and passes them through `STARTF_USESTDHANDLES`. It sends a sorted UTF-16LE environment block with `CREATE_UNICODE_ENVIRONMENT`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. The original command-line argv entry remains unchanged, and the runner can close its carrier descriptors without touching Node's own standard streams. - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. @@ -43,7 +43,7 @@ The process, stdio, and Job constants plus selected structure sizes and offsets g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants. +The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes the `STARTUPINFOW` reserved-table offsets, pointer and handle widths, Unicode-environment flag, and the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants. ## Model Experience @@ -68,7 +68,7 @@ The package contributes no stable request prefix, so it does not invalidate mode - **Windows-only native loading** — importing the generic types is portable, but resolving the binding table loads Windows DLLs and fails on other hosts. Cross-platform tests inject a binding table instead of loading native APIs. - **No public process service** — the package intentionally does not wrap its primitives in Cordis or Node streams. A consumer must own its policy, async scheduling, output limits, cancellation, and final handle closure. -- **Inherited environment only** — process creation passes a null environment block. The sandbox establishes changes through `SetEnvironmentVariableW` first because passing an explicit block through Koffi makes `CreateProcessAsUserW` fail with `ERROR_INVALID_PARAMETER`. Other callers that need environment changes must establish them before invoking the primitive or use their own runner process. +- **Restricted-token null environment** — the `CreateProcessAsUserW` sandbox primitives pass a null environment block and establish changes through `SetEnvironmentVariableW` first because an explicit block through Koffi fails with `ERROR_INVALID_PARAMETER`. The ordinary `CreateProcessW` runner instead requires a complete target environment and passes a sorted, double-NUL-terminated UTF-16LE block, including `=X:` drive entries, without mutating its own environment. - **No standalone process API** — the package exposes the operations current sandbox and ordinary-runner consumers need, but it does not own Node streams, public handles, output policy, cancellation, or durable state. - **Create-to-assignment interruption** — the target starts suspended and cannot execute before Job assignment, but an external termination of the runner in the narrow interval between process creation and assignment can leave the suspended target behind. The package does not claim atomic Job attachment. - **Header evidence is architecture-specific** — the committed ABI probe and layout constants cover the repository's current 64-bit Windows targets. A new pointer width or incompatible Windows ABI requires updating the probe before support is claimed. diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index 6d908142cc..da639d75ec 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -24,11 +24,11 @@ kind: "package-library" ## Behavior -- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll`、`advapi32.dll` 与 `ucrtbase.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 -- **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、继承环境块、返回值检查与句柄清理。 +- **唯一可复用 ABI owner** — `abi.ts` 拥有两条 process 路径消费的 Win32 常量与 x64 布局值。`ffi.ts` 懒加载 `kernel32.dll` 与 `advapi32.dll`,核验 `STARTUPINFOW` 和 `PROCESS_INFORMATION`,提供带类型的操作与错误格式化,并让 sandbox policy 通过同一组已加载库绑定剩余 API。 +- **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、restricted-token 空环境策略、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions.stdio` 指定三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 通过 UCRT `_get_osfhandle` 解析对应 OS handle,临时把这些 handle 设为可继承,通过 `STARTF_USESTDHANDLES` 传入它们,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。单独解析的 `applicationName` 保留 Node 的 executable 搜索语义,同时不改变原始命令行 argv 项。它把 direct-process handle 与 Job 返回给 runner,后者可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。 +- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions` 要求已解析的 `applicationName`、完整 target 环境,以及三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 描述符表以取得三个 OS handle,临时把它们设为可继承,并通过 `STARTF_USESTDHANDLES` 传入。它使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序后的 UTF-16LE 环境块,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。原始命令行 argv 项保持不变,runner 也可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 @@ -43,7 +43,7 @@ process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`ve g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。 +Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定 `STARTUPINFOW` 保留表偏移、指针与 handle 宽度、Unicode 环境标志,以及用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。 ## Model Experience @@ -68,7 +68,7 @@ Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载 - **仅在 Windows 原生加载** — 导入通用类型可跨平台进行,但解析绑定表会加载 Windows DLL,并在其他宿主失败。跨平台测试注入绑定表,不加载原生 API。 - **没有公共进程服务** — 本包刻意不把原语包装成 Cordis 或 Node streams。消费方必须拥有自己的策略、异步调度、输出上限、取消与最终句柄关闭。 -- **只继承环境** — 进程创建传入空环境块。sandbox 会先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会使 `CreateProcessAsUserW` 以 `ERROR_INVALID_PARAMETER` 失败。其他需要改写环境的调用方必须在调用原语前建立环境,或使用自己的 runner 进程。 +- **restricted-token 空环境** — `CreateProcessAsUserW` sandbox 原语传入空环境块,并先通过 `SetEnvironmentVariableW` 建立改动,因为经 Koffi 传入显式环境块会以 `ERROR_INVALID_PARAMETER` 失败。ordinary `CreateProcessW` runner 则要求完整 target 环境,并传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改自身环境。 - **没有 standalone process API** — 本包只暴露当前 sandbox 与 ordinary-runner consumer 所需的操作,不拥有 Node streams、公共 handle、output policy、cancellation 或 durable state。 - **创建到分配之间的中断** — 目标以 suspended 状态启动,不能在 Job 分配前执行,但 runner 若在进程创建到分配之间的极窄区间被外力终止,可能留下 suspended target。本包不声明原子 Job 附加保证。 - **header 证据限定架构** — 已提交的 ABI probe 与布局常量覆盖仓库当前 64 位 Windows 目标。支持新的指针宽度或不兼容 Windows ABI 前,必须先更新 probe。 diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index d3b2eafcb4..3a9eb6ee60 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -10,6 +10,8 @@ export const INFINITE = 0xFFFFFFFF export const WAIT_TIMEOUT = 258 /** CreateProcess flag that prevents user code from running before resume. */ export const CREATE_SUSPENDED = 0x4 +/** CreateProcess flag selecting a UTF-16 environment block. */ +export const CREATE_UNICODE_ENVIRONMENT = 0x400 /** GetStdHandle selector for standard input. */ export const STD_INPUT_HANDLE = -10 /** GetStdHandle selector for standard output. */ @@ -44,3 +46,11 @@ export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 export const STARTUPINFOW_SIZE = 104 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 +/** libuv CRT descriptor flag marking an inherited descriptor as open. */ +export const CRT_FOPEN = 0x01 +/** Largest descriptor count accepted by libuv's inherited stdio table. */ +export const MAX_INHERITED_STDIO_DESCRIPTORS = 256 +/** Byte width of the descriptor count at the start of libuv's stdio table. */ +export const INHERITED_STDIO_COUNT_SIZE = 4 +/** x64 HANDLE width in libuv's inherited stdio table. */ +export const INHERITED_STDIO_HANDLE_SIZE = 8 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index 2664d9b130..a9ec2c3f92 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -18,8 +18,6 @@ export interface Win32BindingContext { readonly kernel32: ReturnType /** Token and security APIs. */ readonly advapi32: ReturnType - /** Universal CRT file-descriptor operations. */ - readonly ucrtbase: ReturnType /** Bind one stdcall function from a loaded Win32 library. */ readonly bind: ( library: ReturnType, @@ -55,6 +53,12 @@ export interface ProcessInfoOutput { dwThreadId: number } +/** STARTUPINFOW fields used to recover libuv's inherited descriptor table. */ +export interface StartupInfoOutput { + cbReserved2: number + lpReserved2: NativePtr | null +} + /** Generic Win32 calls consumed by restricted-token sandbox process operations. */ export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number @@ -90,7 +94,7 @@ export interface Win32ProcessBindings { threadAttributes: null, inheritHandles: number, creationFlags: number, - environment: null, + environment: Buffer | null, currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, @@ -122,9 +126,9 @@ export interface Win32ProcessBindings { getStdHandle(stdHandle: number): NativePtr } -/** Generic Win32 calls plus current-process CRT descriptor lookup. */ +/** Generic Win32 calls plus the inherited startup-information reader. */ export interface CurrentTokenProcessBindings extends Win32ProcessBindings { - getOsfHandle(fileDescriptor: number): number | bigint + getStartupInfoW(startupInfo: NativePtr): void } /** Koffi STARTUPINFOW layout. */ @@ -218,6 +222,15 @@ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInp koffi.encode(startupInfo, STARTUPINFOW, fields) } +/** + * Decode the inherited-descriptor fields from STARTUPINFOW. + * @param startupInfo - struct filled by GetStartupInfoW. + * @returns reserved buffer size and pointer. + */ +export function decodeStartupInfo(startupInfo: NativePtr): StartupInfoOutput { + return koffi.decode(startupInfo, STARTUPINFOW) as StartupInfoOutput +} + /** * Allocate a zeroed PROCESS_INFORMATION. * @returns allocated struct pointer. @@ -243,20 +256,19 @@ function bindingContext(): Win32BindingContext { if (cachedContext !== undefined) return cachedContext const kernel32 = koffi.load('kernel32.dll') const advapi32 = koffi.load('advapi32.dll') - const ucrtbase = koffi.load('ucrtbase.dll') const bind = ( lib: ReturnType, name: string, result: Ptr | string, args: Array, ): unknown => lib.func('__stdcall', name, result, args) - cachedContext = { kernel32, advapi32, ucrtbase, bind } + cachedContext = { kernel32, advapi32, bind } return cachedContext } function bindings(): CurrentTokenProcessBindings { if (cached !== undefined) return cached - const { kernel32, advapi32, ucrtbase, bind } = bindingContext() + const { kernel32, advapi32, bind } = bindingContext() cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), @@ -289,7 +301,7 @@ function bindings(): CurrentTokenProcessBindings { terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), terminateJobObject: bind(kernel32, 'TerminateJobObject', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), - getOsfHandle: ucrtbase.func('_get_osfhandle', 'intptr_t', ['int']), + getStartupInfoW: bind(kernel32, 'GetStartupInfoW', 'void', [koffi.pointer(STARTUPINFOW)]), } as unknown as CurrentTokenProcessBindings return cached } diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index ad37f9abce..340044f742 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -9,6 +9,7 @@ import { allocUint32, decodeProcessInfo, decodePtr, + decodeStartupInfo, decodeUint32, encodeStartupInfo, isNullPtr, @@ -53,6 +54,19 @@ export function buildCommandLine(program: string, args: readonly string[]): stri return [program, ...args].map(quoteArg).join(' ') } +function compareWindowsEnvironmentKeys( + [left]: readonly [string, string], + [right]: readonly [string, string], +): number { + return left.toUpperCase().localeCompare(right.toUpperCase(), 'en-US') +} + +function encodeWindowsEnvironment(env: Readonly>): Buffer { + const entries = Object.entries(env).sort(compareWindowsEnvironmentKeys) + const strings = entries.map(([key, value]) => `${key}=${value}`) + return Buffer.from(`${strings.join('\0')}\0\0`, 'utf16le') +} + interface ProcessSpawnOptions { /** Executable argv entry passed through CreateProcess. */ command: string @@ -65,7 +79,9 @@ interface ProcessSpawnOptions { /** Ordinary process creation inputs used by the local Win32 runner. */ export interface CurrentTokenProcessSpawnOptions extends ProcessSpawnOptions { /** Resolved executable path passed separately from the preserved argv entry. */ - applicationName?: string + applicationName: string + /** Complete target environment passed without mutating the runner. */ + env: Readonly> /** Runner CRT descriptors carrying target stdin, stdout, and stderr. */ stdio: CurrentTokenStdioFileDescriptors } @@ -357,10 +373,48 @@ function targetCarrierHandles( api: CurrentTokenProcessBindings, descriptors: CurrentTokenStdioFileDescriptors, ): ProcessStandardHandles { + let startupInfo: NativePtr | undefined + let table: Buffer + try { + startupInfo = allocStartupInfo() + api.getStartupInfoW(startupInfo) + const inherited = decodeStartupInfo(startupInfo) + if (isNullPtr(inherited.lpReserved2)) { + throw new Error('GetStartupInfoW returned no inherited stdio table') + } + if (inherited.cbReserved2 < abi.INHERITED_STDIO_COUNT_SIZE) { + throw new Error('GetStartupInfoW returned a truncated inherited stdio table') + } + table = Buffer.from(koffi.view(inherited.lpReserved2, inherited.cbReserved2)) + } finally { + freeNative(startupInfo) + } + const count = table.readUInt32LE(0) + if (count > abi.MAX_INHERITED_STDIO_DESCRIPTORS) { + throw new Error(`inherited stdio table declares unsupported descriptor count ${String(count)}`) + } + const requiredSize = abi.INHERITED_STDIO_COUNT_SIZE + + count + + count * abi.INHERITED_STDIO_HANDLE_SIZE + if (table.length < requiredSize) { + throw new Error('GetStartupInfoW returned a truncated inherited stdio table') + } const get = (fileDescriptor: number, label: string): NativePtr => { - const handle = api.getOsfHandle(fileDescriptor) - if (handle !== -1 && handle !== -1n) return BigInt(handle) as NativePtr - throw new Error(`_get_osfhandle failed for target ${label} fd ${String(fileDescriptor)}`) + if (fileDescriptor >= count) { + throw new Error(`inherited stdio table is missing target ${label} fd ${String(fileDescriptor)}`) + } + const flags = table[abi.INHERITED_STDIO_COUNT_SIZE + fileDescriptor] as number + if ((flags & abi.CRT_FOPEN) === 0) { + throw new Error(`inherited stdio table marks target ${label} fd ${String(fileDescriptor)} closed`) + } + const handleOffset = abi.INHERITED_STDIO_COUNT_SIZE + + count + + fileDescriptor * abi.INHERITED_STDIO_HANDLE_SIZE + const handle = table.readBigUInt64LE(handleOffset) + if (handle === 0n || handle === 0xFFFFFFFFFFFFFFFFn || handle === 0xFFFFFFFFFFFFFFFEn) { + throw new Error(`inherited stdio table contains an invalid handle for target ${label} fd ${String(fileDescriptor)}`) + } + return handle as NativePtr } return { stdin: get(descriptors.stdin, 'stdin'), @@ -496,15 +550,16 @@ export function spawnCurrentTokenJobProcess( options: CurrentTokenProcessSpawnOptions, ): SpawnedJobProcess { const commandLine = buildCommandLine(options.command, options.args) + const environment = encodeWindowsEnvironment(options.env) return spawnJobProcess(api, options, () => targetCarrierHandles(api, options.stdio), 'CreateProcessW', (startupInfo, processInfo) => api.createProcessW( - options.applicationName ?? null, + options.applicationName, commandLine, null, null, 1, - abi.CREATE_SUSPENDED, - null, + abi.CREATE_SUSPENDED | abi.CREATE_UNICODE_ENVIRONMENT, + environment, options.cwd, startupInfo, processInfo, @@ -516,8 +571,8 @@ export function spawnCurrentTokenJobProcess( * @param api - active binding table. */ export function probeCurrentTokenJobSupport(api: CurrentTokenProcessBindings): void { - if (typeof api.getOsfHandle !== 'function') { - throw new Error('current-token Job support requires UCRT _get_osfhandle') + 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') diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 7885782548..5aa6b3e1f8 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -11,6 +11,10 @@ import { } from '../src/index.ts' import { CREATE_SUSPENDED, + CREATE_UNICODE_ENVIRONMENT, + CRT_FOPEN, + INHERITED_STDIO_COUNT_SIZE, + INHERITED_STDIO_HANDLE_SIZE, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, @@ -18,11 +22,51 @@ import { } from '../src/abi.ts' import { PROCESS_INFORMATION, STARTUPINFOW } from '../src/ffi.ts' import type { + CurrentTokenProcessSpawnOptions, CurrentTokenProcessBindings, NativePtr, } from '../src/index.ts' +function inheritedStdioTable(count = 7): Buffer { + const table = Buffer.alloc( + INHERITED_STDIO_COUNT_SIZE + count + count * INHERITED_STDIO_HANDLE_SIZE, + ) + table.writeUInt32LE(count, 0) + for (let fileDescriptor = 0; fileDescriptor < count; fileDescriptor++) { + table[INHERITED_STDIO_COUNT_SIZE + fileDescriptor] = CRT_FOPEN + table.writeBigUInt64LE( + BigInt(100 + fileDescriptor), + INHERITED_STDIO_COUNT_SIZE + count + fileDescriptor * INHERITED_STDIO_HANDLE_SIZE, + ) + } + return table +} + +function startupInfo( + table: Buffer | null, + size = table?.length ?? 0, +): CurrentTokenProcessBindings['getStartupInfoW'] { + return vi.fn((startup: NativePtr) => { + koffi.encode(startup, STARTUPINFOW, { cbReserved2: size, lpReserved2: table }) + }) +} + +function options( + overrides: Partial = {}, +): CurrentTokenProcessSpawnOptions { + return { + command: 'probe.exe', + applicationName: 'C:\\resolved\\probe.exe', + args: [], + cwd: 'C:\\work', + env: {}, + stdio: { stdin: 4, stdout: 5, stderr: 6 }, + ...overrides, + } +} + function api(overrides: Partial = {}): CurrentTokenProcessBindings { + const table = inheritedStdioTable() return { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), @@ -31,7 +75,7 @@ function api(overrides: Partial = {}): CurrentToken return 1 }), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), - getOsfHandle: vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor)), + getStartupInfoW: startupInfo(table), setHandleInformation: vi.fn(() => 1), createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { @@ -83,25 +127,24 @@ describe('ordinary Job process operations', () => { resumeThread: vi.fn(() => { events.push('resume'); return 0 }), closeHandle: vi.fn((handle: NativePtr) => { events.push(`close:${handle}`); return 1 }), }) - expect(spawnCurrentTokenJobProcess(bindings, { - command: 'probe.exe', - applicationName: 'C:\\resolved\\probe.exe', + expect(spawnCurrentTokenJobProcess(bindings, options({ args: ['literal $VALUE', 'a b'], - cwd: 'C:\\work', - stdio: { stdin: 4, stdout: 5, stderr: 6 }, - })).toEqual({ pid: 1234, process: 60n, job: 50n }) + env: { ZED: 'last', '=C:': 'C:\\work', alpha: 'first' }, + }))).toEqual({ pid: 1234, process: 60n, job: 50n }) + const environment = createProcessW.mock.calls[0]?.[6] as Buffer expect(createProcessW).toHaveBeenCalledWith( 'C:\\resolved\\probe.exe', 'probe.exe "literal $VALUE" "a b"', null, null, 1, - CREATE_SUSPENDED, - null, + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, + environment, 'C:\\work', expect.anything(), expect.anything(), ) + expect(environment.toString('utf16le')).toBe('=C:=C:\\work\0alpha=first\0ZED=last\0\0') expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) expect(events).toContain('close:61') @@ -111,12 +154,7 @@ describe('ordinary Job process operations', () => { const bindings = api({ createProcessW: vi.fn(() => 0) }) let caught: unknown try { - spawnCurrentTokenJobProcess(bindings, { - command: 'missing.exe', - args: [], - cwd: 'C:\\work', - stdio: { stdin: 4, stdout: 5, stderr: 6 }, - }) + spawnCurrentTokenJobProcess(bindings, options({ command: 'missing.exe' })) } catch (error) { caught = error } @@ -125,10 +163,8 @@ describe('ordinary Job process operations', () => { it('resolves the target carrier descriptors and restores their handle flags', () => { let startup: Record | undefined - const getOsfHandle = vi.fn((fileDescriptor: number) => BigInt(67 + fileDescriptor)) const setHandleInformation = vi.fn(() => 1) const bindings = api({ - getOsfHandle, setHandleInformation, createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { startup = koffi.decode(infoPtr, STARTUPINFOW) as Record @@ -141,17 +177,11 @@ describe('ordinary Job process operations', () => { return 1 }), }) - expect(spawnCurrentTokenJobProcess(bindings, { - command: 'probe.exe', - args: [], - cwd: 'C:\\work', - stdio: { stdin: 4, stdout: 5, stderr: 6 }, - })).toEqual({ pid: 1234, process: 60n, job: 50n }) - expect(getOsfHandle.mock.calls.map(([fileDescriptor]) => fileDescriptor)).toEqual([4, 5, 6]) - expect(startup).toMatchObject({ hStdInput: 71n, hStdOutput: 72n, hStdError: 73n }) + expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n }) + expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n }) expect(setHandleInformation.mock.calls).toEqual([ - [71n, 1, 1], [72n, 1, 1], [73n, 1, 1], - [71n, 1, 0], [72n, 1, 0], [73n, 1, 0], + [104n, 1, 1], [105n, 1, 1], [106n, 1, 1], + [104n, 1, 0], [105n, 1, 0], [106n, 1, 0], ]) }) @@ -211,19 +241,44 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('rejects an unavailable carrier descriptor or CRT binding before target creation', () => { - const closeHandle = vi.fn(() => 1) - const missingDescriptor = api({ closeHandle, getOsfHandle: vi.fn(() => -1) }) - expect(() => spawnCurrentTokenJobProcess(missingDescriptor, { - command: 'probe.exe', - args: [], - cwd: 'C:\\work', - stdio: { stdin: 4, stdout: 5, stderr: 6 }, - })).toThrow('_get_osfhandle failed for target stdin fd 4') - expect(closeHandle).toHaveBeenCalledWith(50n) + it('strictly validates the inherited libuv descriptor table before target creation', () => { + const expectFailure = ( + getStartupInfoW: CurrentTokenProcessBindings['getStartupInfoW'], + message: string, + ): void => { + const closeHandle = vi.fn(() => 1) + expect(() => spawnCurrentTokenJobProcess(api({ closeHandle, getStartupInfoW }), options())) + .toThrow(message) + expect(closeHandle).toHaveBeenCalledWith(50n) + } - const missingBinding = api({ getOsfHandle: undefined as never }) + expectFailure(startupInfo(null), 'no inherited stdio table') + expectFailure(startupInfo(Buffer.alloc(3)), 'truncated inherited stdio table') + + const excessive = Buffer.alloc(INHERITED_STDIO_COUNT_SIZE) + excessive.writeUInt32LE(257, 0) + expectFailure(startupInfo(excessive), 'unsupported descriptor count 257') + + const truncated = Buffer.alloc(INHERITED_STDIO_COUNT_SIZE) + truncated.writeUInt32LE(7, 0) + expectFailure(startupInfo(truncated), 'truncated inherited stdio table') + expectFailure(startupInfo(inheritedStdioTable(6)), 'missing target stderr fd 6') + + const closed = inheritedStdioTable() + closed[INHERITED_STDIO_COUNT_SIZE + 4] = 0 + expectFailure(startupInfo(closed), 'marks target stdin fd 4 closed') + + for (const invalid of [0n, 0xFFFFFFFFFFFFFFFFn, 0xFFFFFFFFFFFFFFFEn]) { + const table = inheritedStdioTable() + table.writeBigUInt64LE( + invalid, + INHERITED_STDIO_COUNT_SIZE + 7 + 4 * INHERITED_STDIO_HANDLE_SIZE, + ) + 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 UCRT _get_osfhandle') + .toThrow('current-token Job support requires GetStartupInfoW') }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 3cbb883ccf..6e1d4b4e60 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -10,6 +10,8 @@ int wmain() P(sizeof(HANDLE)); P(sizeof(STARTUPINFOW)); P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, cbReserved2)); + P(offsetof(STARTUPINFOW, lpReserved2)); P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); @@ -18,6 +20,7 @@ int wmain() P(offsetof(PROCESS_INFORMATION, hThread)); P(offsetof(PROCESS_INFORMATION, dwProcessId)); P(CREATE_SUSPENDED); + P(CREATE_UNICODE_ENVIRONMENT); P(STARTF_USESTDHANDLES); P(HANDLE_FLAG_INHERIT); P(INFINITE); @@ -40,7 +43,10 @@ int wmain() static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(sizeof(int) == 4, "libuv stdio descriptor count size"); + static_assert(sizeof(HANDLE) == 8, "libuv stdio HANDLE size"); static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); + static_assert(CREATE_UNICODE_ENVIRONMENT == 0x400, "Unicode environment flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); static_assert(HANDLE_FLAG_INHERIT == 0x1, "inherit flag"); static_assert(WAIT_TIMEOUT == 258, "zero-time wait timeout"); diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index 3a8dca4a38..240aeab248 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -80,8 +80,7 @@ describe('Typert-backed Cordis catalog', () => { ) } } - const runtimeApi = projector.renderRuntimeApi(model) - expect(runtimeApi).toBe( + expect(projector.renderRuntimeApi(model)).toBe( expected('packages/extensions/tool-cordis/src/api-catalog.ts'), ) }) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index e4ea8485c6..b34496db90 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1375,13 +1375,14 @@ const [runtime, target, cwd, targetScript] = process.argv.slice(2) const child = spawn(runtime, ['--', target, '-c', targetScript], { cwd, env: { ...process.env, DSH_SUBPROCESS_RUNNER: 'windows' }, - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 'pipe'], }) const messages = [] let stdout = '' let stderr = '' -child.stdout.on('data', chunk => { stdout += chunk.toString() }) -child.stderr.on('data', chunk => { stderr += chunk.toString() }) +child.stdio[4].destroy() +child.stdio[5].on('data', chunk => { stdout += chunk.toString() }) +child.stdio[6].on('data', chunk => { stderr += chunk.toString() }) child.on('message', message => { messages.push(message) }) const result = await new Promise((resolve, reject) => { child.once('error', reject) @@ -1416,7 +1417,7 @@ process.stdout.write(JSON.stringify({ ...result, messages, stdout, stderr })) expected = { "exitCode": 0, "signal": None, - "messages": [{"type": "target-exit", "exitCode": 7, "signal": None}], + "messages": [{"type": "target-exit", "exitCode": 7}], "stdout": "", "stderr": "", } From 23af4410f60df2c441efa0f8e9899eeb2517a177 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 06:14:15 +0800 Subject: [PATCH 082/110] fix(subprocess): resolve source runner loader absolutely --- .../subprocess-local/src/runner-launch.ts | 2 +- .../tests/spawn-runner.spec.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 7e6966969b..d6a5a932a9 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -31,7 +31,7 @@ export function spawnRunnerInvocation(): RunnerInvocation { return [ process.execPath, '--import', - 'tsx/esm', + import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./bin.ts', import.meta.url)), ] } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index d0b2751b56..08d0daf546 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events' +import { spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, @@ -291,7 +292,7 @@ describe('runner launch inputs', () => { it('resolves the source runner entry and checks concrete paths without executing it', () => { const invocation = spawnRunnerInvocation() expect(invocation[0]).toBe(process.execPath) - expect(invocation).toContain('tsx/esm') + expect(invocation).toContain(import.meta.resolve('tsx/esm')) expect(runnerInvocationAvailable(invocation)).toBe(true) expect(runnerInvocationAvailable(['/definitely/missing-dsh-runner'])).toBe(false) expect(runnerInvocationAvailable(['node'])).toBe(true) @@ -305,6 +306,21 @@ describe('runner launch inputs', () => { } }) + it('loads the source runner from an isolated application cwd', () => { + const directory = mkdtempSync(join(tmpdir(), 'dsh-runner-cwd-')) + scratch.push(directory) + const invocation = spawnRunnerInvocation() + const env = runnerEnvironment('unused') + Reflect.deleteProperty(env, SUBPROCESS_RUNNER_ENV) + const launched = spawnSync(invocation[0], invocation.slice(1), { + cwd: directory, + env, + encoding: 'utf8', + }) + expect(launched.status).toBe(127) + expect(launched.stderr).not.toContain('ERR_MODULE_NOT_FOUND') + }) + it('bounds non-Error and stackless runner failures', () => { expect(serializeRunnerError('plain failure')).toMatchObject({ name: 'Error', message: 'plain failure', From 6e7b152de7e3d4c5e19b974220e8dc9b1da977f5 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 07:57:59 +0800 Subject: [PATCH 083/110] fix(subprocess): preserve native runner contracts --- .../subprocess-local/src/linux-scope.ts | 4 +- .../subprocess-local/src/managed-owner.ts | 2 - .../subprocess-local/src/runner-launch.ts | 14 ++- .../subprocess/subprocess-local/src/spawn.ts | 5 - .../subprocess-local/src/windows-job.ts | 42 ++++---- .../tests/linux-execve.spec.ts | 2 +- .../tests/native-windows.spec.ts | 35 +++++- .../tests/spawn-runner-built.e2e.ts | 2 +- .../tests/spawn-runner.spec.ts | 12 ++- .../subprocess-local/tests/spawn.spec.ts | 48 ++------- .../tests/windows-job.spec.ts | 100 +++++++++++++----- .../subprocess/win32-process/src/process.ts | 3 - .../tests/ordinary-process.spec.ts | 4 - 13 files changed, 161 insertions(+), 112 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 92d7841aa6..4075f42577 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/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, }) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index da438a37c8..cdb915bfa6 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -22,8 +22,6 @@ export interface ManagedProcessLaunch { stderr: Readable | null direct: Promise owner: BoundProcessOwner - /** Rejects if runner ownership is lost before `.done` completes its stdio barrier. */ - infrastructureFailure?: Promise } /** diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index d6a5a932a9..5f6d34554a 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/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', ] diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 0a4f4564bd..4d8019df18 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -483,7 +483,6 @@ export function bindManagedProcess( let graceTimer: ReturnType | undefined let rangeExitObserved = false let rangeExitObservation: Promise | 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 diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 74ab3206eb..06e8fb4a76 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/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() - const infrastructure = Promise.withResolvers() const rangeExit = Promise.withResolvers() 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, } } diff --git a/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts b/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts index c3679f60b0..5f4904366b 100644 --- a/packages/subprocess/subprocess-local/tests/linux-execve.spec.ts +++ b/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) => { diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 9d83a3adf4..e7e7866e51 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/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()}`) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index e45b9eb125..554eb7f918 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/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 = '' diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 08d0daf546..56e598668f 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/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') diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 98008dad14..a36849c42f 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -808,43 +808,10 @@ describe('coverage seams', () => { await Promise.resolve() }) - it('keeps an infrastructure failure authoritative when it precedes the direct result', async () => { - const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() - const infrastructureFailure = Promise.withResolvers() - const failure = new Error('runner failed before its result') - 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: { - signal: vi.fn(), - waitForExit: async () => {}, - terminateForHostExit: vi.fn(), - }, - }) - - 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 () => { + it('contains a managed-owner cleanup failure after direct and range settlement', async () => { const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() const stopped = Promise.withResolvers() - const infrastructureFailure = Promise.withResolvers() 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' }, }), { @@ -852,22 +819,21 @@ describe('coverage seams', () => { stdout: null, stderr: null, direct: direct.promise, - infrastructureFailure: infrastructureFailure.promise, - owner, + owner: { + signal: vi.fn(), + waitForExit: () => stopped.promise, + terminateForHostExit: vi.fn(), + cleanup, + }, }) 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 () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 8e58b3492f..0b20c303b4 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/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[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', () => { diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index 340044f742..f87dacdf47 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/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') } diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index 5aa6b3e1f8..fbf3f32d09 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/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') }) }) From 5aab7930457c6b3a3c6256e3e7a773b996b0381c Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 09:08:34 +0800 Subject: [PATCH 084/110] fix(subprocess): preserve clean Windows range settlement --- .../2026-08-28-subprocess-native-containment.i18n.yaml | 4 ++-- .../2026-08-28-subprocess-native-containment.md | 4 ++-- .../2026-08-28-subprocess-native-containment.zh.md | 4 ++-- .../subprocess/subprocess-local/src/windows-job.ts | 10 ++++++++-- .../subprocess-local/tests/windows-job.spec.ts | 8 ++++---- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 07de32b014..8e598de5da 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 937c148101388aa49aac4826120218ff4a67a0e8 -2026-08-28-subprocess-native-containment.zh.md: 156d3a84d3a3a92a605866ee94a07616d18a09ee +2026-08-28-subprocess-native-containment.md: 4d3832b969f31a24578c4cdd92eded0d11552ff0 +2026-08-28-subprocess-native-containment.zh.md: 05e2a9d6774aa1f64c43f2e743d790be0bd1a760 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 937c148101..4d3832b969 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 is always a pipe; when stdin is ignored, the parent destroys its writer before sending the start request so the target receives EOF without changing descriptor positions. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the carrier streams as the ordinary handle's stdio, and user bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. @@ -55,7 +55,7 @@ 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, the three scope-establishment states, and exactly-once PTY managed-owner cleanup. -- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, the always-piped ignored-stdin carrier, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. +- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 156d3a84d3..05e2a9d677 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。fd 4 始终是 pipe;忽略 stdin 时,parent 会在发送 start request 前销毁写端,使 target 在不改变描述符位置的情况下收到 EOF。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 @@ -55,7 +55,7 @@ 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、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。 -- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、始终使用 pipe 的 ignored-stdin carrier、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 +- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、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 保留。 diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 06e8fb4a76..db789d0ea4 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -65,6 +65,7 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly runner: RunnerProcess, private readonly exited: Promise, + private readonly directResultSeen: () => boolean, private readonly failInfrastructure: (error: unknown) => void, ) { void this.exited.catch(() => {}) @@ -79,7 +80,7 @@ class WindowsJobOwner implements BoundProcessOwner { this.terminationSent = true try { this.runner.send?.({ type: 'terminate' }, (error) => { - if (error === null || !this.runner.connected) return + if (error === null || this.directResultSeen() || !this.runner.connected) return this.failInfrastructure(error) this.terminateForHostExit() }) @@ -145,7 +146,12 @@ export function launchWindowsJob( rangeExit.reject(error) } - const owner = new WindowsJobOwner(child, rangeExit.promise, failInfrastructure) + const owner = new WindowsJobOwner( + child, + rangeExit.promise, + () => resultSeen, + failInfrastructure, + ) child.on('message', (value: unknown) => { if (resultSeen) { const error = new Error('subprocess-local: Windows runner emitted more than one direct result') diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 0b20c303b4..42177a2358 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -334,7 +334,7 @@ describe('Windows parent runner contract', () => { await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw') }) - it('ignores a terminate callback error delivered after clean runner disconnect', async () => { + it('ignores a terminate callback error after a direct result while the runner is connected', async () => { const child = new FakeChild() const launched = launch(child) const handle = bindManagedProcess(spec, launched.result) @@ -346,12 +346,12 @@ describe('Windows parent runner contract', () => { 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) + expect(child.connected).toBe(true) child.deliverNextSend(new Error('late EPIPE')) await Promise.resolve() expect(child.killed).toEqual([]) + child.connected = false + child.emit('close', 0, null) await expect(handle.waitForExit()).resolves.toBe(true) }) From a416b13eb5d2be8f0791954d04e1f4f33902ff87 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 10:28:44 +0800 Subject: [PATCH 085/110] fix(subprocess): preserve Windows search miss errors --- .../subprocess-local/src/runner-launch.ts | 9 ++--- .../subprocess-local/src/spawn-runner.ts | 19 +++++++++++ .../tests/spawn-runner.spec.ts | 33 ++++++++++++++++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 5f6d34554a..acfa48d984 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -223,7 +223,7 @@ function windowsExecutableNames(command: string, name: string): string[] { * @param env - final target environment containing the child PATH. * @param exists - injectable non-directory candidate probe used by tests. * @param currentEnv - runner environment supplying PATH fallback and cwd-search policy. - * @returns a resolved application name suitable for `CreateProcessW`. + * @returns a resolved application name suitable for `CreateProcessW`, or undefined when no candidate exists. */ export function resolveWindowsExecutable( command: string, @@ -231,7 +231,7 @@ export function resolveWindowsExecutable( env: Readonly>, exists: (candidate: string) => boolean = executableCandidateExists, currentEnv: Readonly> = process.env, -): string { +): string | undefined { const nameStart = windowsFileNameStart(command) const directory = command.slice(0, nameStart) const name = command.slice(nameStart) @@ -254,10 +254,7 @@ export function resolveWindowsExecutable( } } - const unresolved = windowsSearchPathJoin(directory, name, cwd) - if (hasPath) return unresolved - const dot = name.indexOf('.') - return dot >= 0 && dot < name.length - 1 ? unresolved : `${unresolved}.exe` + return undefined } function throwNullByteError(property: string, value: string, argument: boolean): never { diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index e2abddce32..45ac2ab627 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -90,6 +90,18 @@ function asSpawnError(error: unknown, program: string, args: readonly string[]): } } +function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError { + return { + name: 'Error', + message: `spawn ${program} ENOENT`, + code: 'ENOENT', + errno: -4058, + syscall: `spawn ${program}`, + path: program, + spawnargs: [...args], + } +} + function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { return Object.assign(new Error(`spawn ${program} ENOENT`), { code: 'ENOENT', @@ -266,6 +278,13 @@ class WindowsJobRunner { undefined, { ...this.host.env }, ) + if (applicationName === undefined) { + await this.publishTerminalResult({ + type: 'error', + error: windowsPathNotFoundError(command as string, args), + }, 0) + return + } this.api = this.internals.loadWin32ProcessBindings() const spawned = this.internals.spawnCurrentTokenJobProcess(this.api, { command: command as string, diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 56e598668f..a8fdec566f 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -374,7 +374,7 @@ describe('runner launch inputs', () => { expect(resolveWindowsExecutable('tool.', 'C:\\target', {}, candidate => candidate === 'C:\\target\\tool.exe')).toBe('C:\\target\\tool.exe') expect(resolveWindowsExecutable('.\\missing', 'C:\\target', {}, () => false)) - .toBe('C:\\target\\.\\missing') + .toBeUndefined() expect(resolveWindowsExecutable('tool', 'C:\\target', { PATH: ';;C:\\bin', @@ -395,9 +395,9 @@ describe('runner launch inputs', () => { const noSearchEnvironment = { NoDefaultCurrentDirectoryInExePath: '1' } expect(resolveWindowsExecutable('missing', 'C:\\target', {}, () => false, noSearchEnvironment)) - .toBe('C:\\target\\missing.exe') + .toBeUndefined() expect(resolveWindowsExecutable('missing.cmd', 'C:\\target', {}, () => false, noSearchEnvironment)) - .toBe('C:\\target\\missing.cmd') + .toBeUndefined() const directory = mkdtempSync(join(tmpdir(), 'dsh-windows-resolver-')) scratch.push(directory) @@ -409,7 +409,7 @@ describe('runner launch inputs', () => { writeFileSync(`${directoryCandidate}.exe`, '') expect(resolveWindowsExecutable(executable, '', {})).toBe(executable) expect(resolveWindowsExecutable(directoryCandidate, '', {})).toBe(`${directoryCandidate}.exe`) - expect(resolveWindowsExecutable(missingExecutable, '', {})).toBe(missingExecutable) + expect(resolveWindowsExecutable(missingExecutable, '', {})).toBeUndefined() }) }) @@ -554,6 +554,31 @@ describe('Linux one-shot exec bootstrap', () => { }) describe('Windows Job runner protocol owner', () => { + it('publishes a Node-shaped path-search miss before loading Win32 bindings', async () => { + const host = new FakeRunnerHost() + const loadWin32ProcessBindings = vi.fn(() => ({} as CurrentTokenProcessBindings)) + const native = internals({ + loadWin32ProcessBindings, + resolveWindowsExecutable: vi.fn(() => undefined), + }) + await runWindows(host, native) + expect(loadWin32ProcessBindings).not.toHaveBeenCalled() + expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() + expect(host.sent).toEqual([{ + type: 'error', + error: { + name: 'Error', + message: 'spawn tool.exe ENOENT', + code: 'ENOENT', + errno: -4058, + syscall: 'spawn tool.exe', + path: 'tool.exe', + spawnargs: ['literal arg'], + }, + }]) + expect(host.exitCode).toBe(0) + }) + it('maps the bounded Win32 process-creation error classes', async () => { for (const [win32Code, code] of [ [3, 'ENOENT'], From 1760649ba7489c6a6d22d8ef550c79784dcb63c7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 11:28:33 +0800 Subject: [PATCH 086/110] refactor(subprocess): derive native owner state --- .../subprocess-local/src/linux-scope.ts | 15 --------------- .../subprocess-local/src/spawn-runner.ts | 18 ++++++------------ .../subprocess-local/tests/linux-scope.spec.ts | 11 +++-------- 3 files changed, 9 insertions(+), 35 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 4075f42577..c436acab0e 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -77,20 +77,6 @@ function unitStem(prefix: string): string { return `${prefix}-${String(process.pid)}-${randomBytes(6).toString('hex')}` } -/** - * Confirm that the live user manager is readable for this spawn. - * @param internals - optional command seams used by tests. - * @returns whether the current user manager answered successfully. - */ -export function probeLinuxUserManager(internals: LinuxScopeInternals = {}): boolean { - const result = (internals.spawnSync ?? spawnSync)( - internals.systemctl ?? 'systemctl', - ['--user', 'show-environment'], - { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }, - ) - return result.error === undefined && result.status === 0 -} - /** * Confirm this exact runner entry and libc execve binding without a probe mode. * @param internals - optional runner and libc-binding seams used by tests. @@ -139,7 +125,6 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { */ export function probeLinuxNative(internals: LinuxScopeInternals = {}): boolean { return probeLinuxBootstrap(internals) - && probeLinuxUserManager(internals) && probeLinuxScope(internals) } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 45ac2ab627..14735b6d33 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -207,11 +207,9 @@ class WindowsJobRunner { private jobHandle: NativePtr | undefined private pollTimer: ReturnType | undefined private startSeen = false - private committed = false private terminateRequested = false private resultStarted = false private resultDelivered = false - private jobEmpty = false private finished = false private readonly completion = Promise.withResolvers() @@ -296,14 +294,13 @@ class WindowsJobRunner { }) this.processHandle = spawned.process this.jobHandle = spawned.job - this.committed = true for (const fileDescriptor of [4, 5, 6]) { this.internals.closeFileDescriptor(fileDescriptor) } if (this.startCancellationPending()) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) } catch (error) { - if (!this.committed && error instanceof Win32Error && error.api === 'CreateProcessW') { + if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') { await this.publishTerminalResult({ type: 'error', error: asSpawnError(error, this.argv[0] as string, this.argv.slice(1)), @@ -317,12 +314,10 @@ class WindowsJobRunner { private requestTermination(): void { if (this.terminateRequested) return this.terminateRequested = true - if (this.committed) { - try { - this.terminateOwnedJob() - } catch (error) { - void this.runnerFailure(error) - } + try { + this.terminateOwnedJob() + } catch (error) { + void this.runnerFailure(error) } } @@ -355,7 +350,6 @@ class WindowsJobRunner { if (this.jobHandle !== undefined && this.internals.isJobEmpty(this.api, this.jobHandle)) { this.internals.closeHandleChecked(this.api, this.jobHandle, 'ordinary process Job') this.jobHandle = undefined - this.jobEmpty = true if (this.resultDelivered) this.finish(0) } } catch (error) { @@ -379,7 +373,7 @@ class WindowsJobRunner { this.finish(exitCode) return } - if (this.jobEmpty) this.finish(0) + if (this.jobHandle === undefined) this.finish(0) } private async runnerFailure(error: unknown): Promise { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 2422528df5..ccd1f7cadf 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -8,7 +8,6 @@ import { probeLinuxBootstrap, probeLinuxNative, probeLinuxScope, - probeLinuxUserManager, } from '../src/linux-scope.ts' import type { LinuxScopeInternals } from '../src/linux-scope.ts' import { @@ -114,7 +113,7 @@ function launch( } describe('Linux native capability selection', () => { - it('rechecks bootstrap, user manager, and literal transient-scope support', () => { + it('rechecks bootstrap and literal transient-scope support', () => { const spawnSync = vi.fn(() => ({ status: 0, error: undefined })) const runnerAvailable = vi.fn(() => true) const loadLinuxExecve = vi.fn(() => vi.fn() as never) @@ -130,7 +129,7 @@ describe('Linux native capability selection', () => { expect(probeLinuxNative(inputs)).toBe(true) expect(runnerAvailable).toHaveBeenCalledTimes(2) expect(loadLinuxExecve).toHaveBeenCalledTimes(2) - expect(spawnSync).toHaveBeenCalledTimes(4) + expect(spawnSync).toHaveBeenCalledTimes(2) expect(probeLinuxBootstrap({ ...inputs, loadLinuxExecve: () => { throw new Error('libc execve missing') }, @@ -138,9 +137,6 @@ describe('Linux native capability selection', () => { }) it('reports each failed dynamic prerequisite without executing a target', () => { - expect(probeLinuxUserManager({ - spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never, - })).toBe(false) expect(probeLinuxScope({ spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never, })).toBe(false) @@ -157,9 +153,8 @@ describe('Linux native capability selection', () => { it('uses the default command adapters and runner resolution', () => { childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined }) - expect(probeLinuxUserManager()).toBe(true) expect(probeLinuxScope()).toBe(true) - expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2) + expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(1) expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true) expect(probeLinuxBootstrap({ runnerInvocation: [process.execPath], From 60dd357a02b1831ac2c59b1280f949d2225206b8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 11:36:00 +0800 Subject: [PATCH 087/110] fix(subprocess): preserve native spawn error shape --- .../subprocess-local/src/spawn-runner.ts | 58 +++++++++++-------- .../tests/native-containment.spec.ts | 39 +++++++++++-- .../tests/native-windows.spec.ts | 28 ++++++++- .../tests/spawn-runner.spec.ts | 50 ++++++++++++---- 4 files changed, 133 insertions(+), 42 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 14735b6d33..118044a8f2 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -66,40 +66,48 @@ const defaultInternals: SpawnRunnerInternals = { closeHandleChecked, } -function asSpawnError(error: unknown, program: string, args: readonly string[]): SerializedRunnerError { - const serialized = serializeRunnerError(error) - const win32Code = error instanceof Win32Error ? error.win32Code : undefined - const code = win32Code === undefined - ? serialized.code - : win32Code === 2 || win32Code === 3 || win32Code === 267 - ? 'ENOENT' - : win32Code === 5 - ? 'EPERM' - : win32Code === 193 - ? 'EFTYPE' - : 'UNKNOWN' - if (code === undefined) return serialized +function nodeSpawnError( + source: Pick, + program: string, + args: readonly string[], + code: string, + errno: number | undefined, +): SerializedRunnerError { + const message = `spawn ${program} ${code}` + const newline = source.stack?.indexOf('\n') ?? -1 return { - ...serialized, - message: `spawn ${program} ${code}: ${serialized.message}`, + name: 'Error', + message, + ...source.stack === undefined ? {} : { + stack: `Error: ${message}${newline === -1 ? '' : source.stack.slice(newline)}`, + }, code, - ...win32Code === 5 ? { errno: -4048 } : {}, + ...errno === undefined ? {} : { errno }, syscall: `spawn ${program}`, path: program, spawnargs: [...args], } } -function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError { - return { - name: 'Error', - message: `spawn ${program} ENOENT`, - code: 'ENOENT', - errno: -4058, - syscall: `spawn ${program}`, - path: program, - spawnargs: [...args], +function asSpawnError(error: unknown, program: string, args: readonly string[]): SerializedRunnerError { + const serialized = serializeRunnerError(error) + if (!(error instanceof Win32Error)) { + return serialized.code === undefined + ? serialized + : nodeSpawnError(serialized, program, args, serialized.code, serialized.errno) } + const [code, errno] = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 + ? ['ENOENT', -4058] + : error.win32Code === 5 + ? ['EPERM', -4048] + : error.win32Code === 193 + ? ['EFTYPE', -4028] + : ['UNKNOWN', -4094] + return nodeSpawnError(serialized, program, args, code, errno) +} + +function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError { + return nodeSpawnError({}, program, args, 'ENOENT', -4058) } function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index b10c538e53..7e5a13a57b 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -25,6 +26,16 @@ function spec(argv: string[], graceMs = 100): SubprocessSpawnSpec { } } +type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } + +function directSpawnFailure(argv: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(argv[0] as string, argv.slice(1), { cwd: scratch, stdio: 'ignore' }) + child.once('error', resolve) + child.once('spawn', () => { reject(new Error(`expected ${argv[0]} to fail before spawn`)) }) + }) +} + async function waitForPid(path: string): Promise { const deadline = Date.now() + 5_000 while (Date.now() < deadline) { @@ -146,16 +157,36 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { }) it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => { - const missing = spec([`missing-native-target-${Date.now()}`]) + const missingArgv = [`missing-native-target-${Date.now()}`, 'literal arg'] + const expectedMissing = await directSpawnFailure(missingArgv) + const missing = spec(missingArgv) const missingHandle = bindManagedProcess(missing, launchLinuxScope(missing, targetEnvironment(missing))) - await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(missingHandle.done).rejects.toMatchObject({ + name: expectedMissing.name, + message: expectedMissing.message, + code: expectedMissing.code, + errno: expectedMissing.errno, + syscall: expectedMissing.syscall, + path: expectedMissing.path, + spawnargs: expectedMissing.spawnargs, + }) const deniedPath = join(scratch, `not-executable-${Date.now()}`) writeFileSync(deniedPath, '#!/bin/sh\nexit 0\n', { mode: 0o600 }) chmodSync(deniedPath, 0o600) - const denied = spec([deniedPath]) + const deniedArgv = [deniedPath, 'literal arg'] + const expectedDenied = await directSpawnFailure(deniedArgv) + const denied = spec(deniedArgv) const deniedHandle = bindManagedProcess(denied, launchLinuxScope(denied, targetEnvironment(denied))) - await expect(deniedHandle.done).rejects.toMatchObject({ code: 'EACCES' }) + await expect(deniedHandle.done).rejects.toMatchObject({ + name: expectedDenied.name, + message: expectedDenied.message, + code: expectedDenied.code, + errno: expectedDenied.errno, + syscall: expectedDenied.syscall, + path: expectedDenied.path, + spawnargs: expectedDenied.spawnargs, + }) }) it('keeps PTY identity and readiness while containing a reparented setsid descendant', async () => { diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index e7e7866e51..011f52654a 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -58,7 +58,7 @@ function cleanup(pid: number): void { type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } -function directSpawnFailure(argv: string[], cwd = scratch): Promise { +function directSpawnFailure(argv: readonly string[], cwd = scratch): Promise { return new Promise((resolve, reject) => { try { const child = spawn(argv[0] as string, argv.slice(1), { cwd, stdio: 'ignore' }) @@ -189,14 +189,25 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { await expect(relativeHandle.waitForExit()).resolves.toBe(true) const missing = spec([`missing-native-target-${Date.now()}.exe`]) + const expectedMissing = await directSpawnFailure(missing.argv) const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing, targetEnvironment(missing))) - await expect(missingHandle.done).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(missingHandle.done).rejects.toMatchObject({ + name: expectedMissing.name, + message: expectedMissing.message, + code: expectedMissing.code, + errno: expectedMissing.errno, + syscall: expectedMissing.syscall, + path: expectedMissing.path, + spawnargs: expectedMissing.spawnargs, + }) 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({ + name: expectedAccessDenied.name, + message: expectedAccessDenied.message, code: expectedAccessDenied.code, errno: expectedAccessDenied.errno, syscall: expectedAccessDenied.syscall, @@ -211,7 +222,10 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd, targetEnvironment(invalidCwd))) await expect(invalidCwdHandle.done).rejects.toMatchObject({ + name: expectedCwd.name, + message: expectedCwd.message, code: expectedCwd.code, + errno: expectedCwd.errno, syscall: expectedCwd.syscall, path: expectedCwd.path, spawnargs: expectedCwd.spawnargs, @@ -223,7 +237,15 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const directError = await directSpawnFailure([invalidExecutable]) const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid, targetEnvironment(invalid))) - await expect(invalidHandle.done).rejects.toMatchObject({ code: directError.code }) + await expect(invalidHandle.done).rejects.toMatchObject({ + name: directError.name, + message: directError.message, + code: directError.code, + errno: directError.errno, + syscall: directError.syscall, + path: directError.path, + spawnargs: directError.spawnargs, + }) await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index a8fdec566f..a156f6c650 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -434,7 +434,16 @@ describe('Linux one-shot exec bootstrap', () => { expect(execve.mock.calls[0]?.[1]).toEqual(['tool', 'literal arg']) expect(execve.mock.calls[0]?.[2]).toMatchObject({ [SUBPROCESS_RUNNER_ENV]: 'target-value' }) expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ - type: 'error', error: { code: 'ENOENT', path: 'tool' }, + type: 'error', + error: { + name: 'Error', + message: 'spawn tool ENOENT', + code: 'ENOENT', + errno: -2, + syscall: 'spawn tool', + path: 'tool', + spawnargs: ['literal arg'], + }, }) }) @@ -509,10 +518,23 @@ describe('Linux one-shot exec bootstrap', () => { it('uses the default PATH and stops on a non-search error', async () => { const files = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) - const execve = vi.fn((_file: string) => { throw Object.assign(new Error('denied'), { code: 'EACCES' }) }) + const execve = vi.fn((_file: string) => { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', errno: -13 }) + }) await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool']) - expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error', error: { code: 'EACCES' } }) + expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ + type: 'error', + error: { + name: 'Error', + message: 'spawn tool EACCES', + code: 'EACCES', + errno: -13, + syscall: 'spawn tool', + path: 'tool', + spawnargs: [], + }, + }) const explicit = track(createLinuxLaunchFiles({ cwd: '/work', env: {} })) const fatal = vi.fn(() => { throw Object.assign(new Error('bad executable'), { code: 'EIO' }) }) @@ -580,12 +602,12 @@ describe('Windows Job runner protocol owner', () => { }) it('maps the bounded Win32 process-creation error classes', async () => { - for (const [win32Code, code] of [ - [3, 'ENOENT'], - [267, 'ENOENT'], - [5, 'EPERM'], - [193, 'EFTYPE'], - [999, 'UNKNOWN'], + for (const [win32Code, code, errno] of [ + [3, 'ENOENT', -4058], + [267, 'ENOENT', -4058], + [5, 'EPERM', -4048], + [193, 'EFTYPE', -4028], + [999, 'UNKNOWN', -4094], ] as const) { const host = new FakeRunnerHost() await runWindows(host, internals({ @@ -593,7 +615,15 @@ describe('Windows Job runner protocol owner', () => { })) expect(host.sent).toMatchObject([{ type: 'error', - error: { code, ...win32Code === 5 ? { errno: -4048 } : {} }, + error: { + name: 'Error', + message: `spawn tool.exe ${code}`, + code, + errno, + syscall: 'spawn tool.exe', + path: 'tool.exe', + spawnargs: ['literal arg'], + }, }]) } }) From 13f3b2c1727f0ba6506645ac992705e8be682325 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 12:13:30 +0800 Subject: [PATCH 088/110] fix(subprocess): match native spawn error semantics --- .../subprocess-local/src/spawn-runner.ts | 62 ++++++++++++------- .../subprocess-local/src/windows-job.ts | 4 +- .../tests/spawn-runner.spec.ts | 30 ++++++--- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 118044a8f2..e710f8130a 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -68,24 +68,22 @@ const defaultInternals: SpawnRunnerInternals = { function nodeSpawnError( source: Pick, - program: string, - args: readonly string[], + syscall: string, code: string, errno: number | undefined, + details: Pick, ): SerializedRunnerError { - const message = `spawn ${program} ${code}` - const newline = source.stack?.indexOf('\n') ?? -1 + const message = `${syscall} ${code}` return { name: 'Error', message, ...source.stack === undefined ? {} : { - stack: `Error: ${message}${newline === -1 ? '' : source.stack.slice(newline)}`, + stack: source.stack.replace(/^[^\n]*/, `Error: ${message}`), }, code, ...errno === undefined ? {} : { errno }, - syscall: `spawn ${program}`, - path: program, - spawnargs: [...args], + syscall, + ...details, } } @@ -94,20 +92,36 @@ function asSpawnError(error: unknown, program: string, args: readonly string[]): if (!(error instanceof Win32Error)) { return serialized.code === undefined ? serialized - : nodeSpawnError(serialized, program, args, serialized.code, serialized.errno) + : nodeSpawnError(serialized, `spawn ${program}`, serialized.code, serialized.errno, { + path: program, + spawnargs: [...args], + }) } - const [code, errno] = error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267 - ? ['ENOENT', -4058] - : error.win32Code === 5 - ? ['EPERM', -4048] - : error.win32Code === 193 - ? ['EFTYPE', -4028] - : ['UNKNOWN', -4094] - return nodeSpawnError(serialized, program, args, code, errno) + if (error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267) { + return nodeSpawnError(serialized, `spawn ${program}`, 'ENOENT', -4058, { + path: program, + spawnargs: [...args], + }) + } + if (error.win32Code === 740) { + return nodeSpawnError(serialized, `spawn ${program}`, 'EACCES', -4092, { + path: program, + spawnargs: [...args], + }) + } + const [code, errno] = error.win32Code === 5 + ? ['EPERM', -4048] + : error.win32Code === 193 + ? ['EFTYPE', -4028] + : ['UNKNOWN', -4094] + return nodeSpawnError(serialized, 'spawn', code, errno, {}) } function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError { - return nodeSpawnError({}, program, args, 'ENOENT', -4058) + return nodeSpawnError({}, `spawn ${program}`, 'ENOENT', -4058, { + path: program, + spawnargs: [...args], + }) } function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { @@ -271,7 +285,9 @@ class WindowsJobRunner { } await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) if (this.finished) return - if (this.startCancellationPending()) { + // IPC may set this field while start() is suspended above. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (this.terminateRequested) { await this.publishTerminalResult({ type: 'start-cancelled' }, 0) return } @@ -305,7 +321,9 @@ class WindowsJobRunner { for (const fileDescriptor of [4, 5, 6]) { this.internals.closeFileDescriptor(fileDescriptor) } - if (this.startCancellationPending()) this.terminateOwnedJob() + // Descriptor cleanup may synchronously re-enter the IPC handler. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (this.terminateRequested) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) } catch (error) { if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') { @@ -329,10 +347,6 @@ class WindowsJobRunner { } } - private startCancellationPending(): boolean { - return this.terminateRequested - } - private terminateOwnedJob(): void { const job = this.jobHandle if (job === undefined) return diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index db789d0ea4..0c1634001a 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -139,9 +139,7 @@ export function launchWindowsJob( const direct = Promise.withResolvers() const rangeExit = Promise.withResolvers() let resultSeen = false - let infrastructureFailed = false const failInfrastructure = (error: unknown): void => { - infrastructureFailed = true direct.reject(error) rangeExit.reject(error) } @@ -180,7 +178,7 @@ export function launchWindowsJob( failInfrastructure(error) }) child.once('close', (exitCode, signal) => { - const clean = exitCode === 0 && signal === null && resultSeen && !infrastructureFailed + const clean = exitCode === 0 && signal === null && resultSeen if (clean) { rangeExit.resolve() return diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index a156f6c650..f63fc2c7ae 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -602,29 +602,39 @@ describe('Windows Job runner protocol owner', () => { }) it('maps the bounded Win32 process-creation error classes', async () => { - for (const [win32Code, code, errno] of [ - [3, 'ENOENT', -4058], - [267, 'ENOENT', -4058], - [5, 'EPERM', -4048], - [193, 'EFTYPE', -4028], - [999, 'UNKNOWN', -4094], + for (const [win32Code, code, errno, enriched] of [ + [2, 'ENOENT', -4058, true], + [3, 'ENOENT', -4058, true], + [267, 'ENOENT', -4058, true], + [740, 'EACCES', -4092, true], + [5, 'EPERM', -4048, false], + [193, 'EFTYPE', -4028, false], + [999, 'UNKNOWN', -4094, false], ] as const) { const host = new FakeRunnerHost() await runWindows(host, internals({ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }), })) + const syscall = enriched ? 'spawn tool.exe' : 'spawn' expect(host.sent).toMatchObject([{ type: 'error', error: { name: 'Error', - message: `spawn tool.exe ${code}`, + message: `${syscall} ${code}`, code, errno, - syscall: 'spawn tool.exe', - path: 'tool.exe', - spawnargs: ['literal arg'], + syscall, }, }]) + const result = parseWindowsRunnerResult(host.sent[0]) + if (result.type !== 'error') throw new Error('expected runner error') + expect(result.error.stack?.split('\n')[0]).toBe(`Error: ${syscall} ${code}`) + if (enriched) { + expect(result.error).toMatchObject({ path: 'tool.exe', spawnargs: ['literal arg'] }) + } else { + expect(result.error).not.toHaveProperty('path') + expect(result.error).not.toHaveProperty('spawnargs') + } } }) From c0e7b9816999c64f4147a35fb143c0f7b369cbd8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 13:02:36 +0800 Subject: [PATCH 089/110] test(subprocess): preserve optional spawn error keys --- .../tests/native-windows.spec.ts | 53 ++++++------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 011f52654a..0973ee7270 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -58,6 +58,19 @@ function cleanup(pid: number): void { type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } +function expectedSpawnFailure(error: SpawnFailure): Record { + const expected: Record = { + name: error.name, + message: error.message, + code: error.code, + errno: error.errno, + syscall: error.syscall, + } + if (Object.hasOwn(error, 'path')) expected.path = error.path + if (Object.hasOwn(error, 'spawnargs')) expected.spawnargs = error.spawnargs + return expected +} + function directSpawnFailure(argv: readonly string[], cwd = scratch): Promise { return new Promise((resolve, reject) => { try { @@ -191,29 +204,13 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const missing = spec([`missing-native-target-${Date.now()}.exe`]) const expectedMissing = await directSpawnFailure(missing.argv) const missingHandle = bindManagedProcess(missing, launchWindowsJob(missing, targetEnvironment(missing))) - await expect(missingHandle.done).rejects.toMatchObject({ - name: expectedMissing.name, - message: expectedMissing.message, - code: expectedMissing.code, - errno: expectedMissing.errno, - syscall: expectedMissing.syscall, - path: expectedMissing.path, - spawnargs: expectedMissing.spawnargs, - }) + await expect(missingHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedMissing)) 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({ - name: expectedAccessDenied.name, - message: expectedAccessDenied.message, - code: expectedAccessDenied.code, - errno: expectedAccessDenied.errno, - syscall: expectedAccessDenied.syscall, - path: expectedAccessDenied.path, - spawnargs: expectedAccessDenied.spawnargs, - }) + await expect(accessDeniedHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedAccessDenied)) await expect(accessDeniedHandle.waitForExit()).resolves.toBe(true) const missingCwd = join(scratch, `missing-cwd-${Date.now()}`) @@ -221,15 +218,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const expectedCwd = await directSpawnFailure(cwdArgv, missingCwd) const invalidCwd = { ...spec(cwdArgv), cwd: missingCwd } const invalidCwdHandle = bindManagedProcess(invalidCwd, launchWindowsJob(invalidCwd, targetEnvironment(invalidCwd))) - await expect(invalidCwdHandle.done).rejects.toMatchObject({ - name: expectedCwd.name, - message: expectedCwd.message, - code: expectedCwd.code, - errno: expectedCwd.errno, - syscall: expectedCwd.syscall, - path: expectedCwd.path, - spawnargs: expectedCwd.spawnargs, - }) + await expect(invalidCwdHandle.done).rejects.toMatchObject(expectedSpawnFailure(expectedCwd)) await expect(invalidCwdHandle.waitForExit()).resolves.toBe(true) const invalidExecutable = join(scratch, `direct-${Date.now()}.exe`) @@ -237,15 +226,7 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { const directError = await directSpawnFailure([invalidExecutable]) const invalid = spec([invalidExecutable]) const invalidHandle = bindManagedProcess(invalid, launchWindowsJob(invalid, targetEnvironment(invalid))) - await expect(invalidHandle.done).rejects.toMatchObject({ - name: directError.name, - message: directError.message, - code: directError.code, - errno: directError.errno, - syscall: directError.syscall, - path: directError.path, - spawnargs: directError.spawnargs, - }) + await expect(invalidHandle.done).rejects.toMatchObject(expectedSpawnFailure(directError)) await expect(invalidHandle.waitForExit()).resolves.toBe(true) }) }) From aaa5117ebdd91ee946d0f6229ee276a080fedb17 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 13:08:43 +0800 Subject: [PATCH 090/110] fix(subprocess): preserve literal spawn error stacks --- .../subprocess/subprocess-local/src/index.ts | 4 +-- .../subprocess-local/src/runner-launch.ts | 11 +------- .../subprocess-local/src/spawn-runner.ts | 2 +- .../tests/spawn-runner.spec.ts | 27 +++++++++---------- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index aa0f3bb11b..5aa19f1e0b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -35,7 +35,7 @@ import { probeLinuxNative, } from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' -import { targetEnvironment, validateTerminalTarget } from './runner-launch.ts' +import { targetEnvironment } from './runner-launch.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -230,7 +230,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { throw new Error('subprocess-local: terminal argv must contain a program') } spec.signal?.throwIfAborted() - const env = validateTerminalTarget(spec) + const env = targetEnvironment(spec) const options: IPtyForkOptions = { name: 'dumb', rows: spec.rows, diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index acfa48d984..f25929d48c 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -5,7 +5,7 @@ import { accessSync, constants as fsConstants, statSync } from 'node:fs' import { extname, isAbsolute } from 'node:path' import { inspect } from 'node:util' import { fileURLToPath } from 'node:url' -import type { SubprocessSpawnSpec, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { childEnv } from './spawn.ts' /** The one private environment variable consumed before target state is restored. */ @@ -289,12 +289,3 @@ export function targetEnvironment( } return env } - -/** - * Validate Linux PTY target strings before creating its request or terminal. - * @param spec - terminal subprocess request to validate. - * @returns complete validated target environment. - */ -export function validateTerminalTarget(spec: SubprocessTerminalSpawnSpec): Record { - return targetEnvironment(spec) -} diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index e710f8130a..4e5d3e8e60 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -78,7 +78,7 @@ function nodeSpawnError( name: 'Error', message, ...source.stack === undefined ? {} : { - stack: source.stack.replace(/^[^\n]*/, `Error: ${message}`), + stack: source.stack.replace(/^[^\n]*/, () => `Error: ${message}`), }, code, ...errno === undefined ? {} : { errno }, diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index f63fc2c7ae..8ca545503a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -42,7 +42,6 @@ import { spawnRunnerInvocation, SUBPROCESS_RUNNER_ENV, targetEnvironment, - validateTerminalTarget, WINDOWS_RUNNER_SELECTION, } from '../src/runner-launch.ts' import { @@ -114,10 +113,11 @@ async function runWindows( host: FakeRunnerHost, native: SpawnRunnerInternals, start: unknown = { type: 'start', cwd: 'C:\\target', env: { TARGET: 'yes', dsh_subprocess_runner: 'restored' } }, + targetArgv: string[] = ['tool.exe', 'literal arg'], ): Promise { const running = runSpawnRunner( WINDOWS_RUNNER_SELECTION, - ['--', 'tool.exe', 'literal arg'], + ['--', ...targetArgv], hostArgument(host), native, ) @@ -276,7 +276,6 @@ describe('runner launch inputs', () => { expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' }) expect(targetEnvironment({ ...spec, env: { '=C:': 'C:\\target' } })) .toMatchObject({ '=C:': 'C:\\target' }) - expect(validateTerminalTarget({ ...spec, rows: 24, cols: 80 })).toMatchObject({ EXPLICIT: 'yes' }) for (const invalid of [ { ...spec, argv: ['node\0'] }, { ...spec, argv: ['node', 'a\0'] }, @@ -602,20 +601,20 @@ describe('Windows Job runner protocol owner', () => { }) it('maps the bounded Win32 process-creation error classes', async () => { - for (const [win32Code, code, errno, enriched] of [ - [2, 'ENOENT', -4058, true], - [3, 'ENOENT', -4058, true], - [267, 'ENOENT', -4058, true], - [740, 'EACCES', -4092, true], - [5, 'EPERM', -4048, false], - [193, 'EFTYPE', -4028, false], - [999, 'UNKNOWN', -4094, false], + for (const [win32Code, code, errno, enriched, program] of [ + [2, 'ENOENT', -4058, true, 'tool.exe'], + [3, 'ENOENT', -4058, true, 'tool.exe'], + [267, 'ENOENT', -4058, true, 'tool.exe'], + [740, 'EACCES', -4092, true, '$&.exe'], + [5, 'EPERM', -4048, false, 'tool.exe'], + [193, 'EFTYPE', -4028, false, 'tool.exe'], + [999, 'UNKNOWN', -4094, false, 'tool.exe'], ] as const) { const host = new FakeRunnerHost() await runWindows(host, internals({ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', win32Code) }), - })) - const syscall = enriched ? 'spawn tool.exe' : 'spawn' + }), undefined, [program, 'literal arg']) + const syscall = enriched ? `spawn ${program}` : 'spawn' expect(host.sent).toMatchObject([{ type: 'error', error: { @@ -630,7 +629,7 @@ describe('Windows Job runner protocol owner', () => { if (result.type !== 'error') throw new Error('expected runner error') expect(result.error.stack?.split('\n')[0]).toBe(`Error: ${syscall} ${code}`) if (enriched) { - expect(result.error).toMatchObject({ path: 'tool.exe', spawnargs: ['literal arg'] }) + expect(result.error).toMatchObject({ path: program, spawnargs: ['literal arg'] }) } else { expect(result.error).not.toHaveProperty('path') expect(result.error).not.toHaveProperty('spawnargs') From 93c11689e71e562e4950e604e93e913afb16af67 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 29 Aug 2026 15:44:13 +0800 Subject: [PATCH 091/110] fix(subprocess): address containment review findings --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 4 +- ...-08-28-subprocess-native-containment.zh.md | 4 +- .../tests/image-loadable.spec.ts | 17 --- packages/subagent/subagent-acp/src/run.ts | 28 ++--- .../subagent-acp/tests/subagent-acp.spec.ts | 18 +-- .../subagent/subagent-claude-code/src/run.ts | 74 ++++------- .../tests/subagent-claude-code.spec.ts | 116 ++++++++++-------- .../subprocess-local/src/linux-scope.ts | 9 +- .../subprocess-local/src/runner-launch.ts | 9 +- .../tests/linux-scope.spec.ts | 16 +++ .../tests/spawn-runner.spec.ts | 3 + .../subprocess/win32-process/README.i18n.yaml | 4 +- packages/subprocess/win32-process/README.md | 4 +- .../subprocess/win32-process/README.zh.md | 4 +- packages/subprocess/win32-process/src/abi.ts | 8 -- packages/subprocess/win32-process/src/ffi.ts | 22 +--- .../subprocess/win32-process/src/process.ts | 49 ++------ .../tests/ordinary-process.spec.ts | 90 +++++--------- .../win32-process/verify/abi-probe.cpp | 5 +- 20 files changed, 186 insertions(+), 302 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 8e598de5da..9e8b5c8fa1 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 4d3832b969f31a24578c4cdd92eded0d11552ff0 -2026-08-28-subprocess-native-containment.zh.md: 05e2a9d6774aa1f64c43f2e743d790be0bd1a760 +2026-08-28-subprocess-native-containment.md: 2ece0bf300336dab82c42d12fd97e2eaf4cad1f9 +2026-08-28-subprocess-native-containment.zh.md: b59ed6e6c5ef984e3c0162921fd0f81359bcc5d5 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 4d3832b969..2ece0bf300 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -28,7 +28,7 @@ The ordinary target result still comes from the same child process. The PTY path ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` table for fds 4 through 6, temporarily enables inheritance on those OS handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects invalid results, temporarily enables inheritance on those handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. @@ -55,7 +55,7 @@ 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, the three scope-establishment states, and exactly-once PTY managed-owner cleanup. -- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit sorted target environment blocks with `=C:` preservation and double-NUL termination, strict `GetStartupInfoW` libuv descriptor-table decoding, 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. +- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and invalid-result 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 05e2a9d677..b59ed6e6c5 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -28,7 +28,7 @@ request 被消费或 manager 已观察到 unit 都能建立 scope ownership。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 表以取得 fd 4 至 fd 6 的 OS handle,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6,绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝无效结果,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 @@ -55,7 +55,7 @@ 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、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。 -- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、严格的 `GetStartupInfoW` libuv 描述符表解码、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 +- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与无效结果拒绝、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 保留。 diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index 192dba7b62..a00fa133d0 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -37,7 +37,6 @@ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const SUBJECT = '@deepseek-ai/dsh-timeout' const LANDLOCK = '@deepseek-ai/node-addon-landlock-run' const PLUGIN_INVENTORY = '@deepseek-ai/dsh-plugin-package-inventory-deepseek' -const SUBPROCESS_LOCAL = '@deepseek-ai/dsh-subprocess-local' const WEB_SERVER = '@deepseek-ai/dsh-host-webserver' const workspaces = indexWorkspacePackages(repoRoot) @@ -115,15 +114,6 @@ const packedWebServer = (): ReturnType => webServerMemo ??= entries: [], }) -let subprocessLocalMemo: ReturnType | undefined -const packedSubprocessLocal = (): ReturnType => subprocessLocalMemo ??= packVfsImage({ - config: `- id: subject\n name: '${SUBPROCESS_LOCAL}'\n`, - profile: 'subprocess-runner-face-check', - workspaces, - resolveFrom: repoRoot, - entries: [], -}) - /** The image's archive, inflated once: mounting reads the tar, not the gzip member. */ let archiveMemo: Uint8Array | undefined const archive = async (): Promise => @@ -146,13 +136,6 @@ const archive = async (): Promise => expect(result.transform.rewritten).toBeGreaterThan(0) }) - it('keeps the published subprocess runner face in the lowered image', () => { - const result = packedSubprocessLocal() - expect(result.roster).toEqual([SUBPROCESS_LOCAL]) - expect(result.missing).toEqual([]) - expect(Object.hasOwn(result.files, `node_modules/${SUBPROCESS_LOCAL}/lib/runner.js`)).toBe(true) - }) - it('names every JavaScript entry for the debugger, workspace files by repository path', () => { const result = packed() const decoder = new TextDecoder() diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 47fbb436e5..5e6a0b6545 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -285,12 +285,8 @@ function reportFailure(spec: AcpRunSpec, error: unknown): void { function startupFailure( error: unknown, stage: Extract, - processFailure: Error | undefined, outcome: SubprocessOutcome | undefined, ): AcpRunFailure { - if (processFailure !== undefined) { - return new AcpRunFailure({ stage: 'process', category: 'process-start' }, processFailure) - } return new AcpRunFailure( /* v8 ignore next -- Windows anonymous pipes cannot expose a live-child protocol close during startup. */ outcome === undefined @@ -378,16 +374,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe }, ) - // Spawn-level failure surfaces as `done` rejecting into the startup race; a - // clean exit must never win it, so the success arm parks forever. (The ACP - // connection observing its streams closing bounds a child that exits - // without speaking the protocol.) - const spawnFailed: Promise = processDone.then( + // A rejected direct result surfaces into the startup race; a clean exit must + // never win it, so the success arm parks forever. (The ACP connection + // observing its streams closing bounds a child that exits without speaking + // the protocol.) + const processRejected: Promise = processDone.then( /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */ () => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err)), ) - spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) + processRejected.catch(() => { /* observed by the startup race; never unhandled */ }) const observeProcessOutcome = async (signal?: AbortSignal): Promise => { if (processOutcome !== undefined) return processOutcome @@ -505,7 +501,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe /* v8 ignore next -- cancelSettled wins the startup race before this post-response guard can settle it. */ if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), - spawnFailed, + processRejected, cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), ]) } catch (error: unknown) { @@ -523,7 +519,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe kind: 'failed', failure: error instanceof AcpRunFailure ? error - : startupFailure(error, startupStage, processFailure, observedOutcome), + : startupFailure(error, startupStage, observedOutcome), } as const if (startup.kind === 'cancelled') { // Local cancellation owns the startup outcome; only cleanup failure is @@ -582,11 +578,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } catch (error: unknown) { if (!flags.cancelled) { const outcome = await observeProcessOutcome(request.signal) - const facts = processFailure !== undefined - ? { stage: 'process', category: 'process-start' } as const - : outcome === undefined - ? { stage: 'prompt', category: 'transport' } as const - : { stage: 'process', category: 'process-exit', outcome } as const + const facts = outcome === undefined + ? { stage: 'prompt', category: 'transport' } as const + : { stage: 'process', category: 'process-exit', outcome } as const diagnostic = diagnosticText(facts, latestPermission) } throw processFailure ?? error diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index a40455cb63..db666da272 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -348,7 +348,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', expect((failure as AggregateError).errors).toEqual([initialFailure, finalFailure]) }) - it('keeps a startup failure before its rollback failure', async () => { + it('keeps an initialize transport failure before its rollback failure', async () => { const startupFailure = new Error('target startup failed') const cleanupFailure = new Error('range cleanup failed') const direct = Promise.withResolvers() @@ -386,7 +386,7 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', expect(failure).toBeInstanceOf(AggregateError) const failures = (failure as AggregateError).errors as Error[] expect(failures.map(error => error.message)).toEqual([ - `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`, + `subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`, `subagent-acp: ${expectedFailure('stage: teardown; category: unknown')}`, ]) expect(failures[0]?.cause).toBe(startupFailure) @@ -1195,8 +1195,8 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) - it('classifies a rejected direct result independently of PID publication', async () => { - const processFailure = new Error('remote provider failed before publishing a PID') + it('classifies a rejected direct result as the active prompt transport failure', async () => { + const processFailure = new Error('remote provider failed after returning a handle') const direct = Promise.withResolvers() let realChild: SubprocessHandle | undefined const errors: Error[] = [] @@ -1225,7 +1225,7 @@ describe('dsh-subagent-acp', () => { }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailure('stage: process; category: process-start'), + diagnostic: expectedFailure('stage: prompt; category: transport'), stopReason: 'error', }) expect(errors).toContain(processFailure) @@ -1256,7 +1256,7 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) - it('rejects a spawn failure after provider-owned cleanup', async () => { + it('rejects a returned-handle startup failure after provider-owned cleanup', async () => { const privateCommand = '/nonexistent/private/SECRET_TOKEN/acp-agent' const error = await startAcpRun( request(), @@ -1264,7 +1264,7 @@ describe('dsh-subagent-acp', () => { ).catch((cause: unknown) => cause) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toBe( - `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`, + `subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`, ) expect((error as Error).message).not.toContain(privateCommand) }) @@ -1343,7 +1343,7 @@ describe('dsh-subagent-acp', () => { } }) - it('rejects a startup failure via the provider load path', async () => { + it('classifies a returned-handle startup failure through the provider load path', async () => { const ctx = new Context() await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) @@ -1356,7 +1356,7 @@ describe('dsh-subagent-acp', () => { env: {}, }) await expect(ctx.subagents.start('acp', request())).rejects.toThrow( - `subagent-acp: ${expectedFailure('stage: process; category: process-start')}`, + `subagent-acp: ${expectedFailure('stage: initialize; category: transport')}`, ) }) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6dba308789..f4441729c3 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -259,34 +259,12 @@ export async function consumeClaudeQuery( } } -/** Continue one SDK iterator after startup consumed its first message. */ -async function* prefetchedClaudeQuery( - first: SDKMessage, - iterator: AsyncIterator, -): AsyncGenerator { - let completed = false - try { - yield first - while (true) { - const next = await iterator.next() - if (next.done) { - completed = true - return - } - yield next.value - } - } finally { - /* v8 ignore next -- the official Query iterator always owns return(). */ - if (!completed) await iterator.return?.() - } -} - /** * Close the official query, terminate the managed process tree, and wait for * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. - * @param child - live shared-service handle that owns the CLI process tree; - * spawn-failed handles settle at the startup boundary instead. + * @param child - shared-service handle that owns the CLI process tree, including + * a published handle whose direct result later rejects. */ export async function disposeClaudeCodeChild( query: Pick | undefined, @@ -401,7 +379,7 @@ export function claudeQueryOptions( * Start one official Claude Agent SDK query and publish its one-shot run. * @param request - resolved shared subagent request. * @param spec - Workspace, environment, process service, and diagnostic policy. - * @returns the published run after Query, the real CLI handle, and the first SDK message exist. + * @returns the published run after both Query and the real CLI handle exist. */ export async function startClaudeCodeRun( request: SubagentStartRequest, @@ -430,9 +408,8 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let childFailure: Error | undefined - let childStartupFailure: Promise | undefined + let childProcessFailure: Promise | undefined let query: Query | undefined - let queryMessages: AsyncIterable | undefined let managedProcess: ManagedClaudeCodeProcess | undefined let diagnostic: string | undefined const capturePermissionDiagnostic = (value: string): void => { @@ -450,14 +427,14 @@ export async function startClaudeCodeRun( ): void => { child = captured managedProcess = process - childStartupFailure = captured.done.then( + childProcessFailure = captured.done.then( () => new Promise(() => {}), (error: unknown) => { childFailure = thrown(error) throw childFailure }, ) - void childStartupFailure.catch(() => {}) + void childProcessFailure.catch(() => {}) } try { query = officialQuery({ @@ -469,7 +446,7 @@ export async function startClaudeCodeRun( capturePermissionDiagnostic, ), }) - if (child === undefined || childStartupFailure === undefined) { + if (child === undefined || childProcessFailure === undefined) { throw new Error( 'subagent-claude-code: official SDK did not publish a controllable Claude Code process', ) @@ -477,18 +454,6 @@ export async function startClaudeCodeRun( if (isAborted(controller.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - const iterator = query[Symbol.asyncIterator]() - const first = await Promise.race([ - childStartupFailure, - iterator.next(), - ]) - if (isAborted(controller.signal)) { - throw new Error('subagent-claude-code: request was aborted before SDK startup') - } - if (first.done) { - throw new Error('subagent-claude-code: official SDK query ended before its first message') - } - queryMessages = prefetchedClaudeQuery(first.value, iterator) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted @@ -551,21 +516,24 @@ export async function startClaudeCodeRun( const publishedQuery = query const publishedChild = child - const publishedMessages = queryMessages + const publishedProcessFailure = childProcessFailure let receivedResult = false const result = settleRunResult({ attempt: async () => { try { - return await consumeClaudeQuery(publishedMessages, () => { - capturePermissionDiagnostic(unattendedDiagnostic( - spec.permissionMode, - 'tool permission', - 'denied', - 'Claude Code denied the request before an interactive prompt', - )) - }, () => { - receivedResult = true - }) + return await Promise.race([ + consumeClaudeQuery(publishedQuery, () => { + capturePermissionDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }, () => { + receivedResult = true + }), + publishedProcessFailure, + ]) } catch (error: unknown) { const processOutcome = managedProcess?.outcome let facts: ClaudeCodeFailureFacts diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 39b26bed2e..19b92d915e 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -691,10 +691,7 @@ describe('task admission and package contracts', () => { child.stdout.end() await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result', { - exitCode: 9, - signal: null, - }), + diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result'), stopReason: 'error', }) expect(warn).toHaveBeenCalledWith( @@ -1324,7 +1321,7 @@ describe('run publication, cancellation, and settlement', () => { await run.dispose() }) - it('rejects pre-abort and every incomplete startup transaction', async () => { + it('rejects pre-abort and incomplete Query or child acquisition', async () => { const preAborted = new AbortController() preAborted.abort() const unused = fakeRun() @@ -1483,19 +1480,6 @@ describe('run publication, cancellation, and settlement', () => { new Error('spawn /sdk/claude EACCES'), { code: 'EACCES', path: '/sdk/claude' }, ) - const failedSpawn = fakeChild({ - doneError: spawnError, - }) - const failed = fakeRun([], undefined, failedSpawn) - const failedStartup = startClaudeCodeRun(request(), failed.spec) - await expect(failedStartup) - .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) - await expect(failedStartup).rejects.not.toThrow('spawn /sdk/claude EACCES') - await expect(failedStartup).rejects.toMatchObject({ cause: spawnError }) - expect(failed.close).toHaveBeenCalledOnce() - expect(failedSpawn.terminate).toHaveBeenCalledOnce() - expect(failedSpawn.waitForExit).toHaveBeenCalledOnce() - const failedSpawnAbort = new AbortController() const cancelledFailedSpawn = fakeChild({ doneError: spawnError, @@ -1546,31 +1530,6 @@ describe('run publication, cancellation, and settlement', () => { .rejects.not.toThrow('spawn /sdk/claude EACCES') expect(cancelledFailedSpawnClose).toHaveBeenCalledOnce() - const failedSpawnCloseError = new Error('query close failed') - const failedSpawnClose = vi.fn(() => { throw failedSpawnCloseError }) - const failedSpawnWithCloseFailure = fakeChild({ - doneError: spawnError, - }) - queryMock.mockImplementationOnce(({ options }) => { - options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - return queryFrom([], undefined, failedSpawnClose) - }) - const failedWithCloseFailure = startClaudeCodeRun(request(), { - ...unused.spec, - spawn: () => failedSpawnWithCloseFailure.handle, - }) - await expect(failedWithCloseFailure) - .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) - await expect(failedWithCloseFailure) - .rejects.not.toThrow('spawn /sdk/claude EACCES') - await expect(failedWithCloseFailure).rejects.toMatchObject({ - message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, - errors: [ - expect.objectContaining({ cause: spawnError }), - expect.objectContaining({ cause: failedSpawnCloseError }), - ], - }) - const cleanupError = new Error('live child cleanup failed') const constructionError = new Error( 'query construction failed with a live child', @@ -1597,13 +1556,14 @@ describe('run publication, cancellation, and settlement', () => { .rejects.not.toThrow('live child cleanup failed') }) - it('waits for the first SDK message or a delayed provider startup rejection', async () => { + it('publishes before the first SDK message and settles a delayed provider rejection through result', async () => { const spawnError = Object.assign( new Error('spawn /sdk/claude ENOENT'), { code: 'ENOENT', path: '/sdk/claude' }, ) const child = fakeChild() const close = vi.fn() + const onError = vi.fn>() queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) async function* stream(): AsyncGenerator { @@ -1612,16 +1572,25 @@ describe('run publication, cancellation, and settlement', () => { return Object.assign(stream(), { close }) as unknown as Query }) - const startup = startClaudeCodeRun(request(), { + const run = await startClaudeCodeRun(request(), { cwd: '/workspace', permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, spawn: () => child.handle, + onError, }) - await nextTask() + expect(close).not.toHaveBeenCalled() + expect(child.terminate).not.toHaveBeenCalled() child.fail(spawnError) - await expect(startup).rejects.toMatchObject({ cause: spawnError }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), + stopReason: 'error', + }) + expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') + expect(errorCause(onError.mock.calls[0]?.[0])?.message).toBe(spawnError.message) + await run.dispose() expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() expect(child.waitForExit).toHaveBeenCalledOnce() @@ -1640,7 +1609,7 @@ describe('run publication, cancellation, and settlement', () => { return Object.assign(stream(), { close }) as unknown as Query }) - await expect(startClaudeCodeRun( + const run = await startClaudeCodeRun( request(undefined, controller.signal), { cwd: '/workspace', @@ -1649,12 +1618,17 @@ describe('run publication, cancellation, and settlement', () => { disposeGraceMs: 5, spawn: () => child.handle, }, - )).rejects.toThrow('aborted before SDK startup') + ) + await expect(run.result).resolves.toEqual({ + output: [], + stopReason: 'aborted', + }) + await run.dispose() expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() }) - it('rejects an SDK stream that ends before its first message', async () => { + it('settles an SDK stream that ends before its first message through result', async () => { const child = fakeChild() const close = vi.fn() queryMock.mockImplementationOnce(({ options }) => { @@ -1662,16 +1636,50 @@ describe('run publication, cancellation, and settlement', () => { return queryFrom([], undefined, close) }) - const startup = startClaudeCodeRun(request(), { + const run = await startClaudeCodeRun(request(), { cwd: '/workspace', permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, spawn: () => child.handle, }) - await expect(startup).rejects.toThrow( - expectedFailureDiagnostic('query-start', 'unknown'), - ) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'invalid-result'), + stopReason: 'error', + }) + await run.dispose() + expect(close).toHaveBeenCalledOnce() + expect(child.terminate).toHaveBeenCalledOnce() + expect(child.waitForExit).toHaveBeenCalledOnce() + }) + + it('settles a first-read SDK failure through the published result', async () => { + const child = fakeChild() + const close = vi.fn() + const firstReadFailure = new Error('first SDK read failed with SECRET_TOKEN') + const onError = vi.fn>() + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return queryFrom([], firstReadFailure, close) + }) + + const run = await startClaudeCodeRun(request(), { + cwd: '/workspace', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + onError, + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), + stopReason: 'error', + }) + expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') + expect(errorCause(onError.mock.calls[0]?.[0])?.message).toBe(firstReadFailure.message) + await run.dispose() expect(close).toHaveBeenCalledOnce() expect(child.terminate).toHaveBeenCalledOnce() expect(child.waitForExit).toHaveBeenCalledOnce() diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index c436acab0e..f5d19fc129 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -150,7 +150,7 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return - this.direct.signal(signal) + if (this.direct.running()) this.direct.signal(signal) const result = this.runSync(this.systemctl, [ '--user', 'kill', @@ -174,7 +174,9 @@ class SystemdScopeOwner implements BoundProcessOwner { terminateForHostExit(): void { if (this.stopped) return - try { this.direct.signal('SIGKILL') } catch { /* Continue with the native owner. */ } + try { + if (this.direct.running()) this.direct.signal('SIGKILL') + } catch { /* Continue with the native owner. */ } try { this.runSync(this.systemctl, [ '--user', @@ -287,9 +289,8 @@ function directOutcome( } function signalChildGroup(child: ReturnType, signal: 'SIGTERM' | 'SIGKILL'): void { - if (child.pid === undefined) return try { - process.kill(-child.pid, signal) + process.kill(-(child.pid as number), signal) } catch { try { child.kill(signal) } catch { /* The direct process already exited. */ } } diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index f25929d48c..7e199eabe5 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -1,7 +1,7 @@ /** Parent-side invocation and bootstrap state for the private native runner. */ import type { StdioOptions } from 'node:child_process' -import { accessSync, constants as fsConstants, statSync } from 'node:fs' +import { accessSync, constants as fsConstants, lstatSync, statSync } from 'node:fs' import { extname, isAbsolute } from 'node:path' import { inspect } from 'node:util' import { fileURLToPath } from 'node:url' @@ -141,7 +141,12 @@ function executableCandidateExists(candidate: string): boolean { try { return !statSync(candidate).isDirectory() } catch { - return false + try { + const entry = lstatSync(candidate) + return entry.isFile() || entry.isSymbolicLink() + } catch { + return false + } } } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ccd1f7cadf..56bce9bdd6 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -354,6 +354,22 @@ describe('Linux scope establishment and quiescence', () => { launched.result.owner.cleanup?.() }) + it('does not signal the direct group after the launcher exits', async () => { + const { child, result, requestPath, spawnSync } = launch(async () => activeUnit()) + consumeLinuxLaunchRequest(requestPath) + child.exit(0, null) + await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) + const processKill = vi.spyOn(process, 'kill') + + result.owner.signal('SIGTERM') + result.owner.terminateForHostExit() + + expect(processKill).not.toHaveBeenCalled() + expect(child.kills).toEqual([]) + expect(spawnSync).toHaveBeenCalledTimes(2) + result.owner.cleanup?.() + }) + it('runs direct fallback before the exact synchronous scope kill on host exit', () => { const events: string[] = [] const { child, result } = launch(async () => missingUnit(), { diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 8ca545503a..6d40d77ca3 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -403,11 +403,14 @@ describe('runner launch inputs', () => { const executable = join(directory, 'direct.exe') const directoryCandidate = join(directory, 'directory') const missingExecutable = join(directory, 'missing.exe') + const danglingAlias = join(directory, 'alias.exe') writeFileSync(executable, '') mkdirSync(`${directoryCandidate}.com`) writeFileSync(`${directoryCandidate}.exe`, '') + symlinkSync(missingExecutable, danglingAlias, 'file') expect(resolveWindowsExecutable(executable, '', {})).toBe(executable) expect(resolveWindowsExecutable(directoryCandidate, '', {})).toBe(`${directoryCandidate}.exe`) + expect(resolveWindowsExecutable(danglingAlias, '', {})).toBe(danglingAlias) expect(resolveWindowsExecutable(missingExecutable, '', {})).toBeUndefined() }) }) diff --git a/packages/subprocess/win32-process/README.i18n.yaml b/packages/subprocess/win32-process/README.i18n.yaml index 4f85e01409..b8206bbcb5 100644 --- a/packages/subprocess/win32-process/README.i18n.yaml +++ b/packages/subprocess/win32-process/README.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 packages/subprocess/win32-process/README.md -README.md: d2c17f79e839b5ebba09a76f6f45bd3b19f8724f -README.zh.md: da639d75ec824cf45319baf83c6194ab80ef80d0 +README.md: eac2639b91328efa69bea10816defb5e8f794a28 +README.zh.md: d4fd9f8a7fc8511512855462249840aed527d8f4 diff --git a/packages/subprocess/win32-process/README.md b/packages/subprocess/win32-process/README.md index d2c17f79e8..eac2639b91 100644 --- a/packages/subprocess/win32-process/README.md +++ b/packages/subprocess/win32-process/README.md @@ -28,7 +28,7 @@ This low-level Win32 process library is consumed by the Windows ACL sandbox and - **Restricted-token creation** — `RestrictedProcessSpawnOptions` requires the sandbox's primary token and uses `CreateProcessAsUserW`. Piped and inherited-stdio paths share command-line quoting, cwd, the restricted-token null-environment policy, checked return values, and handle cleanup. - **Piped process primitive** — `spawnPipedProcess()` creates anonymous stdin/stdout/stderr pipes, closes stdin immediately, returns the two read ends, and leaves process waiting and pipe draining to the caller. Every partial failure closes the handles already owned by the operation, and every Koffi out-parameter or struct allocation is freed after its Win32 lifetime. - **Inherited-stdio Job primitive** — `spawnInheritedJobProcess()` creates one kill-on-close Job, temporarily marks the current stdio handles inheritable, creates the restricted child suspended, assigns it to the Job, and then resumes its initial thread. Target code cannot run before Job assignment; controlled assignment or resume failures terminate the suspended child or close the assigned Job before releasing every owned handle. -- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions` requires a resolved `applicationName`, the complete target environment, and three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` calls `GetStartupInfoW`, strictly decodes libuv's `cbReserved2`/`lpReserved2` descriptor table to recover the three OS handles, temporarily marks them inheritable, and passes them through `STARTF_USESTDHANDLES`. It sends a sorted UTF-16LE environment block with `CREATE_UNICODE_ENVIRONMENT`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. The original command-line argv entry remains unchanged, and the runner can close its carrier descriptors without touching Node's own standard streams. +- **Ordinary Job runner primitive** — `CurrentTokenProcessSpawnOptions` requires a resolved `applicationName`, the complete target environment, and three runner CRT descriptors dedicated to target stdin, stdout, and stderr. `spawnCurrentTokenJobProcess()` maps those descriptors to OS handles through Node's exported `uv_get_osfhandle()`, rejects invalid results, temporarily marks the handles inheritable, and passes them through `STARTF_USESTDHANDLES`. It sends a sorted UTF-16LE environment block with `CREATE_UNICODE_ENVIRONMENT`, creates the target suspended through `CreateProcessW`, assigns it to an unnamed kill-on-close Job, and resumes it only after assignment. The original command-line argv entry remains unchanged, and the runner can close its carrier descriptors without touching Node's own standard streams. - **Ordinary settlement operations** — `pollProcessExit()` publishes direct exit separately, while `isJobEmpty()` reads `QueryInformationJobObject(JobObjectBasicAccountingInformation)` until `ActiveProcesses` reaches zero. Checked Job termination and handle closure keep the runner as the only native owner. - **Explicit settlement ownership** — `waitForProcessExit()` waits and closes a sandbox process handle; ordinary runner process polling, Job accounting, and checked Job termination/closure remain separate operations. `drainPipe()` reuses one native count slot while draining, frees it, and closes the pipe read handle. Each caller owns its result composition and returned handles. @@ -43,7 +43,7 @@ The process, stdio, and Job constants plus selected structure sizes and offsets g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes the `STARTUPINFOW` reserved-table offsets, pointer and handle widths, Unicode-environment flag, and the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants. +The Koffi `STARTUPINFOW` and `PROCESS_INFORMATION` definitions also assert their 64-bit sizes at module load. The probe additionally fixes pointer and handle widths, the Unicode-environment flag, and the basic Job accounting record size and `ActiveProcesses` offset used to determine quiescence; it remains the evidence for the other recorded offsets and constants. ## Model Experience diff --git a/packages/subprocess/win32-process/README.zh.md b/packages/subprocess/win32-process/README.zh.md index da639d75ec..d4fd9f8a7f 100644 --- a/packages/subprocess/win32-process/README.zh.md +++ b/packages/subprocess/win32-process/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **restricted-token 创建** — `RestrictedProcessSpawnOptions` 要求 sandbox 的 primary token,并使用 `CreateProcessAsUserW`。pipe 与 inherited-stdio 路径共用命令行引用、cwd、restricted-token 空环境策略、返回值检查与句柄清理。 - **管道进程原语** — `spawnPipedProcess()` 创建匿名 stdin/stdout/stderr 管道,立即关闭 stdin,并返回两个读取端;调用方负责等待进程与排空管道。任一局部失败都会关闭该操作已经拥有的句柄,并在各自 Win32 生命周期结束后释放每个 Koffi 输出槽与结构体分配。 - **继承 stdio 的 Job 原语** — `spawnInheritedJobProcess()` 创建一个 kill-on-close Job,临时把当前 stdio 句柄设为可继承,以 suspended 状态创建 restricted child,把它分配给 Job,再恢复初始线程。目标代码不会在 Job 分配前运行;受控的分配或恢复失败会终止 suspended child,或在释放全部已拥有句柄前关闭已分配的 Job。 -- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions` 要求已解析的 `applicationName`、完整 target 环境,以及三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 调用 `GetStartupInfoW`,严格解码 libuv 的 `cbReserved2`/`lpReserved2` 描述符表以取得三个 OS handle,临时把它们设为可继承,并通过 `STARTF_USESTDHANDLES` 传入。它使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序后的 UTF-16LE 环境块,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。原始命令行 argv 项保持不变,runner 也可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。 +- **ordinary Job runner 原语** — `CurrentTokenProcessSpawnOptions` 要求已解析的 `applicationName`、完整 target 环境,以及三个专用于 target stdin、stdout 与 stderr 的 runner CRT 描述符。`spawnCurrentTokenJobProcess()` 通过 Node 导出的 `uv_get_osfhandle()` 把这些描述符映射为 OS handle,拒绝无效结果,临时把 handle 设为可继承,并通过 `STARTF_USESTDHANDLES` 传入。它使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序后的 UTF-16LE 环境块,再以 suspended 状态通过 `CreateProcessW` 创建 target、把它分配给 unnamed kill-on-close Job,并只在分配后恢复。原始命令行 argv 项保持不变,runner 也可以关闭自己的 carrier 描述符,而不触碰 Node 自身的标准流。 - **ordinary 停稳操作** — `pollProcessExit()` 单独发布 direct exit,`isJobEmpty()` 则读取 `QueryInformationJobObject(JobObjectBasicAccountingInformation)`,直到 `ActiveProcesses` 归零。带检查的 Job 终止与 handle 关闭使 runner 保持唯一 native owner。 - **显式结算归属** — `waitForProcessExit()` 等待并关闭 sandbox process handle;ordinary runner 的 process polling、Job accounting 与 checked Job termination/closure 是独立操作。`drainPipe()` 在排空期间复用一个 native count slot,释放该分配并关闭管道读取句柄。每个调用方拥有自己的 result 组合与返回 handle。 @@ -43,7 +43,7 @@ process、stdio 与 Job 的常量以及选定结构体的大小和偏移由 [`ve g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp && ./abi-probe.exe ``` -Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定 `STARTUPINFOW` 保留表偏移、指针与 handle 宽度、Unicode 环境标志,以及用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。 +Koffi 的 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 定义还会在模块加载时断言各自的 64 位大小。该探针还固定指针与 handle 宽度、Unicode 环境标志,以及用于判断停稳的基础 Job accounting record 大小与 `ActiveProcesses` 偏移;其余已记录偏移和常量也由该探针提供证据。 ## Model Experience diff --git a/packages/subprocess/win32-process/src/abi.ts b/packages/subprocess/win32-process/src/abi.ts index 3a9eb6ee60..e34d941613 100644 --- a/packages/subprocess/win32-process/src/abi.ts +++ b/packages/subprocess/win32-process/src/abi.ts @@ -46,11 +46,3 @@ export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 export const STARTUPINFOW_SIZE = 104 /** x64 PROCESS_INFORMATION byte size verified by the native probe. */ export const PROCESS_INFORMATION_SIZE = 24 -/** libuv CRT descriptor flag marking an inherited descriptor as open. */ -export const CRT_FOPEN = 0x01 -/** Largest descriptor count accepted by libuv's inherited stdio table. */ -export const MAX_INHERITED_STDIO_DESCRIPTORS = 256 -/** Byte width of the descriptor count at the start of libuv's stdio table. */ -export const INHERITED_STDIO_COUNT_SIZE = 4 -/** x64 HANDLE width in libuv's inherited stdio table. */ -export const INHERITED_STDIO_HANDLE_SIZE = 8 diff --git a/packages/subprocess/win32-process/src/ffi.ts b/packages/subprocess/win32-process/src/ffi.ts index a9ec2c3f92..c8b8445e0a 100644 --- a/packages/subprocess/win32-process/src/ffi.ts +++ b/packages/subprocess/win32-process/src/ffi.ts @@ -53,12 +53,6 @@ export interface ProcessInfoOutput { dwThreadId: number } -/** STARTUPINFOW fields used to recover libuv's inherited descriptor table. */ -export interface StartupInfoOutput { - cbReserved2: number - lpReserved2: NativePtr | null -} - /** Generic Win32 calls consumed by restricted-token sandbox process operations. */ export interface Win32ProcessBindings { closeHandle(handle: NativePtr): number @@ -126,9 +120,9 @@ export interface Win32ProcessBindings { getStdHandle(stdHandle: number): NativePtr } -/** Generic Win32 calls plus the inherited startup-information reader. */ +/** Generic Win32 calls plus Node's libuv descriptor-to-handle bridge. */ export interface CurrentTokenProcessBindings extends Win32ProcessBindings { - getStartupInfoW(startupInfo: NativePtr): void + uvGetOsfhandle(fileDescriptor: number): NativePtr | null } /** Koffi STARTUPINFOW layout. */ @@ -222,15 +216,6 @@ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInp koffi.encode(startupInfo, STARTUPINFOW, fields) } -/** - * Decode the inherited-descriptor fields from STARTUPINFOW. - * @param startupInfo - struct filled by GetStartupInfoW. - * @returns reserved buffer size and pointer. - */ -export function decodeStartupInfo(startupInfo: NativePtr): StartupInfoOutput { - return koffi.decode(startupInfo, STARTUPINFOW) as StartupInfoOutput -} - /** * Allocate a zeroed PROCESS_INFORMATION. * @returns allocated struct pointer. @@ -269,6 +254,7 @@ function bindingContext(): Win32BindingContext { function bindings(): CurrentTokenProcessBindings { if (cached !== undefined) return cached const { kernel32, advapi32, bind } = bindingContext() + const node = koffi.load(null) cached = { closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), getLastError: bind(kernel32, 'GetLastError', 'uint32', []), @@ -301,7 +287,7 @@ function bindings(): CurrentTokenProcessBindings { terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), terminateJobObject: bind(kernel32, 'TerminateJobObject', 'int', [PVOID, 'uint32']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), - getStartupInfoW: bind(kernel32, 'GetStartupInfoW', 'void', [koffi.pointer(STARTUPINFOW)]), + uvGetOsfhandle: node.func('uv_get_osfhandle', PVOID, ['int']), } as unknown as CurrentTokenProcessBindings return cached } diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index f87dacdf47..d8770206fc 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -9,7 +9,6 @@ import { allocUint32, decodeProcessInfo, decodePtr, - decodeStartupInfo, decodeUint32, encodeStartupInfo, isNullPtr, @@ -58,7 +57,9 @@ function compareWindowsEnvironmentKeys( [left]: readonly [string, string], [right]: readonly [string, string], ): number { - return left.toUpperCase().localeCompare(right.toUpperCase(), 'en-US') + const foldedLeft = left.toUpperCase() + const foldedRight = right.toUpperCase() + return foldedLeft < foldedRight ? -1 : foldedLeft > foldedRight ? 1 : 0 } function encodeWindowsEnvironment(env: Readonly>): Buffer { @@ -373,48 +374,12 @@ function targetCarrierHandles( api: CurrentTokenProcessBindings, descriptors: CurrentTokenStdioFileDescriptors, ): ProcessStandardHandles { - let startupInfo: NativePtr | undefined - let table: Buffer - try { - startupInfo = allocStartupInfo() - api.getStartupInfoW(startupInfo) - const inherited = decodeStartupInfo(startupInfo) - if (isNullPtr(inherited.lpReserved2)) { - throw new Error('GetStartupInfoW returned no inherited stdio table') - } - if (inherited.cbReserved2 < abi.INHERITED_STDIO_COUNT_SIZE) { - throw new Error('GetStartupInfoW returned a truncated inherited stdio table') - } - table = Buffer.from(koffi.view(inherited.lpReserved2, inherited.cbReserved2)) - } finally { - freeNative(startupInfo) - } - const count = table.readUInt32LE(0) - if (count > abi.MAX_INHERITED_STDIO_DESCRIPTORS) { - throw new Error(`inherited stdio table declares unsupported descriptor count ${String(count)}`) - } - const requiredSize = abi.INHERITED_STDIO_COUNT_SIZE - + count - + count * abi.INHERITED_STDIO_HANDLE_SIZE - if (table.length < requiredSize) { - throw new Error('GetStartupInfoW returned a truncated inherited stdio table') - } const get = (fileDescriptor: number, label: string): NativePtr => { - if (fileDescriptor >= count) { - throw new Error(`inherited stdio table is missing target ${label} fd ${String(fileDescriptor)}`) + const handle = api.uvGetOsfhandle(fileDescriptor) + if (isNullPtr(handle) || handle === -1n || handle === -2n) { + throw new Error(`uv_get_osfhandle returned an invalid handle for target ${label} fd ${String(fileDescriptor)}`) } - const flags = table[abi.INHERITED_STDIO_COUNT_SIZE + fileDescriptor] as number - if ((flags & abi.CRT_FOPEN) === 0) { - throw new Error(`inherited stdio table marks target ${label} fd ${String(fileDescriptor)} closed`) - } - const handleOffset = abi.INHERITED_STDIO_COUNT_SIZE - + count - + fileDescriptor * abi.INHERITED_STDIO_HANDLE_SIZE - const handle = table.readBigUInt64LE(handleOffset) - if (handle === 0n || handle === 0xFFFFFFFFFFFFFFFFn || handle === 0xFFFFFFFFFFFFFFFEn) { - throw new Error(`inherited stdio table contains an invalid handle for target ${label} fd ${String(fileDescriptor)}`) - } - return handle as NativePtr + return handle } return { stdin: get(descriptors.stdin, 'stdin'), diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index fbf3f32d09..fcdb2dd203 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -12,9 +12,6 @@ import { import { CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, - CRT_FOPEN, - INHERITED_STDIO_COUNT_SIZE, - INHERITED_STDIO_HANDLE_SIZE, JOBOBJECT_BASIC_ACCOUNTING_ACTIVE_PROCESSES_OFFSET, JOBOBJECT_BASIC_ACCOUNTING_SIZE, JobObjectBasicAccountingInformation, @@ -27,30 +24,6 @@ import type { NativePtr, } from '../src/index.ts' -function inheritedStdioTable(count = 7): Buffer { - const table = Buffer.alloc( - INHERITED_STDIO_COUNT_SIZE + count + count * INHERITED_STDIO_HANDLE_SIZE, - ) - table.writeUInt32LE(count, 0) - for (let fileDescriptor = 0; fileDescriptor < count; fileDescriptor++) { - table[INHERITED_STDIO_COUNT_SIZE + fileDescriptor] = CRT_FOPEN - table.writeBigUInt64LE( - BigInt(100 + fileDescriptor), - INHERITED_STDIO_COUNT_SIZE + count + fileDescriptor * INHERITED_STDIO_HANDLE_SIZE, - ) - } - return table -} - -function startupInfo( - table: Buffer | null, - size = table?.length ?? 0, -): CurrentTokenProcessBindings['getStartupInfoW'] { - return vi.fn((startup: NativePtr) => { - koffi.encode(startup, STARTUPINFOW, { cbReserved2: size, lpReserved2: table }) - }) -} - function options( overrides: Partial = {}, ): CurrentTokenProcessSpawnOptions { @@ -66,7 +39,6 @@ function options( } function api(overrides: Partial = {}): CurrentTokenProcessBindings { - const table = inheritedStdioTable() return { createJobObjectW: vi.fn(() => 50n), setInformationJobObject: vi.fn(() => 1), @@ -75,7 +47,7 @@ function api(overrides: Partial = {}): CurrentToken return 1 }), getStdHandle: vi.fn((selector: number) => BigInt(100 - selector)), - getStartupInfoW: startupInfo(table), + uvGetOsfhandle: vi.fn((fileDescriptor: number) => BigInt(100 + fileDescriptor)), setHandleInformation: vi.fn(() => 1), createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, _startup, info) => { koffi.encode(info, PROCESS_INFORMATION, { @@ -129,7 +101,13 @@ describe('ordinary Job process operations', () => { }) expect(spawnCurrentTokenJobProcess(bindings, options({ args: ['literal $VALUE', 'a b'], - env: { ZED: 'last', '=C:': 'C:\\work', alpha: 'first' }, + env: { + ZED: 'last', + '=C:': 'C:\\work', + alpha: 'first', + ALPHA: 'same-folded-key', + _A: 'underscore', + }, }))).toEqual({ pid: 1234, process: 60n, job: 50n }) const environment = createProcessW.mock.calls[0]?.[6] as Buffer expect(createProcessW).toHaveBeenCalledWith( @@ -144,7 +122,9 @@ describe('ordinary Job process operations', () => { expect.anything(), expect.anything(), ) - expect(environment.toString('utf16le')).toBe('=C:=C:\\work\0alpha=first\0ZED=last\0\0') + expect(environment.toString('utf16le')).toBe( + '=C:=C:\\work\0alpha=first\0ALPHA=same-folded-key\0ZED=last\0_A=underscore\0\0', + ) expect(events.indexOf('create')).toBeLessThan(events.indexOf('assign')) expect(events.indexOf('assign')).toBeLessThan(events.indexOf('resume')) expect(events).toContain('close:61') @@ -164,8 +144,12 @@ describe('ordinary Job process operations', () => { it('resolves the target carrier descriptors and restores their handle flags', () => { let startup: Record | undefined const setHandleInformation = vi.fn(() => 1) + const uvGetOsfhandle = vi.fn( + (fileDescriptor: number) => BigInt(100 + fileDescriptor) as NativePtr, + ) const bindings = api({ setHandleInformation, + uvGetOsfhandle, createProcessW: vi.fn((_app, _line, _pa, _ta, _inherit, _flags, _env, _cwd, infoPtr, processInfo) => { startup = koffi.decode(infoPtr, STARTUPINFOW) as Record koffi.encode(processInfo, PROCESS_INFORMATION, { @@ -179,6 +163,9 @@ describe('ordinary Job process operations', () => { }) expect(spawnCurrentTokenJobProcess(bindings, options())).toEqual({ pid: 1234, process: 60n, job: 50n }) expect(startup).toMatchObject({ hStdInput: 104n, hStdOutput: 105n, hStdError: 106n }) + expect(uvGetOsfhandle).toHaveBeenNthCalledWith(1, 4) + expect(uvGetOsfhandle).toHaveBeenNthCalledWith(2, 5) + expect(uvGetOsfhandle).toHaveBeenNthCalledWith(3, 6) expect(setHandleInformation.mock.calls).toEqual([ [104n, 1, 1], [105n, 1, 1], [106n, 1, 1], [104n, 1, 0], [105n, 1, 0], [106n, 1, 0], @@ -241,40 +228,19 @@ describe('ordinary Job process operations', () => { expect(closeHandle).toHaveBeenCalledExactlyOnceWith(50n) }) - it('strictly validates the inherited libuv descriptor table before target creation', () => { - const expectFailure = ( - getStartupInfoW: CurrentTokenProcessBindings['getStartupInfoW'], - message: string, - ): void => { + it('rejects invalid carrier handles before target creation', () => { + const expectFailure = (invalid: NativePtr | null): void => { const closeHandle = vi.fn(() => 1) - expect(() => spawnCurrentTokenJobProcess(api({ closeHandle, getStartupInfoW }), options())) - .toThrow(message) + const createProcessW = vi.fn(() => 1) + expect(() => spawnCurrentTokenJobProcess(api({ + closeHandle, + createProcessW, + uvGetOsfhandle: vi.fn(() => invalid), + }), options())).toThrow('uv_get_osfhandle returned an invalid handle for target stdin fd 4') expect(closeHandle).toHaveBeenCalledWith(50n) + expect(createProcessW).not.toHaveBeenCalled() } - expectFailure(startupInfo(null), 'no inherited stdio table') - expectFailure(startupInfo(Buffer.alloc(3)), 'truncated inherited stdio table') - - const excessive = Buffer.alloc(INHERITED_STDIO_COUNT_SIZE) - excessive.writeUInt32LE(257, 0) - expectFailure(startupInfo(excessive), 'unsupported descriptor count 257') - - const truncated = Buffer.alloc(INHERITED_STDIO_COUNT_SIZE) - truncated.writeUInt32LE(7, 0) - expectFailure(startupInfo(truncated), 'truncated inherited stdio table') - expectFailure(startupInfo(inheritedStdioTable(6)), 'missing target stderr fd 6') - - const closed = inheritedStdioTable() - closed[INHERITED_STDIO_COUNT_SIZE + 4] = 0 - expectFailure(startupInfo(closed), 'marks target stdin fd 4 closed') - - for (const invalid of [0n, 0xFFFFFFFFFFFFFFFFn, 0xFFFFFFFFFFFFFFFEn]) { - const table = inheritedStdioTable() - table.writeBigUInt64LE( - invalid, - INHERITED_STDIO_COUNT_SIZE + 7 + 4 * INHERITED_STDIO_HANDLE_SIZE, - ) - expectFailure(startupInfo(table), 'invalid handle for target stdin fd 4') - } + for (const invalid of [null, 0n, -1n, -2n]) expectFailure(invalid as NativePtr | null) }) }) diff --git a/packages/subprocess/win32-process/verify/abi-probe.cpp b/packages/subprocess/win32-process/verify/abi-probe.cpp index 6e1d4b4e60..f57d707702 100644 --- a/packages/subprocess/win32-process/verify/abi-probe.cpp +++ b/packages/subprocess/win32-process/verify/abi-probe.cpp @@ -10,8 +10,6 @@ int wmain() P(sizeof(HANDLE)); P(sizeof(STARTUPINFOW)); P(offsetof(STARTUPINFOW, dwFlags)); - P(offsetof(STARTUPINFOW, cbReserved2)); - P(offsetof(STARTUPINFOW, lpReserved2)); P(offsetof(STARTUPINFOW, hStdInput)); P(offsetof(STARTUPINFOW, hStdOutput)); P(offsetof(STARTUPINFOW, hStdError)); @@ -43,8 +41,7 @@ int wmain() static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); - static_assert(sizeof(int) == 4, "libuv stdio descriptor count size"); - static_assert(sizeof(HANDLE) == 8, "libuv stdio HANDLE size"); + static_assert(sizeof(HANDLE) == 8, "HANDLE size"); static_assert(CREATE_SUSPENDED == 0x4, "suspended process flag"); static_assert(CREATE_UNICODE_ENVIRONMENT == 0x400, "Unicode environment flag"); static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); From 8bb19f8f7fde89c000aec038aa7af7a5a7b5239b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 14:36:13 +0800 Subject: [PATCH 092/110] fix(subprocess): close containment review findings --- packages/subagent/subagent-acp/src/run.ts | 4 +- .../subprocess-local/src/linux-scope.ts | 5 +- .../subprocess-local/src/spawn-runner.ts | 58 ++++++++++++----- .../subprocess/subprocess-local/src/spawn.ts | 4 +- .../tests/linux-scope.spec.ts | 22 +++++++ .../tests/spawn-runner.spec.ts | 62 ++++++++++++++++++- .../subprocess-local/tests/spawn.spec.ts | 38 ++++++++++++ 7 files changed, 170 insertions(+), 23 deletions(-) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5e6a0b6545..b39fc4dfe4 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -398,8 +398,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe try { return await Promise.race([processDone, aborted.promise]) } catch { - // The active protocol failure remains authoritative when exit observation fails. - /* v8 ignore next -- a published child.done cannot reject; spawn rejection is consumed before publication. */ + // A provider rejection after handle publication leaves no direct outcome; + // the active protocol failure remains authoritative. return processOutcome } finally { bound.removeEventListener('abort', onObservationAbort) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index f5d19fc129..3dfae47c0b 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -150,7 +150,9 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return - if (this.direct.running()) this.direct.signal(signal) + if (!this.established && !existsSync(this.files.requestPath)) this.established = true + const directFallbackRequired = !this.established + if (directFallbackRequired && this.direct.running()) this.direct.signal(signal) const result = this.runSync(this.systemctl, [ '--user', 'kill', @@ -162,6 +164,7 @@ class SystemdScopeOwner implements BoundProcessOwner { if (signal === 'SIGKILL') this.killFailure = undefined return } + if (!directFallbackRequired && this.direct.running()) this.direct.signal(signal) if (signal === 'SIGKILL') { const output = `${result.stdout}\n${result.stderr}` if (!MISSING_UNIT.test(output)) { diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 4e5d3e8e60..57a4209a0d 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -1,6 +1,7 @@ /** One-shot Linux exec bootstrap and Windows Job-owning subprocess runner. */ import { closeSync } from 'node:fs' +import koffi from 'koffi' import { closeHandleChecked, isJobEmpty, @@ -40,6 +41,31 @@ type RunnerHost = Pick): never @@ -51,6 +77,7 @@ export interface SpawnRunnerInternals { isJobEmpty: typeof isJobEmpty terminateJob: typeof terminateJob closeHandleChecked: typeof closeHandleChecked + uvErrorBindings?: UvErrorBindings } const defaultInternals: SpawnRunnerInternals = { @@ -66,6 +93,8 @@ const defaultInternals: SpawnRunnerInternals = { closeHandleChecked, } +const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'EAGAIN', 'EMFILE', 'ENFILE', 'ENOENT']) + function nodeSpawnError( source: Pick, syscall: string, @@ -87,7 +116,12 @@ function nodeSpawnError( } } -function asSpawnError(error: unknown, program: string, args: readonly string[]): SerializedRunnerError { +function asSpawnError( + error: unknown, + program: string, + args: readonly string[], + internals: Pick, +): SerializedRunnerError { const serialized = serializeRunnerError(error) if (!(error instanceof Win32Error)) { return serialized.code === undefined @@ -97,23 +131,15 @@ function asSpawnError(error: unknown, program: string, args: readonly string[]): spawnargs: [...args], }) } - if (error.win32Code === 2 || error.win32Code === 3 || error.win32Code === 267) { - return nodeSpawnError(serialized, `spawn ${program}`, 'ENOENT', -4058, { + const uv = internals.uvErrorBindings ?? loadUvErrorBindings() + const errno = uv.translateSystemError(error.win32Code) + const code = uv.errorName(errno) + if (NODE_SPAWN_DETAIL_CODES.has(code)) { + return nodeSpawnError(serialized, `spawn ${program}`, code, errno, { path: program, spawnargs: [...args], }) } - if (error.win32Code === 740) { - return nodeSpawnError(serialized, `spawn ${program}`, 'EACCES', -4092, { - path: program, - spawnargs: [...args], - }) - } - const [code, errno] = error.win32Code === 5 - ? ['EPERM', -4048] - : error.win32Code === 193 - ? ['EFTYPE', -4028] - : ['UNKNOWN', -4094] return nodeSpawnError(serialized, 'spawn', code, errno, {}) } @@ -198,7 +224,7 @@ function runLinux( } catch (error) { writeLinuxStartupError(files, { type: 'error', - error: asSpawnError(error, argv[0] as string, argv.slice(1)), + error: asSpawnError(error, argv[0] as string, argv.slice(1), internals), }) host.exitCode = 127 } @@ -329,7 +355,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.argv.slice(1)), + error: asSpawnError(error, this.argv[0] as string, this.argv.slice(1), this.internals), }, 0) return } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 4d8019df18..54bfc9decc 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -481,6 +481,7 @@ export function bindManagedProcess( } let graceTimer: ReturnType | undefined + let terminationStarted = false let rangeExitObserved = false let rangeExitObservation: Promise | undefined let settled = false @@ -520,7 +521,8 @@ export function bindManagedProcess( } const terminateWithReason = (cancellationReason: unknown): void => { - if (rangeExitObserved || graceTimer !== undefined) return + if (rangeExitObserved || terminationStarted) return + terminationStarted = true // Keep the shared observation rejection available to waitForExit() without // leaking an unhandled rejection when a caller only invokes terminate(). void observeRangeExit().catch(() => {}) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 56bce9bdd6..2989ab06a0 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -193,6 +193,28 @@ describe('Linux scope establishment and quiescence', () => { expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false) }) + it('uses the scope alone after establishment and the direct range only when scope signalling fails', async () => { + const spawnSync = vi.fn() + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'scope signal failed' }) + const { child, result, requestPath } = launch(async () => activeUnit(), { + spawnSync: spawnSync as never, + }) + consumeLinuxLaunchRequest(requestPath) + const processKill = vi.spyOn(process, 'kill').mockReturnValue(true) + + result.owner.signal('SIGTERM') + expect(processKill).not.toHaveBeenCalled() + + result.owner.signal('SIGKILL') + expect(processKill).toHaveBeenCalledExactlyOnceWith(-321, 'SIGKILL') + expect(spawnSync).toHaveBeenCalledTimes(2) + + child.exit(null, 'SIGKILL') + await expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' }) + result.owner.cleanup?.() + }) + it('uses manager-observed unit existence as establishment proof', async () => { const { child, result } = launch(async () => activeUnit('inactive')) await expect(result.owner.waitForExit()).resolves.toBeUndefined() diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 6d40d77ca3..e2e4e4e3a8 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -105,6 +105,10 @@ function internals(overrides: Partial = {}): 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, } } @@ -603,20 +607,26 @@ describe('Windows Job runner protocol owner', () => { expect(host.exitCode).toBe(0) }) - it('maps the bounded Win32 process-creation error classes', async () => { + 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'], - [3, 'ENOENT', -4058, true, 'tool.exe'], - [267, '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'], ] 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', @@ -640,6 +650,52 @@ describe('Windows Job runner protocol owner', () => { } }) + it('loads the error translation functions from Node-linked libuv', async () => { + for (let attempt = 0; attempt < 2; attempt += 1) { + 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', + errno: process.platform === 'win32' ? -4058 : -2, + path: 'tool.exe', + spawnargs: ['literal arg'], + }, + }]) + } + }) + + it.skipIf(process.platform !== 'win32')('preserves native EMFILE and UNKNOWN translations', async () => { + for (const [win32Code, code, errno, enriched] of [ + [4, 'EMFILE', -4066, true], + [999, 'UNKNOWN', -4094, 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, errno }, + }]) + 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', spawnargs: ['literal arg'] }) + } else { + expect(result.error).not.toHaveProperty('path') + expect(result.error).not.toHaveProperty('spawnargs') + } + } + }) + it('rejects a Windows runner without an initial IPC channel', async () => { const disconnected = new FakeRunnerHost() disconnected.connected = false diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index a36849c42f..0edd0af591 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -908,6 +908,44 @@ describe('coverage seams', () => { } }) + it('does not restart managed termination after the escalation timer fires', async () => { + vi.useFakeTimers() + try { + const direct = Promise.withResolvers<{ exitCode: number; signal: null }>() + const stopped = Promise.withResolvers() + const signal = vi.fn() + const handle = bindManagedProcess(spec('true', { + graceMs: 10, + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, + }), { + stdin: null, + stdout: null, + stderr: null, + direct: direct.promise, + owner: { + signal, + waitForExit: () => stopped.promise, + terminateForHostExit: vi.fn(), + }, + }) + + handle.terminate() + await vi.advanceTimersByTimeAsync(10) + handle.terminate() + await vi.advanceTimersByTimeAsync(10) + expect(signal).toHaveBeenCalledTimes(2) + expect(signal).toHaveBeenNthCalledWith(1, 'SIGTERM', expect.any(Error)) + expect(signal).toHaveBeenNthCalledWith(2, 'SIGKILL', undefined) + + stopped.resolve(undefined) + await expect(handle.waitForExit()).resolves.toBe(true) + direct.resolve({ exitCode: 0, signal: null }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + } finally { + vi.useRealTimers() + } + }) + it('delivers an already-aborted managed spawn reason before target settlement', async () => { const reason = null const controller = new AbortController() From 5e3eaea742b06a2f69803c65c887396e7656bcd7 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 15:22:12 +0800 Subject: [PATCH 093/110] refactor(subprocess): keep runner error bindings one-shot --- .../subprocess-local/src/spawn-runner.ts | 6 +--- .../tests/spawn-runner.spec.ts | 32 +++++++++---------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 57a4209a0d..15d08e51fd 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -46,12 +46,9 @@ interface UvErrorBindings { errorName(error: number): string } -let cachedUvErrorBindings: UvErrorBindings | undefined - function loadUvErrorBindings(): UvErrorBindings { - if (cachedUvErrorBindings !== undefined) return cachedUvErrorBindings const node = koffi.load(null) - cachedUvErrorBindings = { + return { translateSystemError: node.func( 'uv_translate_sys_error', 'int', @@ -63,7 +60,6 @@ function loadUvErrorBindings(): UvErrorBindings { ['int'], ) as unknown as UvErrorBindings['errorName'], } - return cachedUvErrorBindings } /** Injectable operations used by the protocol-owner tests. */ diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index e2e4e4e3a8..778594197f 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -651,23 +651,21 @@ describe('Windows Job runner protocol owner', () => { }) it('loads the error translation functions from Node-linked libuv', async () => { - for (let attempt = 0; attempt < 2; attempt += 1) { - 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', - errno: process.platform === 'win32' ? -4058 : -2, - path: 'tool.exe', - spawnargs: ['literal arg'], - }, - }]) - } + 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', + errno: process.platform === 'win32' ? -4058 : -2, + path: 'tool.exe', + spawnargs: ['literal arg'], + }, + }]) }) it.skipIf(process.platform !== 'win32')('preserves native EMFILE and UNKNOWN translations', async () => { From 6d49ac2ef692f51715ffec4a7b7ca1cf87c382f6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 15:37:14 +0800 Subject: [PATCH 094/110] fix(subprocess): align runner bindings with current types --- packages/subprocess/subprocess-local/src/spawn-runner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 15d08e51fd..8860eaa29a 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -53,12 +53,12 @@ function loadUvErrorBindings(): UvErrorBindings { 'uv_translate_sys_error', 'int', ['int'], - ) as unknown as UvErrorBindings['translateSystemError'], + ), errorName: node.func( 'uv_err_name', 'str', ['int'], - ) as unknown as UvErrorBindings['errorName'], + ), } } From fc19a0a3fa8beecd4a22348583574cea302d8bef Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 19:26:08 +0800 Subject: [PATCH 095/110] fix(subprocess): clarify provider failures and scope polling --- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +-- .../2026-07-26-subprocess-seam.md | 2 +- .../2026-07-26-subprocess-seam.zh.md | 2 +- ...28-subprocess-native-containment.i18n.yaml | 4 +-- ...026-08-28-subprocess-native-containment.md | 6 ++-- ...-08-28-subprocess-native-containment.zh.md | 6 ++-- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +-- .../implemented/feature/2026-07-06-sandbox.md | 6 ++-- .../feature/2026-07-06-sandbox.zh.md | 6 ++-- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/shell.i18n.yaml | 4 +-- docs/subsystems/shell.md | 7 ++-- docs/subsystems/shell.zh.md | 7 ++-- packages/fs/tool-fs-search/src/search-core.ts | 9 +++--- .../tool-fs-search/tests/integration.spec.ts | 5 +-- .../fs/tool-fs-search/tests/tools.spec.ts | 10 +++--- packages/shell/bash-local/README.i18n.yaml | 4 +-- packages/shell/bash-local/README.md | 2 +- packages/shell/bash-local/README.zh.md | 2 +- packages/shell/bash-local/src/index.ts | 32 +++++++++---------- .../shell/bash-local/tests/executor.spec.ts | 30 +++++++++++++++-- packages/shell/bash-sandbox/README.i18n.yaml | 4 +-- packages/shell/bash-sandbox/README.md | 4 +-- packages/shell/bash-sandbox/README.zh.md | 4 +-- packages/shell/bash-sandbox/src/index.ts | 22 +++++++------ .../tests/partial-landlock.spec.ts | 6 ++-- .../shell/bash-sandbox/tests/sandbox.spec.ts | 8 ++--- packages/shell/pwsh-local/README.i18n.yaml | 4 +-- packages/shell/pwsh-local/README.md | 2 +- packages/shell/pwsh-local/README.zh.md | 2 +- packages/shell/pwsh-local/src/index.ts | 28 ++++++++-------- .../shell/pwsh-local/tests/executor.spec.ts | 23 ++++++++++--- packages/shell/pwsh-sandbox/README.i18n.yaml | 4 +-- packages/shell/pwsh-sandbox/README.md | 2 +- packages/shell/pwsh-sandbox/README.zh.md | 2 +- packages/shell/pwsh-sandbox/src/index.ts | 25 ++++++++------- .../shell/pwsh-sandbox/tests/sandbox.spec.ts | 4 +-- packages/shell/shell/README.i18n.yaml | 4 +-- packages/shell/shell/README.md | 2 +- packages/shell/shell/README.zh.md | 2 +- packages/shell/shell/src/types.ts | 5 ++- .../subprocess-local/README.i18n.yaml | 4 +-- .../subprocess/subprocess-local/README.md | 4 +-- .../subprocess/subprocess-local/README.zh.md | 4 +-- .../subprocess-local/src/linux-scope.ts | 16 ++++++++-- .../tests/linux-scope.spec.ts | 25 +++++++++++++++ 51 files changed, 232 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index b31d6baf7e..2f25210b87 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.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-07-26-subprocess-seam.md -2026-07-26-subprocess-seam.md: b892c43a4027815692dcc8082d4a2cc9feea18c7 -2026-07-26-subprocess-seam.zh.md: 78e290a14bc0cdf50434462dd854bdc1355d4938 +2026-07-26-subprocess-seam.md: 359e0c60997cb51df427db69c02e0200f293d07c +2026-07-26-subprocess-seam.zh.md: 56dd228c98a5eb71cb809c9f0eab954ed414967d diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index b892c43a40..359e0c6099 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -19,7 +19,7 @@ A new `subprocess/` capability family owns "run and manage a process"; the bash Every composition that loads a bash executor also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, the Python bundled runtime, and inline test configs). -Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta. +Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: the subprocess service rejects `done` when spawn or provider failure prevents a direct outcome, without exposing whether target execution began. The executor therefore injects the stage-neutral `subprocess failed before reporting an outcome: …` note into exactly one `readOutput()` delta instead of manufacturing process stderr. Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 78e290a14b..56dd228c98 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -19,7 +19,7 @@ Status: implemented 每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时以及各内联测试配置。 -后台进程的存续期从执行器移到了服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,服务会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 +后台进程的存续期从执行器移到了服务:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(服务的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:当 spawn 或 provider failure 使 direct outcome 无法产生时,subprocess 服务会 reject `done`,但不会公开 target 是否已经开始执行。因此执行器不再伪造进程 stderr,而是把不声明阶段的 `subprocess failed before reporting an outcome: …` 提示注入恰好一个 `readOutput()` 增量。 基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 9e8b5c8fa1..1b1168725a 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 2ece0bf300336dab82c42d12fd97e2eaf4cad1f9 -2026-08-28-subprocess-native-containment.zh.md: b59ed6e6c5ef984e3c0162921fd0f81359bcc5d5 +2026-08-28-subprocess-native-containment.md: 378a109b7d9b16593eda4ae06ab45cb27a6319c0 +2026-08-28-subprocess-native-containment.zh.md: d0bdf71eb8602896adab853bd7d3c2a969caad21 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 2ece0bf300..378a109b7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -22,7 +22,7 @@ Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the 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 the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. After establishment, an inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. 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 the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. The parent checks this unresolved interval every 50 milliseconds; after establishment, active-state queries back off exponentially to the existing 5-second systemctl bound. An inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. 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. @@ -32,7 +32,7 @@ The Windows parent starts the provider runner from a bootstrap cwd and environme The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. -The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof. +The parent permanently latches a validated numeric `target-exit` as soon as it arrives, before the existing stdout/stderr close or bounded-drain barrier settles. `.done` waits only for that stdio barrier and then returns the latched result. A later Job query, range-settlement failure, IPC loss, or abnormal runner exit rejects only `waitForExit()` and cannot replace the direct result. Infrastructure failure before a valid target result rejects `.done`, but that rejection does not reveal whether target execution began. On disconnect or result-send failure, the runner stops protocol work, terminates and closes its only Job handle, and exits nonzero. Closing the last Job handle kills remaining members but does not convert the disconnected path into a successful quiescence proof. ### Private dispatch and protocol @@ -54,7 +54,7 @@ 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, the three scope-establishment states, and exactly-once PTY managed-owner 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, the three scope-establishment states, prompt pre-establishment polling with bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and invalid-result 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index b59ed6e6c5..d0bdf71eb8 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.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 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。建立之后,inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 +request 被消费或 manager 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。parent 每 50 毫秒检查一次这段未决区间;建立后,active-state 查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `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 后代。 @@ -32,7 +32,7 @@ Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 tar runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 -parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdout/stderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。 +parent 会在收到经过校验、只含数字的 `target-exit` 时立即永久锁存它,此时既有 stdout/stderr close 或有界 drain barrier 可能尚未完成。`.done` 只继续等待该 stdio barrier,随后返回已锁存的结果。后续 Job query、range settlement failure、IPC loss 或 runner 异常退出只会使 `waitForExit()` reject,不能替换 direct result。在有效 target result 到达前发生 infrastructure failure 才会使 `.done` reject,但该 rejection 不会说明 target 是否已经开始执行。disconnect 或 result-send failure 会让 runner 停止协议工作、终止并关闭自己唯一的 Job handle,然后以非零状态退出。最后一个 Job handle 关闭会终止剩余成员,但不会把 disconnected 路径改写成成功的完全停稳证明。 ### 私有分派与协议 @@ -54,7 +54,7 @@ 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、三种 scope 建立状态,以及 PTY managed-owner 恰好一次 cleanup。 +- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、三种 scope 建立状态、建立前快速轮询与建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与无效结果拒绝、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。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index e89b17232f..1af7100e6a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: c4153c6ef47b6d11290094adf06b8107418be889 -2026-07-06-sandbox.zh.md: 9191b1b8d2d4e65acab8b935d0da170066876aea +2026-07-06-sandbox.md: ed8cac5f98629ee749f8bb10f62b17ef47c44a16 +2026-07-06-sandbox.zh.md: a5014d41251984c2f28a1ef164b9593a2ad49f84 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index c4153c6ef4..ed8cac5f98 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -38,7 +38,7 @@ Four `cordis.yml` entries turn an unconfined coding agent into the sandboxed pro The swap is invisible to every consumer of `ctx.shell`: the bash tools, hook commands, and background jobs run exactly as before by directly spawning the wrapped argv the provider returns. Deleting the `sandbox` and `permission` entries and replacing `bash` with `@deepseek-ai/dsh-bash-local` is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only `approval` keeps confinement but fails every escalation closed with its own error text; `permission` also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load. -Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. If the selected runner rejects with attributable `ENOENT` or `EACCES`, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests. +Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` at `confine()` rather than degrading to unconfined execution. An attributable `ENOENT` or `EACCES` that names the selected runner proves that executable did not start, so the consumer reports the same infrastructure error; other synchronous creation errors propagate unchanged. An asynchronous subprocess-provider rejection exposes no public target stage and retains stage-neutral local semantics. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner hook for keyless tests. Denied file effects return a `[sandbox: file access denied under mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to ""`, and permits no re-ask. The owner-derived pending policy context states the current file policy without replacing those enforcement boundaries. When `dsh-permission-presets` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP application bundle](../../../../packages/bundle/acp-app/README.md) does not mount that UI service and selects its deployment mode explicitly. @@ -70,7 +70,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La #### The bash consumer -`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn '`; a present path also requires `syscall: 'spawn'` or the exact `spawn `. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution converts runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`. +`dsh-bash-sandbox` extends `LocalBashExecutor`, hands `ctx.sandbox` the exact `['bash', '-c', command]` argv, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn '`; a present path also requires `syscall: 'spawn'` or the exact `spawn `. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start throw `SANDBOX_UNAVAILABLE`, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`. The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries. @@ -184,7 +184,7 @@ Costs and accepted limits: ## FAQ - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. -- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background job stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. +- **How is a BROKEN sandbox told apart from a failing command?** An asynchronous subprocess-provider rejection exposes no public execution stage. It identifies a broken confinement runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]; a bare `syscall: 'spawn'` without an exact error path and all other rejections remain stage-neutral provider failures. After a direct outcome exists, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground runner failures throw structured `SANDBOX_UNAVAILABLE` with executable or matched-line detail; an attributable asynchronous background rejection or a matched settled failure stamps `sandbox.runnerFailed`, while every asynchronous rejection renders the local executor's provider-failure note. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - **What happens on a platform with no backend?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE`, and the command never spawns. - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** `SandboxMode` claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host `/proc/` magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged ([decision](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)). Whether network restriction becomes its own knob is left open in § The seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 9191b1b8d2..a5014d4125 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -38,7 +38,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 这一替换对 `ctx.shell` 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 argv 启动。删除 `sandbox` 和 `permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此组合不完整的 preset 层会在加载时明确报错。 -配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。 +配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。若可归因的 `ENOENT` 或 `EACCES` 指明所选 runner,就能证明该 executable 未启动,因此消费方会报告同一基础设施错误;其他同步创建错误原样传播。异步 subprocess-provider rejection 不公开 target 阶段,保留本地不声明阶段的语义。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。 被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。 @@ -70,7 +70,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness #### bash 消费方 -`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。 +`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。 模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)负责该上下文的理由与边界。 @@ -184,7 +184,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 ## FAQ - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 -- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 +- **如何区分损坏的沙箱与失败的命令?** 异步 subprocess-provider rejection 不公开执行阶段。只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 confinement runner 损坏;没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有 rejection 都保持不声明阶段的 provider failure。direct outcome 已存在后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台 runner failure 会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 executable 或匹配行详情;可归因的异步后台 rejection 或匹配到的已结算失败会盖章 `sandbox.runnerFailed`,而所有异步 rejection 都会渲染本地执行器的 provider-failure 提示。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 - **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 18130686dc..41ab9f7886 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 8128e4466c0845ee9749c8bff7d8e982a9780133 -2026-07-16-persistent-pty-sessions.zh.md: ad7ac3c4a51bd01fc20408197fdf857cac036e33 +2026-07-16-persistent-pty-sessions.md: 276aa11dea77d43ea58109ef3f165d4f1c79640e +2026-07-16-persistent-pty-sessions.zh.md: f4dc8c6c1b207879ef1b4583277ef2080c94405b diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 8128e4466c..276aa11dea 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -92,7 +92,7 @@ Background sends use the existing task completion notice and `job_output` result ### Process-tree teardown -On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Close sends `SIGTERM` to the direct PTY and the scope, waits for the manager to prove that range empty, and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome. +On supported Linux hosts, the subprocess terminal handle binds the top-level PTY process to its transient user-systemd scope. Before establishment, close sends `SIGTERM` through the direct PTY fallback so the bootstrap cannot continue; after establishment, it signals the scope alone and uses the direct fallback only if scope signalling fails. It waits for the manager to prove that range empty and escalates to `SIGKILL` after the configured grace. Scope membership continues to include descendants that call `setsid` or reparent, while the PTY's direct exit notification remains the terminal outcome. Fallback hosts retain observational process-session cleanup. The handle snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index ad7ac3c4a5..f4dc8c6c1b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -92,7 +92,7 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 ### 进程树 teardown -在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。close 会向 direct PTY 与 scope 发送 `SIGTERM`,等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。 +在受支持的 Linux 宿主上,subprocess 终端句柄会把顶层 PTY 进程绑定到临时 user-systemd scope。建立前,close 通过 direct PTY fallback 发送 `SIGTERM`,阻止 bootstrap 继续;建立后只向 scope 发送信号,并且仅在 scope signalling 失败时使用 direct fallback。随后它等待 manager 证明该 range 为空,并在配置的宽限期后升级到 `SIGKILL`。调用 `setsid` 或发生 reparent 的后代仍属于 scope,而 PTY 的 direct exit 通知继续作为终端结果。 fallback 宿主保留观察式进程 session 清理。句柄会按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 49cb7bb2c7..7e33d4202f 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: ec077edd10962f324db242698b1d652563c3ac2f -config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7 +config-catalog.md: 8669904b4470cd2b7db09922e475d3aecbbc1dfb +config-catalog.zh.md: dd19ac6375034a74f01312c7237e7c8cfa0452e3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ec077edd10..8669904b44 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -311,7 +311,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) -Source: [`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts) +Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d349575cb2..dd19ac6375 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -313,7 +313,7 @@ export type Config = LocalConfig 依赖:[`LocalConfig`](#deepseek-aidsh-bash-local) -来源:[`packages/shell/bash-sandbox/src/index.ts:35`](../packages/shell/bash-sandbox/src/index.ts) +来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts) diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index 1515b4ed7b..f98de59b10 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.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 docs/subsystems/shell.md -shell.md: 554adcfb1b37a5e2fe787a78dbb61a1206f80cf9 -shell.zh.md: f15b57cc0050bb8f38d2652ddb9ad0e250568663 +shell.md: ff83ae6d6e1b13e53e5f1112b2d713d3212a132e +shell.zh.md: 60beba0ef37129a5bd3f86922f4beeef68e94010 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index 554adcfb1b..ff83ae6d6e 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -166,7 +166,7 @@ The `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) i ## Background processes: `ShellProcess` -`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves. +`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.jobs.start()` hooks; the generic runtime then owns job identity and lifecycle. `done` resolves when the underlying process settles and never rejects; a subprocess provider rejection becomes a `killed` process with a stage-neutral error on stderr. Reads remain valid after settlement, and sandbox facts are stamped before `done` resolves. ```ts type-equiv /** @@ -182,7 +182,10 @@ interface ShellProcess { exitCode: number | null /** Terminating signal name, when signal-killed. */ signal: NodeJS.Signals | null - /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */ + /** + * Resolves when the underlying process settles (never rejects — provider + * rejection settles as `killed` with a stage-neutral error on stderr). + */ readonly done: Promise /** Sandbox facts, stamped once a confined process settles. */ sandbox?: ShellSandboxInfo diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index f15b57cc00..60beba0ef3 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -166,7 +166,7 @@ interface ShellSandboxInfo { ## 后台进程:`ShellProcess` -`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 在进程关闭时完成且绝不被拒绝;进程结束后仍可读取,并且沙箱事实会在 `done` 完成前写入。 +`start()` 返回不含 id 或所有者的句柄。`dsh-tool-bash` 将它适配为 `ctx.jobs.start()` 钩子;随后由通用运行时拥有任务标识与生命周期。`done` 会在底层进程结算时完成且绝不 reject;subprocess 提供方的 rejection 会生成状态为 `killed` 的进程,并把不声明阶段的错误写入 stderr。进程结算后仍可读取,并且沙箱事实会在 `done` 完成前写入。 ```ts type-equiv /** @@ -182,7 +182,10 @@ interface ShellProcess { exitCode: number | null /** Terminating signal name, when signal-killed. */ signal: NodeJS.Signals | null - /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */ + /** + * Resolves when the underlying process settles (never rejects — provider + * rejection settles as `killed` with a stage-neutral error on stderr). + */ readonly done: Promise /** Sandbox facts, stamped once a confined process settles. */ sandbox?: ShellSandboxInfo diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 60ea042d4f..4bd577e41a 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -201,9 +201,10 @@ export function resolveRgPath(): Promise { * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / * `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are * classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort - * racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a - * rejection of `handle.done` (the seam's infrastructure failures) both become - * `SEARCH_FAILED` with the original as `cause` — an abort already observed by + * racing the pre-check, a rejected `@vscode/ripgrep` resolution) reports that + * the command could not start, while a rejection of `handle.done` reports a + * provider failure without claiming whether execution began. Both become + * `SEARCH_FAILED` with the original as `cause`; an abort already observed by * creation time becomes `SEARCH_ABORTED` instead. * * @param ctx - the plugin context; execution uses its `subprocess` service. @@ -258,7 +259,7 @@ export async function runRipgrep( try { outcome = await handle.done } catch (error: unknown) { - throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) + throw new SearchError(`${toolName} subprocess failed before reporting an outcome (ripgrep provider failure)`, 'SEARCH_FAILED', { cause: error }) } const stdout = handle.collected.stdout?.readFrom(0) const stderr = handle.collected.stderr?.readFrom(0) diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 94be0e1088..77ed0c8c1a 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -189,12 +189,13 @@ describe('search tools over the real subprocess service + the packaged rg', () = expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) }) - it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { + it('an unusable session cwd provider rejection is SEARCH_FAILED', async () => { const gone = join(dir, 'deleted-session-dir') const result = await call('glob', { pattern: '*' }, { session: { header: { id: 'session-int', cwd: gone } } }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('could not start') + expect(text(result)).toContain('subprocess failed before reporting an outcome') + expect(text(result)).not.toContain('could not start') }) }) }) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index b0f4e02816..294db3db35 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -471,10 +471,9 @@ describe('workdir derivation and signal forwarding', () => { .toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) }) - it('translates a spawn rejection into SEARCH_FAILED even when the signal aborts concurrently', async () => { - // The seam rejects only for infrastructure failures (unusable workdir, - // missing binary); the abort happened after dispatch, so the launch - // failure is the reportable cause with the original error chained. + it('translates a provider rejection without claiming the search command never started', async () => { + // The public seam does not expose whether a done rejection happened before + // or after target execution; the original provider error remains chained. const { ctx, subprocess } = await setup() const controller = new AbortController() subprocess.handler = () => { @@ -486,7 +485,8 @@ describe('workdir derivation and signal forwarding', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) - expect(text(result)).toContain('could not start') + expect(text(result)).toContain('subprocess failed before reporting an outcome') + expect(text(result)).not.toContain('could not start') }) it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => { diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 81134a7ef2..5121d95ba5 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.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 packages/shell/bash-local/README.md -README.md: 1523430aa27c5b07d6dd1db614a697a1c1738cc2 -README.zh.md: dc5200e764623f8482b9e07f21bed300f0895c2e +README.md: 388374217ed14ab2c26c3f7d50ab03a5ca57b234 +README.zh.md: f0ef6b257943effaa265eaa81b682d2fd9ecd476 diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 1523430aa2..388374217e 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -137,7 +137,7 @@ These limits define when this executor is a poor fit. They are current package c - **Unconfined by itself** — commands run with the harness process's authority; deployments needing confinement compose `dsh-bash-sandbox`, while per-call allow/deny/ask policy belongs on the tools' `pre-execute` waterfall. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. - **POSIX-only** — the `bash` binary is hardcoded and the underlying service's group semantics are POSIX; Windows is unsupported. -- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **A background provider-failure note is single-delivery** — `SubprocessHandle.done` can reject before or after target execution begins, so the executor injects the stage-neutral `subprocess failed before reporting an outcome: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. ### Dev Note diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index dc5200e764..f0ef6b2579 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -137,7 +137,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) - **自身不提供隔离**——命令以 harness 进程的权限运行;需要隔离的部署组合 `dsh-bash-sandbox`,每次调用的 allow/deny/ask 策略则属于工具的 `pre-execute` waterfall。 - **没有持久 shell 或 PTY**——每次调用都启动全新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续延期,直到真实工作流需要它们。 - **仅支持 POSIX**——`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 -- **后台 spawn 失败提示只交付一次**——subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 +- **后台 provider failure 提示只交付一次**——`SubprocessHandle.done` 可能在 target 开始执行前或后 reject,因此执行器把不声明失败阶段的 `subprocess failed before reporting an outcome: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 ### 开发备注 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 064f124a00..7a7bbc4fdf 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -252,19 +252,19 @@ export class LocalBashExecutor extends ShellExecutor { * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. * @param argv - exact executable and arguments to hand to `ctx.subprocess`. - * @returns the live background handle; spawn rejection settles it as killed. + * @returns the live background handle; provider rejection settles it as killed. */ protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal)) const collected = LocalBashExecutor.collected(running) - // A spawn failure produces no process output, so the subprocess service has nothing - // to buffer; the note is delivered exactly once through the read path. - let spawnFailureNote: string | undefined - const consumeSpawnFailure = (): string => { - const note = spawnFailureNote ?? '' - spawnFailureNote = undefined + // A provider rejection has no direct outcome to display; its stage is not + // public, so a neutral note is delivered once through the read path. + let providerFailureNote: string | undefined + const consumeProviderFailure = (): string => { + const note = providerFailureNote ?? '' + providerFailureNote = undefined return note } @@ -283,10 +283,10 @@ export class LocalBashExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - // Background spawn failures settle as killed and surface through the read path. + // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote, true, error) + providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -294,9 +294,7 @@ export class LocalBashExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A failed spawn never produced process output, so the note and real - // stderr text are mutually exclusive. - const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + const errText = err.text.length > 0 ? err.text : consumeProviderFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -321,15 +319,15 @@ export class LocalBashExecutor extends ShellExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. - * Called after exit facts or spawn-failure output are stamped and before + * Called after exit facts or provider-failure output are stamped and before * {@link ShellProcess.done} resolves. The base implementation is intentionally * empty. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. - * @param _spawnFailed - whether the subprocess promise rejected before a process started. - * @param _spawnError - the original spawn rejection reason, which may itself be undefined. + * @param _providerRejected - whether the subprocess promise rejected without a direct outcome. + * @param _providerError - the provider rejection reason, which may itself be undefined. */ - protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} + protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {} } export default LocalBashExecutor diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c081ad7078..78e26ff774 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -1,10 +1,11 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { ShellProcess } from '@deepseek-ai/dsh-shell' @@ -288,13 +289,36 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.signal).toBe('SIGTERM') }) - it('a background spawn failure settles as killed with the error readable on stderr', async () => { + it('an asynchronous provider rejection does not claim that the command never started', async () => { + const { ctx, bash } = await setup() + const emptyReader: SubprocessOutputReader = { + readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), + } + vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: emptyReader, stderr: emptyReader }, + done: Promise.reject(new Error('provider lost the direct outcome')), + terminate: vi.fn(), + waitForExit: async () => true, + } satisfies SubprocessHandle) + + const proc = bash.start(bash.resolve({ command: 'true' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + const output = proc.readOutput().delta + expect(output).toContain('subprocess failed before reporting an outcome:') + expect(output).not.toContain('spawn failed:') + }) + + it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' })) // done resolves (never rejects) even though the process never ran. await expect(proc.done).resolves.toBeUndefined() expect(proc.status).toBe('killed') - expect(proc.readOutput().delta).toContain('spawn failed:') + expect(proc.readOutput().delta).toContain('subprocess failed before reporting an outcome:') }) }) diff --git a/packages/shell/bash-sandbox/README.i18n.yaml b/packages/shell/bash-sandbox/README.i18n.yaml index 05f7a32787..080811fcbb 100644 --- a/packages/shell/bash-sandbox/README.i18n.yaml +++ b/packages/shell/bash-sandbox/README.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 packages/shell/bash-sandbox/README.md -README.md: 8ec918ed3e361f6a2c6345d1cda25e5c0bd39560 -README.zh.md: 4c44a0ad6d09d16c18f75fe237f91116f7a62fcd +README.md: 3eef8b4b92f857a643e910090fe83333fdedac03 +README.zh.md: 6b2b14026dc9f159847223968993e4a0dcd95696 diff --git a/packages/shell/bash-sandbox/README.md b/packages/shell/bash-sandbox/README.md index 8ec918ed3e..3eef8b4b92 100644 --- a/packages/shell/bash-sandbox/README.md +++ b/packages/shell/bash-sandbox/README.md @@ -61,7 +61,7 @@ A denied command is reported, not retried silently: the result carries `sandbox: ### Failures and recovery -If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A runner-attributable spawn failure carries the original spawn error as detail; other spawn rejections keep the local executor's ordinary command-start semantics. +If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A provider rejection is attributed to the confinement runner only when its `ENOENT`/`EACCES` path or syscall independently names `argv[0]`; otherwise it keeps the local executor's stage-neutral provider-failure semantics. ----- @@ -151,7 +151,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -If no runner can enforce a confined mode, the foreground call propagates the `SANDBOX_UNAVAILABLE` error from the sandbox seam. A runner-attributable spawn failure supplies the original spawn error as detail; a rejection without `ENOENT`/`EACCES` path or syscall evidence that names `argv[0]` remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection; the appended `Runner failure: ` is the authoritative diagnosis over the generic `SANDBOX_UNAVAILABLE` prefix. +If no runner can enforce a confined mode, the foreground call propagates the `SANDBOX_UNAVAILABLE` error from the sandbox seam. A provider rejection with `ENOENT`/`EACCES` path or syscall evidence that names `argv[0]` supplies the original error as runner-failure detail; another rejection remains a stage-neutral provider error. A settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection; the appended `Runner failure: ` is the authoritative diagnosis over the generic `SANDBOX_UNAVAILABLE` prefix. #### Token effect diff --git a/packages/shell/bash-sandbox/README.zh.md b/packages/shell/bash-sandbox/README.zh.md index 4c44a0ad6d..6b2b14026d 100644 --- a/packages/shell/bash-sandbox/README.zh.md +++ b/packages/shell/bash-sandbox/README.zh.md @@ -61,7 +61,7 @@ kind: "package-reference" ### 失败与恢复 -如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;其他 spawn 拒绝保持本地执行器普通的命令启动语义。 +如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。只有当 provider rejection 的 `ENOENT`/`EACCES` 路径或 syscall 独立指向 `argv[0]` 时,才把它归因于 confinement runner;其他 rejection 保持本地执行器不声明阶段的 provider-failure 语义。 ----- @@ -151,7 +151,7 @@ kind: "package-reference" #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播来自 sandbox seam 的 `SANDBOX_UNAVAILABLE` 错误。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;没有 `ENOENT`/`EACCES` 的 `path` 或 `syscall` 证据指明 `argv[0]` 的拒绝仍是普通的命令启动错误。已结算的 runner 失败以匹配到的致命 stderr 行作为详情,并保留原始 stderr 收集结果;追加的 `Runner failure: ` 是权威诊断,优先于通用的 `SANDBOX_UNAVAILABLE` 前缀。 +如果没有 runner 能强制执行受限模式,前台调用会传播来自 sandbox seam 的 `SANDBOX_UNAVAILABLE` 错误。带有 `ENOENT`/`EACCES` 路径或 syscall 证据并指向 `argv[0]` 的 provider rejection 会把原始错误作为 runner-failure 详情;其他 rejection 保持不声明阶段的 provider error。已结算的 runner 失败以匹配到的致命 stderr 行作为详情,并保留原始 stderr 收集结果;追加的 `Runner failure: ` 是权威诊断,优先于通用的 `SANDBOX_UNAVAILABLE` 前缀。 #### Token 影响 diff --git a/packages/shell/bash-sandbox/src/index.ts b/packages/shell/bash-sandbox/src/index.ts index be9c6647ec..de3d4368bc 100644 --- a/packages/shell/bash-sandbox/src/index.ts +++ b/packages/shell/bash-sandbox/src/index.ts @@ -1,10 +1,11 @@ /** * Sandbox-consuming bash executor. It wraps the exact local bash argv through * `ctx.sandbox`, inherits local process mechanics, and reports the selected - * mode, enforcement, and denial facts. Positive runner-launch evidence means - * the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while - * background processes carry `runnerFailed`; other spawn rejections retain - * local-executor semantics. The tool owns approval and passes a complete per-call policy. + * mode, enforcement, and denial facts. Positive runner-executable evidence + * identifies a broken confinement runner: foreground calls throw + * `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`; + * other provider rejections retain stage-neutral local-executor semantics. The + * tool owns approval and passes a complete per-call policy. * @module @deepseek-ai/dsh-bash-sandbox */ @@ -147,14 +148,15 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Stamp per-process sandbox facts before `done` settles. Full-access processes * have no facts; signal deaths are not denials. */ - protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A rejected spawn never started the confined launch. Otherwise runner - // failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = spawnFailed - ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + // A provider rejection exposes no public failure stage. Attribute it to + // the confinement runner only when the error independently names argv[0]. + // Otherwise settled runner failure outranks denial-like diagnostics. + const runnerFailed = providerRejected + ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, @@ -163,7 +165,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr, spawnFailed, spawnError) + super.onProcessDone(proc, stderr, providerRejected, providerError) } /** diff --git a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts index d665bb4eb5..d2355c7dad 100644 --- a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts @@ -100,7 +100,7 @@ describe('partial Landlock runner-failure classification', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done expect(task.status).toBe('killed') - expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner}`) + expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner}`) expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, @@ -134,7 +134,7 @@ describe('partial Landlock runner-failure classification', () => { const task = bash.start(bash.resolve(request)) await task.done expect(task.status).toBe('killed') - expect(task.readOutput().delta).toContain(`spawn failed: Error: spawn ${runner} ENOENT`) + expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner} ENOENT`) expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, @@ -188,7 +188,7 @@ describe('partial Landlock runner-failure classification', () => { const output = background.readOutput().delta expect(output.startsWith('[stderr]\n')).toBe(true) expect(output.length).toBeGreaterThan('[stderr]\n'.length) - expect(output).not.toContain('spawn failed:') + expect(output).not.toContain('subprocess failed before reporting an outcome:') } const accounting = (bash as unknown as { processFacts: Map }).processFacts diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index ce1cd844c6..07e2431a25 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -547,7 +547,7 @@ describe('background sandbox facts', () => { await task.done expect(task.status).toBe('killed') - expect(task.readOutput().delta).toContain('spawn failed:') + expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome:') expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, @@ -560,7 +560,7 @@ describe('background sandbox facts', () => { } }) - it('does not invent runner evidence when a spawn rejection has no structured reason', async () => { + it('does not invent runner evidence when a provider rejection has no structured reason', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), @@ -570,7 +570,7 @@ describe('background sandbox facts', () => { stdout: undefined, stderr: undefined, collected: { stdout: emptyReader, stderr: emptyReader }, - // Arbitrary subprocess providers can reject without a value; that edge is the point of this test. + // Arbitrary subprocess providers can reject without a value or public stage. // oxlint-disable-next-line typescript/prefer-promise-reject-errors done: Promise.reject(undefined), terminate: vi.fn(), @@ -580,7 +580,7 @@ describe('background sandbox facts', () => { const task = bash.start(bash.resolve({ command: 'true' })) await task.done - expect(task.readOutput().delta).toContain('spawn failed: undefined') + expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome: undefined') expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index 2da1533c41..e44f18ec5a 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.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 packages/shell/pwsh-local/README.md -README.md: 0f586495e9ab9c1bb533d2dce26f2777ef859bc8 -README.zh.md: e704ee6d2f55fd72143b1c50da4b7b0162ff830e +README.md: 275a5a0bcda3ddc663b989f778f6cd69d19470b9 +README.zh.md: 93f81526e8b0911f331254324eafce19cdd78c42 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 0f586495e9..275a5a0bcd 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -143,7 +143,7 @@ These limits define when this executor is a poor fit. They are current package c - **Unconfined by itself** — commands run with the harness process's authority; deployments needing confinement compose a sandboxing executor or policy instead. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`. - **The command string is PowerShell text** — the `-Command` domain has no shell-quoting layer, but a model-facing command is parsed by PowerShell itself, so PowerShell syntax errors are command failures, not launch failures. -- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **A background provider-failure note is single-delivery** — `SubprocessHandle.done` can reject before or after target execution begins, so the executor injects the stage-neutral `subprocess failed before reporting an outcome: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. - **Windows termination reports no signal** — a force-killed process settles as exit 1 with `signal: null`, so signal-based status classification does not apply on Windows; `kill()`-initiated stops still stamp `killed` directly. - **The encoding preamble precedes the command** — PowerShell requires `param(...)`, `#requires`, and `using` statements at the very top of a script, so a command whose first statement is one of those cannot run under the UTF-8 output preamble; wrap a `param(...)` script in `& { … }`, and run `using`/`#requires` scripts from a file instead. - **Non-ASCII stdin under Windows PowerShell 5.1 may be mis-decoded** — the preamble pins output encoding only; `[Console]::InputEncoding` stays at the host default because setting it under redirected stdin throws; pwsh 7 defaults to UTF-8 and is unaffected. diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index e704ee6d2f..93f81526e8 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -143,7 +143,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) - **自身不提供隔离**——命令以 harness 进程的权限运行;需要隔离的部署组合沙箱执行器或策略。 - **没有持久 shell 或 PTY**——每次调用都启动全新的 `pwsh -Command`。 - **命令字符串是 PowerShell 文本**——`-Command` 域没有 shell 引号层,但面向模型的命令由 PowerShell 自己解析,因此 PowerShell 语法错误是命令失败,而非启动失败。 -- **后台 spawn 失败提示只交付一次**——subprocess 服务不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 +- **后台 provider failure 提示只交付一次**——`SubprocessHandle.done` 可能在 target 开始执行前或后 reject,因此执行器把不声明失败阶段的 `subprocess failed before reporting an outcome: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 - **Windows 终止不报告信号**——被强制终止的进程以退出码 1、`signal: null` 结算,因此基于信号的状态分类在 Windows 上不适用;`kill()` 发起的停止仍会直接标记为 `killed`。 - **编码 preamble 位于命令之前**——PowerShell 要求 `param(...)`、`#requires` 与 `using` 语句位于脚本最顶部,因此以其中一种开头的命令无法在 UTF-8 输出 preamble 下运行;`param(...)` 脚本请包进 `& { … }`,`using`/`#requires` 脚本请改从文件运行。 - **Windows PowerShell 5.1 下的非 ASCII stdin 可能被错误解码**——preamble 只固定输出编码;`[Console]::InputEncoding` 保持主机默认,因为在重定向 stdin 下设置它会抛出异常;pwsh 7 默认 UTF-8,不受影响。 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 93133b37af..a31616ad41 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -288,12 +288,12 @@ export class PwshLocalExecutor extends ShellExecutor { const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) - // A spawn failure produces no process output, so the subprocess service has nothing - // to buffer; the note is delivered exactly once through the read path. - let spawnFailureNote: string | undefined - const consumeSpawnFailure = (): string => { - const note = spawnFailureNote ?? '' - spawnFailureNote = undefined + // A provider rejection has no direct outcome to display; its stage is not + // public, so a neutral note is delivered once through the read path. + let providerFailureNote: string | undefined + const consumeProviderFailure = (): string => { + const note = providerFailureNote ?? '' + providerFailureNote = undefined return note } @@ -312,10 +312,10 @@ export class PwshLocalExecutor extends ShellExecutor { proc.signal = outcome.signal this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { - // Background spawn failures settle as killed and surface through the read path. + // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote, true, error) + providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -323,9 +323,7 @@ export class PwshLocalExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - // A failed spawn never produced process output, so the note and real - // stderr text are mutually exclusive. - const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() + const errText = err.text.length > 0 ? err.text : consumeProviderFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' @@ -355,10 +353,10 @@ export class PwshLocalExecutor extends ShellExecutor { * pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. - * @param _spawnFailed - whether the spawn rejected before any process existed. - * @param _spawnError - the spawn rejection, when `_spawnFailed`. + * @param _providerRejected - whether the subprocess promise rejected without a direct outcome. + * @param _providerError - the provider rejection reason, which may itself be undefined. */ - protected onProcessDone(_proc: ShellProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} + protected onProcessDone(_proc: ShellProcess, _stderr: string, _providerRejected: boolean, _providerError?: unknown): void {} } /* jscpd:ignore-end */ diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 2adf6dddca..968d41602c 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -18,7 +18,7 @@ import { Context } from '@deepseek-ai/cordis' import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SubprocessRuntime from '@deepseek-ai/dsh-subprocess' -import type { SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { ShellProcess } from '@deepseek-ai/dsh-shell' @@ -155,6 +155,7 @@ describe('spawn construction (pure, every platform)', () => { /** A subprocess service that records spawn specs and settles instantly. */ class CapturingSubprocessRuntime extends SubprocessRuntime { specs: SubprocessSpawnSpec[] = [] + done: Promise = Promise.resolve({ exitCode: 0, signal: null }) override async resolveExecutable(command: string): Promise { return command } override spawnTerminal(): Promise { throw new Error('pwsh spawns pipes, never terminals') } private readonly reader: SubprocessOutputReader = { @@ -167,7 +168,7 @@ describe('spawn construction (pure, every platform)', () => { stdout: undefined, stderr: undefined, collected: { stdout: this.reader, stderr: this.reader }, - done: Promise.resolve({ exitCode: 0, signal: null }), + done: this.done, terminate: () => {}, waitForExit: async () => true, } @@ -186,6 +187,20 @@ describe('spawn construction (pure, every platform)', () => { expect(ENCODING_PREAMBLE).toContain('[Console]::OutputEncoding') expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') }) + + it('reports asynchronous provider rejection without claiming that pwsh never started', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessRuntime(ctx) + await ctx.plugin(PwshLocalExecutor) + subprocess.done = Promise.reject(new Error('provider lost the direct outcome')) + + const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + const output = proc.readOutput().delta + expect(output).toContain('subprocess failed before reporting an outcome:') + expect(output).not.toContain('spawn failed:') + }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { @@ -435,13 +450,13 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' expect(['SIGTERM', 'SIGKILL']).toContain(proc.signal) }) - it('a background spawn failure settles as killed with the error readable on stderr', async () => { + it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'Write-Output ok', workdir: '/nonexistent-dsh' })) // done resolves (never rejects) even though the process never ran. await expect(proc.done).resolves.toBeUndefined() expect(proc.status).toBe('killed') - expect(proc.readOutput().delta).toContain('spawn failed:') + expect(proc.readOutput().delta).toContain('subprocess failed before reporting an outcome:') }) }) diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index abf9e9e709..aa45509b9e 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/README.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 packages/shell/pwsh-sandbox/README.md -README.md: 49cadd35a9f4311cfa4188df6e555e180e3486aa -README.zh.md: 20595f7f10c38a9bff77666dab014314895b97f7 +README.md: d2aa0170e95e89fd4a53499dec8f641113f96cf8 +README.zh.md: f691b6c589e663863a60d2a39bcc09bf3968389c diff --git a/packages/shell/pwsh-sandbox/README.md b/packages/shell/pwsh-sandbox/README.md index 49cadd35a9..d2aa0170e9 100644 --- a/packages/shell/pwsh-sandbox/README.md +++ b/packages/shell/pwsh-sandbox/README.md @@ -61,7 +61,7 @@ A denied command is reported as a fact: the result carries `sandbox: { mode, den ### Failures and recovery -If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A runner-attributable spawn failure carries the original spawn error as detail; other spawn rejections keep the local executor's ordinary command-start semantics. +If no runner can enforce a confined mode, the foreground call fails with `SANDBOX_UNAVAILABLE` and a background process records a runner-failure fact — never a silent unconfined run. A provider rejection is attributed to the confinement runner only when its `ENOENT`/`EACCES` path or syscall independently names `argv[0]`; otherwise it keeps the local executor's stage-neutral provider-failure semantics. ----- diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 20595f7f10..f691b6c589 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -61,7 +61,7 @@ kind: "package-reference" ### 失败与恢复 -如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。可归因于 runner 的 spawn 失败以原始 spawn 错误作为详情;其他 spawn 拒绝保持本地执行器普通的命令启动语义。 +如果没有 runner 能强制执行受限模式,前台调用以 `SANDBOX_UNAVAILABLE` 失败,后台进程则记录 runner 失败事实——绝不会静默无隔离运行。只有当 provider rejection 的 `ENOENT`/`EACCES` 路径或 syscall 独立指向 `argv[0]` 时,才把它归因于 confinement runner;其他 rejection 保持本地执行器不声明阶段的 provider-failure 语义。 ----- diff --git a/packages/shell/pwsh-sandbox/src/index.ts b/packages/shell/pwsh-sandbox/src/index.ts index 66bc73bcf0..2e9a03557a 100644 --- a/packages/shell/pwsh-sandbox/src/index.ts +++ b/packages/shell/pwsh-sandbox/src/index.ts @@ -3,12 +3,12 @@ * `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through * `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner * chain), inherits local process mechanics, and reports the selected mode, - * enforcement, and denial facts. Positive runner-launch evidence means the - * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while - * background processes carry `runnerFailed`; other spawn rejections retain - * local-executor semantics. The tool layer owns the escalation approval flow - * through `ctx.approval`; this executor reports the sandbox facts the tool - * renders. + * enforcement, and denial facts. Positive runner-executable evidence + * identifies a broken confinement runner: foreground calls throw + * `SANDBOX_UNAVAILABLE`, while background processes carry `runnerFailed`; + * other provider rejections retain stage-neutral local-executor semantics. The + * tool layer owns the escalation approval flow through `ctx.approval`; this + * executor reports the sandbox facts the tool renders. * @module @deepseek-ai/dsh-pwsh-sandbox */ @@ -153,14 +153,15 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { * Stamp per-process sandbox facts before `done` settles. Full-access * processes have no facts; signal deaths are not denials. */ - protected override onProcessDone(proc: ShellProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + protected override onProcessDone(proc: ShellProcess, stderr: string, providerRejected: boolean, providerError?: unknown): void { const facts = this.processFacts.get(proc) if (facts !== undefined) { this.processFacts.delete(proc) - // A rejected spawn never started the confined launch. Otherwise runner - // failure outranks denial because its diagnostics may contain denial terms. - const runnerFailed = spawnFailed - ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + // A provider rejection exposes no public failure stage. Attribute it to + // the confinement runner only when the error independently names argv[0]. + // Otherwise settled runner failure outranks denial-like diagnostics. + const runnerFailed = providerRejected + ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir) : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined proc.sandbox = { mode: facts.mode, @@ -169,7 +170,7 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { ...(runnerFailed ? { runnerFailed } : {}), } } - super.onProcessDone(proc, stderr, spawnFailed, spawnError) + super.onProcessDone(proc, stderr, providerRejected, providerError) } /** diff --git a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts index 258e3d950f..166e5ed042 100644 --- a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts @@ -300,7 +300,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) }, 30_000) - it('background spawn rejections settle as runnerFailed facts', async () => { + it('background provider rejections with runner provenance settle as runnerFailed facts', async () => { const { executor } = await setup(() => ({ argv: ['definitely-not-a-real-runner', '--', 'pwsh'], enforcement: 'full', @@ -312,7 +312,7 @@ describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true }) // The failure note surfaces through the read path. const read = proc.readOutput() - expect(read.delta).toContain('spawn failed') + expect(read.delta).toContain('subprocess failed before reporting an outcome') }, 30_000) it('danger-full-access background runs bypass confine and carry no facts', async () => { diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 4095071e42..33a3e06db1 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.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 packages/shell/shell/README.md -README.md: 1400947fc7cda0d45a050e45008356aebbd33776 -README.zh.md: bde496672866bb182f883d0fe0d8d793624cfa49 +README.md: 0c1cb0872b32be55f5d5b617091b8acc39d64b2b +README.zh.md: 3e2fe2a7786a0195cd13c59f8118e2a63147ed40 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index 1400947fc7..0c1cb0872b 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -91,7 +91,7 @@ The package is one role of a standard capability seam: the Service Definition th ### Background lifecycle and ownership -A background process belongs to the subprocess service, not to the executor: it survives an executor-only reload and is killed and joined when the composition tears down. Implementations must honor the seam's semantics — `run` rejects only for infrastructure failures; `start` returns immediately with no timeout and its `done` never rejects (spawn failures settle as `killed` with the error on stderr); `readOutput` is consuming and lossy reads report spill files. +A background process belongs to the subprocess service, not to the executor: it survives an executor-only reload and is killed and joined when the composition tears down. Implementations must honor the seam's semantics — `run` rejects only for infrastructure failures; `start` returns immediately with no timeout and its `done` never rejects (a subprocess provider rejection settles as `killed` with a stage-neutral error on stderr); `readOutput` is consuming and lossy reads report spill files. diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index bde4966728..3e2fe2a778 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -91,7 +91,7 @@ seam 本身不是执行器:每个组合只挂载一个提供方,工具即可 ### 后台生命周期与归属 -后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject;`start` 立即返回且不设超时,其 `done` 绝不 reject(spawn 失败以 `killed` 结算,错误进入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。 +后台进程属于 subprocess 服务而非执行器:它能在仅重载执行器后存活,并在组合拆解时被终止并 join。实现必须遵守 seam 的语义——`run` 只在基础设施失败时 reject;`start` 立即返回且不设超时,其 `done` 绝不 reject(subprocess provider rejection 以 `killed` 结算,并把不声明阶段的错误写入 stderr);`readOutput` 是消费式的,有损读取会报告 spill 文件。 diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts index 21fea2d791..20ecd2ffa3 100644 --- a/packages/shell/shell/src/types.ts +++ b/packages/shell/shell/src/types.ts @@ -165,7 +165,10 @@ export interface ShellProcess { exitCode: number | null /** Terminating signal name, when signal-killed. */ signal: NodeJS.Signals | null - /** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */ + /** + * Resolves when the underlying process settles (never rejects — provider + * rejection settles as `killed` with a stage-neutral error on stderr). + */ readonly done: Promise /** Sandbox facts, stamped once a confined process settles. */ sandbox?: ShellSandboxInfo diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 588493cc45..2b370f9f69 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 4fcc0812819f2960d9cacd3ef07b1e47da4126e1 -README.zh.md: c5b36637dca64587981aa6a349c9bf85cfb6acba +README.md: 89817f20bf1eebc0692af45a164be0e8cb468168 +README.zh.md: 7df61f1894a22c5d00ac1c8528cec46033ce7c66 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4fcc081281..89817f20bf 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -54,7 +54,7 @@ Normal disposal terminates every running managed range and terminal session and ### What can go wrong -An executable that cannot be resolved fails loud with a stable error; a spawn that never starts rejects `done`. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. +An executable that cannot be resolved fails loud with a stable error. `done` rejects when spawn or provider failure prevents a direct outcome, and that rejection does not prove whether target execution began. `waitForExit()` rejects if the selected owner can no longer prove its range empty, and cleanup still attempts termination. A read past the retained tail is `lossy` and points at the spill file when one exists. A fallback process group or observed terminal session can miss a descendant that escapes before observation — see the limitations below. ----- @@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory and polls unresolved scope state; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. +- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index c5b36637dc..7df61f1894 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 可能出错的地方 -无法解析的可执行文件会以稳定的错误快速失败;从未启动成功的 spawn 会让 `done` reject。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 +无法解析的可执行文件会以稳定错误快速失败。当 spawn 或 provider failure 使 direct outcome 无法产生时,`done` 会 reject;该 rejection 不能证明 target 是否已经开始执行。若所选 owner 无法再证明其范围为空,`waitForExit()` 会 reject,清理仍会尝试终止。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。fallback 进程组或已观察终端 session 可能遗漏在观察前逃逸的后代——见下文限制。 ----- @@ -130,7 +130,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。 - **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,并在 scope 状态尚未确定时轮询;Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 +- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 - **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。 - **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 3dfae47c0b..733267384b 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -38,6 +38,7 @@ export interface LinuxScopeInternals { resolveRunnerInvocation?: () => RunnerInvocation runnerAvailable?: (invocation: RunnerInvocation) => boolean loadLinuxExecve?: typeof loadLinuxExecve + sleep?: (delayMs: number) => Promise } interface SystemctlResult { @@ -48,7 +49,7 @@ interface SystemctlResult { } const SYSTEMCTL_TIMEOUT_MS = 5_000 -const SCOPE_POLL_INTERVAL_MS = 50 +const SCOPE_INITIAL_POLL_INTERVAL_MS = 50 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu function systemctlEnv(): NodeJS.ProcessEnv { @@ -146,6 +147,7 @@ class SystemdScopeOwner implements BoundProcessOwner { private readonly systemctl: string, private readonly runSync: typeof spawnSync, private readonly query: (command: string, args: readonly string[]) => Promise, + private readonly sleep: (delayMs: number) => Promise, ) {} signal(signal: 'SIGTERM' | 'SIGKILL'): void { @@ -228,7 +230,15 @@ class SystemdScopeOwner implements BoundProcessOwner { async waitForExit(): Promise { if (this.stopped) return this.observation ??= (async () => { - while (await this.rangeActive()) await sleepMs(SCOPE_POLL_INTERVAL_MS) + let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS + while (await this.rangeActive()) { + await this.sleep(pollIntervalMs) + // Keep establishment responsive, then reduce systemctl process churn + // while systemd remains the authoritative owner of an active range. + if (this.established) { + pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS) + } + } this.stopped = true })().catch((error: unknown) => { this.observation = undefined @@ -337,6 +347,7 @@ export function prepareLinuxTerminalScope( internals.systemctl ?? 'systemctl', internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, + internals.sleep ?? sleepMs, ), resolveOutcome: (outcome) => { const startup = readLinuxStartupError(files.startupErrorPath) @@ -391,6 +402,7 @@ export function launchLinuxScope( internals.systemctl ?? 'systemctl', internals.spawnSync ?? spawnSync, internals.systemctlQuery ?? querySystemctl, + internals.sleep ?? sleepMs, ) return { stdin: child.stdin, diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 2989ab06a0..ff8e69f560 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -105,6 +105,7 @@ function launch( runnerInvocation: overrides.runnerInvocation ?? ['/usr/bin/node', '/runner.js'], ...overrides.runnerAvailable === undefined ? {} : { runnerAvailable: overrides.runnerAvailable }, ...overrides.loadLinuxExecve === undefined ? {} : { loadLinuxExecve: overrides.loadLinuxExecve }, + ...overrides.sleep === undefined ? {} : { sleep: overrides.sleep }, }) const requestPath = options?.env?.[SUBPROCESS_RUNNER_ENV] if (requestPath === undefined) throw new Error('launch did not publish a request locator') @@ -232,6 +233,30 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) + it('polls promptly before establishment and backs off established active scopes', async () => { + const delays: number[] = [] + const states = [ + missingUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit(), + activeUnit('inactive'), + ] + const launched = launch( + async () => states.shift() ?? activeUnit('inactive'), + { sleep: async (delayMs) => { delays.push(delayMs) } }, + ) + + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(delays).toEqual([50, 50, 100, 200, 400, 800, 1_600, 3_200, 5_000]) + launched.result.owner.cleanup?.() + }) + it('reports child termination before request consumption to both result and wait', async () => { const { child, result } = launch(async () => missingUnit()) child.exit(127, null) From 2660635e6302e1b0fdef1e827a984d28c049ceaf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 20:05:37 +0800 Subject: [PATCH 096/110] fix(shell): preserve stderr on provider failure --- packages/shell/bash-local/src/index.ts | 5 ++++- .../shell/bash-local/tests/executor.spec.ts | 14 ++++++++++++-- packages/shell/pwsh-local/src/index.ts | 5 ++++- .../shell/pwsh-local/tests/executor.spec.ts | 17 ++++++++++++++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 7a7bbc4fdf..16d222916e 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -294,7 +294,10 @@ export class LocalBashExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - const errText = err.text.length > 0 ? err.text : consumeProviderFailure() + const providerFailure = consumeProviderFailure() + const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : '' + const errText = err.text + + (providerFailure.length > 0 ? `${failureSeparator}${providerFailure}` : '') // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 78e26ff774..b595508087 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -289,16 +289,24 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.signal).toBe('SIGTERM') }) - it('an asynchronous provider rejection does not claim that the command never started', async () => { + it('reports both unread stderr and an asynchronous provider rejection exactly once', async () => { const { ctx, bash } = await setup() const emptyReader: SubprocessOutputReader = { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), } + const stderrText = 'target stderr' + const stderrReader: SubprocessOutputReader = { + readFrom: offset => ({ + text: stderrText.slice(offset), + nextOffset: stderrText.length, + lossy: false, + }), + } vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ stdin: undefined, stdout: undefined, stderr: undefined, - collected: { stdout: emptyReader, stderr: emptyReader }, + collected: { stdout: emptyReader, stderr: stderrReader }, done: Promise.reject(new Error('provider lost the direct outcome')), terminate: vi.fn(), waitForExit: async () => true, @@ -308,8 +316,10 @@ describe('LocalBashExecutor.start (background process handles)', () => { await expect(proc.done).resolves.toBeUndefined() expect(proc.status).toBe('killed') const output = proc.readOutput().delta + expect(output).toContain('target stderr') expect(output).toContain('subprocess failed before reporting an outcome:') expect(output).not.toContain('spawn failed:') + expect(proc.readOutput().delta).toBe('') }) it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => { diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index a31616ad41..3a69fe97b0 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -323,7 +323,10 @@ export class PwshLocalExecutor extends ShellExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset - const errText = err.text.length > 0 ? err.text : consumeProviderFailure() + const providerFailure = consumeProviderFailure() + const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : '' + const errText = err.text + + (providerFailure.length > 0 ? `${failureSeparator}${providerFailure}` : '') // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 968d41602c..e2ba0d193d 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -156,18 +156,26 @@ describe('spawn construction (pure, every platform)', () => { class CapturingSubprocessRuntime extends SubprocessRuntime { specs: SubprocessSpawnSpec[] = [] done: Promise = Promise.resolve({ exitCode: 0, signal: null }) + stderrText = '' override async resolveExecutable(command: string): Promise { return command } override spawnTerminal(): Promise { throw new Error('pwsh spawns pipes, never terminals') } - private readonly reader: SubprocessOutputReader = { + private readonly stdoutReader: SubprocessOutputReader = { readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }), } + private readonly stderrReader: SubprocessOutputReader = { + readFrom: offset => ({ + text: this.stderrText.slice(offset), + lossy: false, + nextOffset: this.stderrText.length, + }), + } override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { this.specs.push(spec) return { stdin: undefined, stdout: undefined, stderr: undefined, - collected: { stdout: this.reader, stderr: this.reader }, + collected: { stdout: this.stdoutReader, stderr: this.stderrReader }, done: this.done, terminate: () => {}, waitForExit: async () => true, @@ -188,18 +196,21 @@ describe('spawn construction (pure, every platform)', () => { expect(ENCODING_PREAMBLE).toContain('$OutputEncoding') }) - it('reports asynchronous provider rejection without claiming that pwsh never started', async () => { + it('reports both unread stderr and an asynchronous provider rejection exactly once', async () => { const ctx = new Context() const subprocess = new CapturingSubprocessRuntime(ctx) await ctx.plugin(PwshLocalExecutor) + subprocess.stderrText = 'target stderr' subprocess.done = Promise.reject(new Error('provider lost the direct outcome')) const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' })) await expect(proc.done).resolves.toBeUndefined() expect(proc.status).toBe('killed') const output = proc.readOutput().delta + expect(output).toContain('target stderr') expect(output).toContain('subprocess failed before reporting an outcome:') expect(output).not.toContain('spawn failed:') + expect(proc.readOutput().delta).toBe('') }) }) From ac1a5891c2faa100ba533cc1f7b88338ae19d583 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 2 Sep 2026 22:49:46 +0800 Subject: [PATCH 097/110] fix(subprocess): close remaining native containment findings --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 18 +-- ...-08-28-subprocess-native-containment.zh.md | 18 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/shell.i18n.yaml | 4 +- docs/subsystems/shell.md | 6 +- docs/subsystems/shell.zh.md | 6 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 3 +- docs/subsystems/subprocess.zh.md | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 3 +- packages/lsp/lsp-stdio/README.i18n.yaml | 4 +- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/src/connection.ts | 17 ++- packages/lsp/lsp-stdio/src/instance.ts | 11 +- packages/lsp/lsp-stdio/tests/instance.spec.ts | 2 +- packages/shell/bash-local/README.i18n.yaml | 4 +- packages/shell/bash-local/README.md | 6 +- packages/shell/bash-local/README.zh.md | 6 +- packages/shell/bash-local/src/index.ts | 10 +- .../shell/bash-local/tests/executor.spec.ts | 2 +- packages/shell/pwsh-local/README.i18n.yaml | 4 +- packages/shell/pwsh-local/README.md | 4 +- packages/shell/pwsh-local/README.zh.md | 4 +- packages/shell/pwsh-local/src/index.ts | 2 +- .../shell/pwsh-local/tests/executor.spec.ts | 2 +- packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 2 +- packages/shell/shell/README.zh.md | 2 +- packages/shell/shell/src/types.ts | 2 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 4 +- packages/subagent/subagent-acp/README.zh.md | 4 +- packages/subagent/subagent-acp/src/run.ts | 20 +-- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 4 +- .../subagent-claude-code/README.zh.md | 4 +- .../subagent-claude-code/src/index.ts | 2 +- .../subagent-claude-code/src/invariant.ts | 2 +- .../subagent-claude-code/src/process.ts | 8 +- .../subagent/subagent-claude-code/src/run.ts | 10 +- .../tests/subagent-claude-code.spec.ts | 6 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 2 +- packages/subagent/subagent-codex/README.zh.md | 2 +- packages/subagent/subagent-codex/src/index.ts | 2 +- .../subagent/subagent-codex/src/invariant.ts | 2 +- packages/subagent/subagent-codex/src/run.ts | 12 +- .../tests/subagent-codex.spec.ts | 6 +- .../subagent/subagent/src/out-of-process.ts | 2 +- .../subprocess/subprocess-local/src/index.ts | 26 ++-- .../subprocess-local/src/linux-scope.ts | 128 +++++++++++++++--- .../subprocess-local/src/runner-protocol.ts | 29 +--- .../subprocess-local/src/spawn-runner.ts | 51 +++---- .../subprocess/subprocess-local/src/spawn.ts | 4 +- .../subprocess-local/src/windows-job.ts | 44 +++--- .../tests/linux-scope.spec.ts | 113 ++++++++++++++-- .../subprocess-local/tests/local.spec.ts | 36 ++++- .../tests/native-containment.spec.ts | 6 +- .../tests/native-windows.spec.ts | 4 +- .../tests/spawn-runner-built.e2e.ts | 8 +- .../tests/spawn-runner.spec.ts | 71 +++++----- .../subprocess-local/tests/spawn.spec.ts | 29 ++-- .../tests/windows-job.spec.ts | 55 ++++++-- packages/subprocess/subprocess/src/index.ts | 3 +- packages/subprocess/subprocess/src/types.ts | 2 +- .../subprocess/win32-process/src/process.ts | 10 +- .../tests/ordinary-process.spec.ts | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 8 +- 72 files changed, 576 insertions(+), 332 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 1b1168725a..35dd6ab4f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 378a109b7d9b16593eda4ae06ab45cb27a6319c0 -2026-08-28-subprocess-native-containment.zh.md: d0bdf71eb8602896adab853bd7d3c2a969caad21 +2026-08-28-subprocess-native-containment.md: 84ddc8dd533123a90555b813f7a2b5d95b3a00a1 +2026-08-28-subprocess-native-containment.zh.md: fd7bbd362b7d4ef1cefb0c379728dcfda8366a67 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 378a109b7d..84ddc8dd53 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -18,17 +18,17 @@ An ordinary `SubprocessHandle` has no PID or public startup state. `.done` repor ### Linux scope and one-shot bootstrap -Every eligible Linux ordinary or PTY spawn rechecks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. A positive result is not cached. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback. +The first eligible Linux ordinary or PTY call in one runtime deeply checks the exact runner entry, the libc `execve` and `fcntl` bindings, the readable user manager, and literal-argv transient-scope support. Failed deep probes are retried, while the first success is cached. Each later eligible call still performs a lightweight user-manager reachability probe before target execution. Once selected, a scope, protocol, state-query, or pre-exec failure is reported through that launch and never switches to fallback. 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 the unit establishes scope ownership. Unit absence before either fact remains unresolved; direct-child exit with an unconsumed request is establishment failure. The parent checks this unresolved interval every 50 milliseconds; after establishment, active-state queries back off exponentially to the existing 5-second systemctl bound. An inactive, failed, or collected-away unit proves the range empty. Unknown states and unreadable manager results reject `waitForExit()` instead of claiming quiescence. 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. 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. ### Windows runner and Job -The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and uses Node IPC for exactly one start request, idempotent terminate control, and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects invalid results, temporarily enables inheritance on those handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. +The Windows parent starts the provider runner from a bootstrap cwd and environment, gives it the original target argv after a private `--` delimiter, and waits for Node's runner `spawn` event before sending exactly one start request. A pre-spawn runner error preserves that direct launch failure while proving that no Job range existed, so the empty-range wait succeeds; post-spawn infrastructure failure remains uncertain and rejects range settlement. Node IPC otherwise carries idempotent terminate control and exactly one result. Runner fd 0 through fd 2 are isolated, fd 3 carries IPC, and fd 4 through fd 6 carry target stdin, stdout, and stderr. Fd 4 inherits the platform null-device descriptor when stdin is ignored and uses a pipe otherwise. The shared Win32 layer maps fds 4 through 6 to OS handles through Node's exported `uv_get_osfhandle()`, rejects null plus the unsigned `UV_INVALID_OS_FILE_HANDLE` and `UV_INVALID_FILE_DESCRIPTOR` sentinels exposed by Koffi, temporarily enables inheritance on valid handles, and passes them through `STARTF_USESTDHANDLES`. `spawnCurrentTokenJobProcess` requires a separately resolved `applicationName` and a complete target environment, which it sends as a sorted, double-NUL-terminated UTF-16LE block with `CREATE_UNICODE_ENVIRONMENT`, including `=X:` drive entries, without mutating the runner environment. After the suspended target enters the Job and resumes, the runner closes only fd 4 through fd 6; it never mutates or destroys Node's standard streams. The parent returns the pipe carriers as the ordinary handle's stdio, and user bytes never pass through IPC. The runner is the sole owner of the target process handle and unnamed Job handle. `spawnCurrentTokenJobProcess` creates the target suspended, assigns it to a kill-on-close Job that disallows active breakaway, and resumes it only after assignment. The runner polls the direct process for the target exit code and the Job for active-process count. It exits successfully only after the direct result has been delivered through the IPC send callback and the Job has reported zero active processes; the parent maps only that clean exit to successful `waitForExit()`. @@ -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 three result branches: `target-exit` with a numeric `exitCode`, `error` with bounded Node-shaped fields, and payload-free `start-cancelled`; the parent derives `signal: null`. A cancellation reason never crosses the wire, so the parent preserves 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 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. ### 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, the three scope-establishment states, prompt pre-establishment polling with bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. -- Windows protocol and Win32 suites pin exactly three result branches, numeric-only target exits, raw local cancellation reasons, `EPERM`/`-4048` access-denied mapping, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and invalid-result 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, 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. - 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. @@ -72,10 +72,12 @@ This note owns the current native-containment mechanism. It partially updates th **Parse a hidden runner argument in the public CLI or ship another Node executable.** Rejected because either choice expands the public application grammar or distribution surface. A packaging-only bootstrap keeps one physical executable and two private logical entries. -**Cache successful native probes or recover a failed native launch by replaying the command.** Rejected because user-manager, entry, and Job availability can change between spawns, while replay can execute a command twice after an ambiguous failure. +**Cache the complete Linux native decision without another reachability check.** Rejected because user-manager availability can change between spawns. The runtime caches only the first successful deep bootstrap/scope probe and lightly rechecks manager reachability for every later eligible call. + +**Recover a failed native launch by replaying the command.** Rejected because an ambiguous failure may occur after target execution and replay can therefore execute the command twice. ## Consequences -Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement. +Supported Linux ordinary and PTY launches and Windows ordinary launches retain descendants through process-group escape and direct-parent exit, while direct target results remain independent from range quiescence. The cost is a per-spawn Linux manager check and scope/request or Windows runner/IPC/Job lifecycle, plus explicit failure when the selected owner cannot prove settlement. Fallback hosts continue to run commands but carry a visible weaker guarantee. Windows ConPTY, macOS native containment, active breakaway descendants, old or absent user-systemd environments, target replay, persistent runner recovery, and termination paths where JavaScript cannot execute remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index d0bdf71eb8..fd7bbd362b 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -18,17 +18,17 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只 ### Linux scope 与 one-shot bootstrap -每次符合条件的 Linux 普通或 PTY spawn 都会重新检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。正向结果不缓存。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。 +同一 runtime 第一次符合条件的 Linux 普通或 PTY 调用会深入检查准确 runner 入口、libc `execve` 与 `fcntl` bindings、可读的 user manager 与保留 literal argv 的 transient-scope 支持。失败的深度 probe 会重试,第一次成功则会缓存。之后每次符合条件的调用仍会在 target 执行前轻量检查 user manager 是否可达。native 路径一旦选定,scope、协议、状态查询或 pre-exec failure 都由本次启动报告,绝不切换到 fallback。 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 已观察到 unit 都能建立 scope ownership。在这两项事实出现前,unit absence 仍是未决状态;direct child 在 request 尚未消费时退出表示建立失败。parent 每 50 毫秒检查一次这段未决区间;建立后,active-state 查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。inactive、failed 或已经被 collect 卸载的 unit 可以证明 range 为空。未知状态与不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。严格的同目录 `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 立即复查。严格的同目录 `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 后代。 ### Windows runner 与 Job -Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并通过 Node IPC 传递恰好一条 start request、幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝无效结果,临时启用这些 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 +Windows parent 从 bootstrap cwd 与环境启动 provider runner,把原始 target argv 放在私有 `--` 分隔符之后,并等待 Node 的 runner `spawn` 事件后才发送恰好一条 start request。runner 在 spawn 前报错时,direct launch failure 会原样保留,同时证明 Job range 从未存在,因此 empty-range wait 成功;spawn 后的 infrastructure failure 仍是不确定状态,会使 range settlement reject。除此之外,Node IPC 还承载幂等 terminate control 与恰好一个 result。runner 的 fd 0 至 fd 2 相互隔离,fd 3 承载 IPC,fd 4 至 fd 6 承载 target stdin、stdout 与 stderr。忽略 stdin 时,fd 4 继承平台 null-device descriptor;其他模式使用 pipe。共享 Win32 层通过 Node 导出的 `uv_get_osfhandle()` 把 fd 4 至 fd 6 映射为 OS handle,拒绝 null 以及 Koffi 暴露的 unsigned `UV_INVALID_OS_FILE_HANDLE` 与 `UV_INVALID_FILE_DESCRIPTOR` sentinel,临时启用有效 handle 的继承,并通过 `STARTF_USESTDHANDLES` 传入。`spawnCurrentTokenJobProcess` 要求单独解析的 `applicationName` 与完整 target 环境,并使用 `CREATE_UNICODE_ENVIRONMENT` 传入排序、双 NUL 结尾的 UTF-16LE 块,其中包括 `=X:` 驱动器条目,而不修改 runner 环境。suspended target 进入 Job 并恢复后,runner 只关闭 fd 4 至 fd 6;它绝不改写或销毁 Node 标准流。parent 把 pipe carrier stream 作为普通句柄的 stdio 返回,用户字节绝不经过 IPC。 runner 是 target process handle 与 unnamed Job handle 的唯一 owner。`spawnCurrentTokenJobProcess` 以 suspended 状态创建 target,把它分配给不允许 active breakaway 的 kill-on-close Job,并只在分配后恢复。runner 轮询 direct process 获取 target exit code,并轮询 Job 获取 active-process count。只有 direct result 已通过 IPC send callback 交付且 Job 已报告零 active process 后,runner 才成功退出;parent 只把这次 clean exit 映射成成功的 `waitForExit()`。 @@ -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`、携带有界 Node-shaped 字段的 `error`,以及无载荷的 `start-cancelled`;parent 会派生 `signal: null`。取消 reason 不跨 wire 传递,因此 parent 会原样保留第一个本地 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`,并携带私有 `DSH_SUBPROCESS_START_CANCELLED` code。取消 reason 不跨 wire 传递,因此 parent 会把该 code 原样映射回第一个本地 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、三种 scope 建立状态、建立前快速轮询与建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 -- Windows 协议与 Win32 测试套件固定恰好三个 result 分支、只含数字的 target exit、原样本地 cancellation reason、access denied 到 `EPERM`/`-4048` 的映射、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与无效结果拒绝、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`、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。 - 真实 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 保留。 @@ -72,10 +72,12 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu **在公共 CLI 中解析隐藏 runner 参数,或发布另一个 Node 可执行程序。**不予采用,因为前者扩张公共应用语法,后者扩张分发面。packaging-only bootstrap 保留一个物理可执行程序与两个私有逻辑入口。 -**缓存成功的 native probe,或在 native launch 失败后重放命令。**不予采用,因为 user-manager、入口与 Job availability 可以在两次 spawn 之间变化,而一次含糊 failure 之后的 replay 可能执行命令两次。 +**缓存完整的 Linux native 选择结果且不再检查可达性。**不予采用,因为 user-manager availability 可以在两次 spawn 之间变化。runtime 只缓存第一次成功的 bootstrap/scope 深度 probe,并在之后每次符合条件的调用中轻量复查 manager reachability。 + +**在 native launch 失败后重放命令。**不予采用,因为含糊 failure 可能发生在 target 已经执行之后,重放因此可能把命令执行两次。 ## Consequences -受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一个 Linux scope/request 或 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。 +受支持的 Linux 普通与 PTY 启动、Windows 普通启动会在后代逃离进程组或 direct parent 退出后继续拥有它们,同时 direct target result 与 range 完全停稳保持独立。代价是每次 spawn 都需要一次 Linux manager 检查与 scope/request,或一套 Windows runner/IPC/Job 生命周期,而且所选 owner 无法证明 settlement 时会显式失败。 fallback 宿主继续运行命令,但携带可见的较弱保证。Windows ConPTY、macOS native containment、active breakaway 后代、旧版或缺失的 user-systemd 环境、target replay、持久 runner recovery,以及 JavaScript 无法执行的终止路径均不属于本决策。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7e33d4202f..093c80d9b4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 8669904b4470cd2b7db09922e475d3aecbbc1dfb -config-catalog.zh.md: dd19ac6375034a74f01312c7237e7c8cfa0452e3 +config-catalog.md: 37076c866dfa9e2bcc1968a90fe63b7cac182154 +config-catalog.zh.md: 511a9d49c8b1315a40b6bb3fb8d2cba41a3c9c2c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8669904b44..37076c866d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2276,7 +2276,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code process-tree termination. */ + /** Grace in milliseconds between Claude Code managed-range termination tiers. */ disposeGraceMs?: number } @@ -2306,7 +2306,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds between app-server managed-range termination tiers. */ disposeGraceMs?: number } diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dd19ac6375..511a9d49c8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2278,7 +2278,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code process-tree termination. */ + /** Grace in milliseconds between Claude Code managed-range termination tiers. */ disposeGraceMs?: number } @@ -2308,7 +2308,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds between app-server managed-range termination tiers. */ disposeGraceMs?: number } diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index f98de59b10..2fd003003f 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.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 docs/subsystems/shell.md -shell.md: ff83ae6d6e1b13e53e5f1112b2d713d3212a132e -shell.zh.md: 60beba0ef37129a5bd3f86922f4beeef68e94010 +shell.md: 1bf0fc68461ba0ab3a5fb9712b348ccd8ba06de6 +shell.zh.md: dce096ac9b317d284fd6afb01ddd82dae6b7e213 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index ff83ae6d6e..1bf0fc6846 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -2,7 +2,7 @@ English | [中文](shell.zh.md) -The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md). +The bash execution seam is split across a Service Definition ([dsh-shell](../../packages/shell/shell), `ctx.shell`), Service Providers ([dsh-bash-local](../../packages/shell/bash-local) and [dsh-bash-sandbox](../../packages/shell/bash-sandbox)), and Consumer ([dsh-tool-bash](../../packages/shell/tool-bash), the `bash` schema). Generic background-job ids, ownership, and controls live in [jobs.md](jobs.md); this seam returns a task-free process handle. Managed-range mechanics live behind the [subprocess seam](subprocess.md). Source: [`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts) @@ -196,7 +196,7 @@ interface ShellProcess { */ readOutput(): ShellProcessRead /** - * Kill the process group. Returns false when it had already finished + * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. */ kill(): boolean @@ -221,7 +221,7 @@ interface ShellProcessRead { ## The service -`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill. +`ShellExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; managed-range termination, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic job runtime](jobs.md). `dsh-shell` owns the shell tools' shared exit-status contract: the exported `parseExitStatus`/`ParsedExitStatus` inverts the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append, and both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index 60beba0ef3..dce096ac9b 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -2,7 +2,7 @@ [English](shell.md) | 中文 -bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制封装在[子进程 seam](subprocess.zh.md)之后。 +bash 执行 seam 分为 Service Definition([dsh-shell](../../packages/shell/shell),`ctx.shell`)、Service Provider([dsh-bash-local](../../packages/shell/bash-local) 与 [dsh-bash-sandbox](../../packages/shell/bash-sandbox))和 Consumer([dsh-tool-bash](../../packages/shell/tool-bash),即 `bash` schema)。通用后台任务的 job id、所有权与控制位于 [jobs.md](jobs.zh.md);本 seam 返回一个不含任务概念的进程句柄。managed-range 机制封装在[子进程 seam](subprocess.zh.md)之后。 源码:[`packages/shell/shell/src/types.ts`](../../packages/shell/shell/src/types.ts) @@ -196,7 +196,7 @@ interface ShellProcess { */ readOutput(): ShellProcessRead /** - * Kill the process group. Returns false when it had already finished + * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. */ kill(): boolean @@ -221,7 +221,7 @@ interface ShellProcessRead { ## 服务 -`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。 +`ShellExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;managed-range 终止、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[子进程服务](subprocess.zh.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](jobs.zh.md)。`dsh-shell` 拥有 shell 工具共享的退出状态约定:导出的 `parseExitStatus`/`ParsedExitStatus` 是 `dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 所追加的 `[exit code: N]` / `[killed by signal: X]` 标记的逆解析,两个工具的 `presentResult` 都用它把渲染文本拆分为 terminal 卡的输出正文与退出状态 pill。 diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 7a2b2f777b..90672cfb7f 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.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 docs/subsystems/subprocess.md -subprocess.md: 0e2adacbc5dfa4c4fa6ac922a47a81388fd75535 -subprocess.zh.md: 87e88344a7e027ae02557b0aa973f9f2ae7bacf0 +subprocess.md: 782623760e70648a108ac51252b610e27bffbe45 +subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 0e2adacbc5..782623760e 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -305,13 +305,14 @@ abstract resolveExecutable( command: string, env?: Readonly', - description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.', + description: 'Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.', parameters: [{ name: 'spec', description: 'fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.' }], returns: 'the live terminal handle after allocation succeeds.', }, diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 52663fcb35..ecd1dbf2b0 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.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 packages/lsp/lsp-stdio/README.md -README.md: 7acb5a591f3cf50a02d01e96f9b4a26ff3bf693b -README.zh.md: 27020c1babbcff8acde890db5df11eaede45301a +README.md: 8ce70d3bc4703b2926a5d6a1c4efe43cfc71f62e +README.zh.md: 88bfbb34d3b9d31686565dcfeda7b08d4544281d diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 7acb5a591f..8ce70d3bc4 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -91,7 +91,7 @@ This section explains the design decisions behind the provider and where the cod - **Read before spawn.** The source is resolved, contained, and byte-bounded inside the workspace queue before any process is created, so a queued query sees current bytes when its turn starts and an invalid source cannot leave an idle process pooled. - **One pooled process per canonical workspace.** Instances are single-flighted per `(server id, canonical workspace target)`; a transport failure retries the read-only query once on a fresh process after awaiting disposal. - **Per-workspace serialization.** One abortable queue per workspace serializes source-read/open/query/close lifecycles; distinct workspaces run in parallel, and a cancellation that fails to stop a server terminates only that instance. -- **Bounded teardown.** Graceful `shutdown`/`exit` escalates through tree termination (process-group signaling on POSIX, `taskkill /T /F` on Windows); quiescence is confirmed by awaiting process-tree exit, not by the kill outcome. +- **Bounded teardown.** Graceful `shutdown`/`exit` escalates through the subprocess provider's managed-range termination procedure; quiescence is confirmed by awaiting that whole range, not by the termination request's outcome. - **Execution-world pairing.** Servers launch through `ctx.subprocess` with `processId: null` (another machine or PID namespace must not monitor the harness), sources read through `ctx.fs`, and no `fs/observed` event is emitted — only the LSP result is model-visible. ### Source map diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index 27020c1bab..88bfbb34d3 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -91,7 +91,7 @@ kind: "package-reference" - **先读后启动。** 源文件在工作区队列内先完成解析、包含关系检查与字节限制,然后才创建任何进程,因此排队查询只会在轮到自身时读取当前字节,无效源文件也不会留下空闲的池化进程。 - **每个规范工作区一个池化进程。** 实例按 `(server id, canonical workspace target)` 进行 single-flight;传输故障会在等待释放完成后于新进程上重试一次该只读查询。 - **逐工作区串行化。** 每个工作区一条可中止队列,串行执行源读取/打开/查询/关闭生命周期;不同工作区并行运行,无法停止服务器的取消只会终止该实例。 -- **有边界的释放。** 优雅 `shutdown`/`exit` 升级为进程树终止(POSIX 进程组信号,Windows `taskkill /T /F`);是否完全停稳由等待进程树退出确认,而非由终止操作自身的结果确认。 +- **有边界的释放。** 优雅 `shutdown`/`exit` 会升级到 subprocess 提供方的 managed-range 终止流程;是否完全停稳由等待整个 range 确认,而非由终止请求自身的结果确认。 - **执行世界配对。** 服务器通过 `ctx.subprocess` 启动,`processId: null`(另一台机器或 PID namespace 不得监视 harness);源文件通过 `ctx.fs` 读取;不发出 `fs/observed` 事件——只有 LSP 结果对模型可见。 ### 源码地图 diff --git a/packages/lsp/lsp-stdio/src/connection.ts b/packages/lsp/lsp-stdio/src/connection.ts index 0c70e318bb..bdc4b3fb11 100644 --- a/packages/lsp/lsp-stdio/src/connection.ts +++ b/packages/lsp/lsp-stdio/src/connection.ts @@ -4,8 +4,8 @@ * server→client requests: it answers `workspace/configuration` from static * config, and rejects `workspace/applyEdit` (this host never applies edits or * runs commands). It caps stderr, surfaces framing/decoder failures as a - * fatal close, and exposes tree-scoped termination through the handle so the - * instance owns teardown; group/tree mechanics live in the subprocess + * fatal close, and exposes managed-range termination through the handle so the + * instance owns teardown; platform mechanics live in the subprocess * Service Provider. * @module @deepseek-ai/dsh-lsp-stdio/connection */ @@ -88,7 +88,7 @@ export class LspConnection { this.decoder = new MessageDecoder(spec.maxMessageBytes) // stdin/stdout are piped protocol streams this endpoint frames itself; // stderr is a collected diagnostic tail (no spill — the bounded tail IS - // the contract). The seam owns detachment and tree-scoped signalling. + // the contract). The seam owns managed-range signalling and observation. this.handle = spawner({ argv: [spec.command, ...spec.args], cwd: spec.cwd, @@ -204,17 +204,17 @@ export class LspConnection { return this.nextId } - /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ + /** Terminate the server's provider-managed range (idempotent). */ terminate(): void { this.handle.terminate() } /** - * Wait until the owned process tree has exited. + * Wait until the owned managed range is empty. * @param signal - optional bound for the wait. - * @returns `true` when the tree exited, or `false` when the signal aborted first. + * @returns `true` when the range is empty, or `false` when the signal aborted first. */ - async waitForProcessTreeExit(signal?: AbortSignal): Promise { + async waitForManagedRangeExit(signal?: AbortSignal): Promise { return await this.handle.waitForExit(signal) } @@ -224,8 +224,7 @@ export class LspConnection { messages = this.decoder.push(chunk) } catch (error) { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and - // terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then - // the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window). + // terminate the managed range so helper processes do not outlive the leader. this.fail(asError(error)) this.handle.terminate() return diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index 84053df118..d86d484571 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -292,7 +292,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. + // Graceful shutdown failed or timed out; managed-range cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -307,16 +307,15 @@ export class LspInstance { } /** - * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), - * then await leader and helper exit. The awaits are unbounded on purpose: - * the seam's escalation already committed to SIGKILL, so quiescence — not - * another timer — is the postcondition disposal owes its callers. + * Terminate the provider-managed range, then await the direct server result + * and whole-range quiescence. The awaits are unbounded on purpose because + * quiescence, not another timer, is the postcondition disposal owes callers. */ private async forceTerminate(): Promise { this.connection.terminate() await Promise.all([ this.connection.closed, - this.connection.waitForProcessTreeExit(), + this.connection.waitForManagedRangeExit(), ]) } } diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 28634053b0..7e7ee17696 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -311,7 +311,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it('awaits a surviving process-tree helper on every concurrent dispose', async () => { + it('awaits a surviving managed-range helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' diff --git a/packages/shell/bash-local/README.i18n.yaml b/packages/shell/bash-local/README.i18n.yaml index 5121d95ba5..5282a92882 100644 --- a/packages/shell/bash-local/README.i18n.yaml +++ b/packages/shell/bash-local/README.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 packages/shell/bash-local/README.md -README.md: 388374217ed14ab2c26c3f7d50ab03a5ca57b234 -README.zh.md: f0ef6b257943effaa265eaa81b682d2fd9ecd476 +README.md: 13dba5a7686512aa6f0646013b5d014e546d8006 +README.zh.md: 756cf1b67b435309e88e780431375991606cf25d diff --git a/packages/shell/bash-local/README.md b/packages/shell/bash-local/README.md index 388374217e..13dba5a768 100644 --- a/packages/shell/bash-local/README.md +++ b/packages/shell/bash-local/README.md @@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### Background processes -Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` stops the process group; `done` settles when the process closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with. +Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with. ### Adjusting budgets at runtime @@ -80,7 +80,7 @@ This section explains the design of the executor and points at the code that rea ### Design concept -The executor is a Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything bash-shaped — command defaulting and caps, deadline fusion and cause classification, the model-friendly terminal environment, and the background read merge — while process-group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) belong to the subprocess service. Every call spawns a fresh non-login `bash -c` with no rc files, so commands are deterministic and shell state never leaks between calls. +The executor is a Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything bash-shaped — command defaulting and caps, deadline fusion and cause classification, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-login `bash -c` with no rc files, so commands are deterministic and shell state never leaks between calls. ### Source map @@ -114,7 +114,7 @@ Read these pages when the executor contract is not enough. They move from the se - [bash-sandbox](../bash-sandbox/README.md) — the confining executor to compose instead when commands need the sandbox capability. - [tool-bash](../tool-bash/README.md) — the model-facing `bash` tool over this executor. - [Bash executor subsystem](../../../docs/subsystems/shell.md) — request/spec vocabulary, results, and the service contract in full. -- [subprocess-local](../../subprocess/subprocess-local/README.md) — the process-group mechanics behind this executor. +- [subprocess-local](../../subprocess/subprocess-local/README.md) — the managed-range mechanics behind this executor. ----- diff --git a/packages/shell/bash-local/README.zh.md b/packages/shell/bash-local/README.zh.md index f0ef6b2579..756cf1b67b 100644 --- a/packages/shell/bash-local/README.zh.md +++ b/packages/shell/bash-local/README.zh.md @@ -61,7 +61,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 后台进程 -调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 停止进程组;`done` 在进程关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 +调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 ### 运行时调整预算 @@ -80,7 +80,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 设计概念 -本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 Service Provider:它负责所有 bash 层职责——命令默认化与上限、deadline 融合与原因分类、面向模型的终端环境,以及后台读取合并——而进程组机制(有界 spill 输出、凭据清除、终止升级、dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非登录 `bash -c`,不读取 rc 文件,因此命令是确定性的,shell 状态绝不会在调用之间泄漏。 +本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 Service Provider:它负责所有 bash 层职责——命令默认化与上限、deadline 融合与原因分类、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非登录 `bash -c`,不读取 rc 文件,因此命令是确定性的,shell 状态绝不会在调用之间泄漏。 ### 源码地图 @@ -114,7 +114,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) - [bash-sandbox](../bash-sandbox/README.zh.md) —— 需要沙箱能力时替换组合的受限执行器。 - [tool-bash](../tool-bash/README.zh.md) —— 基于本执行器的面向模型 `bash` 工具。 - [Bash 执行器子系统](../../../docs/subsystems/shell.zh.md) —— 请求/spec 词汇、结果与完整的服务约定。 -- [subprocess-local](../../subprocess/subprocess-local/README.zh.md) —— 本执行器背后的进程组机制。 +- [subprocess-local](../../subprocess/subprocess-local/README.zh.md) —— 本执行器背后的 managed-range 机制。 ----- diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 16d222916e..ab021be141 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -1,6 +1,6 @@ /** * Local Service Provider for the bash capability seam over the subprocess - * capability seam. Public commands run as `bash -c` in a managed process group spawned + * capability seam. Public commands run as `bash -c` in a provider-managed range * through `ctx.subprocess`; subclasses may reuse the same mechanics with an * explicit argv. This executor owns command defaulting, deadlines and cause * classification, the model-friendly terminal environment, and the model-facing @@ -93,9 +93,9 @@ export function assertServiceableBashConfig(config: Config): void { } /** - * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and - * process-group SIGTERM→SIGKILL escalation are the subprocess service's - * mechanics; this executor supplies their configured budgets per spawn, so a + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, + * managed-range SIGTERM→SIGKILL escalation, and quiescence are the subprocess + * service's mechanics; this executor supplies their configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ @@ -247,7 +247,7 @@ export class LocalBashExecutor extends ShellExecutor { /** * Start an explicit argv with the background lifecycle, environment, output, - * cancellation, and process-tree ownership semantics of this executor. + * cancellation, and managed-range ownership semantics of this executor. * Subclasses use this after replacing the public command's shell argv at an * execution boundary. * @param spec - resolved execution settings and caller-owned command metadata. diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index b595508087..7668012964 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -239,7 +239,7 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(read.delta).toContain('[stderr]') }) - it('kill() terminates the process group: true once, false after settlement', async () => { + it('kill() requests managed-range termination: true once, false after settlement', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'sleep 60' })) expect(proc.kill()).toBe(true) diff --git a/packages/shell/pwsh-local/README.i18n.yaml b/packages/shell/pwsh-local/README.i18n.yaml index e44f18ec5a..ea440b5ff8 100644 --- a/packages/shell/pwsh-local/README.i18n.yaml +++ b/packages/shell/pwsh-local/README.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 packages/shell/pwsh-local/README.md -README.md: 275a5a0bcda3ddc663b989f778f6cd69d19470b9 -README.zh.md: 93f81526e8b0911f331254324eafce19cdd78c42 +README.md: 92d10d08da5210fb01e9ae742d3e804c81eaab2c +README.zh.md: a964818e3b04dd062166ddb58bfff12fb2a6e508 diff --git a/packages/shell/pwsh-local/README.md b/packages/shell/pwsh-local/README.md index 275a5a0bcd..92d10d08da 100644 --- a/packages/shell/pwsh-local/README.md +++ b/packages/shell/pwsh-local/README.md @@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### Background processes -Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` stops the process tree; `done` settles when the process closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with. +Call `start` to run a command in the background; it returns a handle immediately and no timeout applies. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with. ### Adjusting budgets at runtime @@ -85,7 +85,7 @@ This section explains the design of the executor and points at the code that rea ### Design concept -The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while process-tree mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive`, so commands are deterministic and profile state never leaks between calls. +The executor is the PowerShell Service Provider for the `ctx.shell` seam built on the subprocess capability: it owns everything pwsh-shaped — executable resolution, command defaulting and caps, deadline fusion and cause classification, UTF-8 output pinning, the model-friendly terminal environment, and the background read merge — while managed-range mechanics (bounded spill-backed output, credential scrub, termination escalation, quiescence, and disposal) belong to the subprocess service. Every call spawns a fresh non-interactive `pwsh -Command` with `-NoLogo -NoProfile -NonInteractive`, so commands are deterministic and profile state never leaks between calls. ### Source map diff --git a/packages/shell/pwsh-local/README.zh.md b/packages/shell/pwsh-local/README.zh.md index 93f81526e8..a964818e3b 100644 --- a/packages/shell/pwsh-local/README.zh.md +++ b/packages/shell/pwsh-local/README.zh.md @@ -66,7 +66,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 后台进程 -调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 停止进程树;`done` 在进程关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 +调用 `start` 即可在后台运行命令;它立即返回句柄,且不应用任何超时。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止由提供方管理的 range;`done` 在 direct command 关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 ### 运行时调整预算 @@ -85,7 +85,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs) ### 设计概念 -本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而进程树机制(有界 spill 输出、凭据清除、终止升级、dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。 +本执行器是基于 subprocess 能力的 `ctx.shell` seam 的 PowerShell Service Provider:它负责所有 pwsh 层职责——可执行文件解析、命令默认化与上限、deadline 融合与原因分类、UTF-8 输出固定、面向模型的终端环境,以及后台读取合并——而 managed-range 机制(有界 spill 输出、凭据清除、终止升级、完全停稳与 dispose(资源释放))属于 subprocess 服务。每次调用都 spawn 全新的非交互 `pwsh -Command`,并带 `-NoLogo -NoProfile -NonInteractive`,因此命令是确定性的,profile 状态绝不会在调用之间泄漏。 ### 源码地图 diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 3a69fe97b0..92770a2742 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -122,7 +122,7 @@ export function assertServiceablePwshConfig(config: Config): void { /** * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill - * files, and process-tree termination are the subprocess service's mechanics; + * files, and managed-range termination are the subprocess service's mechanics; * this executor supplies their configured budgets per spawn. */ export class PwshLocalExecutor extends ShellExecutor { diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index e2ba0d193d..341826fa5a 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -425,7 +425,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' expect(lf(read.delta)).toContain('[stderr]') }) - it('kill() terminates the process tree: true once, false after settlement', async () => { + it('kill() requests managed-range termination: true once, false after settlement', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Seconds 60' })) expect(proc.kill()).toBe(true) diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 33a3e06db1..4c94a929d5 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.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 packages/shell/shell/README.md -README.md: 0c1cb0872b32be55f5d5b617091b8acc39d64b2b -README.zh.md: 3e2fe2a7786a0195cd13c59f8118e2a63147ed40 +README.md: 475ca13ab40d56ce260934dedbd18130c1d260fb +README.zh.md: 66b39b985103dc84237ca9af5127fd2c9448b59a diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index 0c1cb0872b..475ca13ab4 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text) ### Background processes -Call `start` with a resolved spec to launch a background process; it returns a handle immediately and no timeout applies. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Kill the process group with `kill()` (returns `false` once it has finished) and await `done` for settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle. +Call `start` with a resolved spec to launch a background process; it returns a handle immediately and no timeout applies. Read output incrementally with `readOutput()` — consecutive reads never repeat output, and lossy reads point at full-stream spill files. Terminate the provider-managed range with `kill()` (returns `false` once the direct command has finished) and await `done` for direct-command settlement. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, where the tool layer registers the handle. ### Requests and resolved specs diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 3e2fe2a778..66b39b9851 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -38,7 +38,7 @@ console.log(result.exitCode, result.stdout.text) ### 后台进程 -用已解析的 spec 调用 `start` 即可启动后台进程;它会立即返回句柄,且不应用任何超时。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止进程组(进程结束后返回 `false`),并等待 `done` 结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 +用已解析的 spec 调用 `start` 即可启动后台进程;它会立即返回句柄,且不应用任何超时。用 `readOutput()` 增量读取输出——连续读取绝不会重复交付,有损读取会指向完整流的 spill 文件。用 `kill()` 终止提供方管理的 range(直接命令结束后返回 `false`),并等待 `done` 完成直接命令结算。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。 ### 请求与已解析 spec diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts index 20ecd2ffa3..8a17c3880b 100644 --- a/packages/shell/shell/src/types.ts +++ b/packages/shell/shell/src/types.ts @@ -179,7 +179,7 @@ export interface ShellProcess { */ readOutput(): ShellProcessRead /** - * Kill the process group. Returns false when it had already finished + * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. */ kill(): boolean diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index b84e755713..66279c0665 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.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 packages/subagent/subagent-acp/README.md -README.md: 185a06a4003386a18414998e9cbaa314b9599921 -README.zh.md: 1594677f2ecdacd4ca389337fb29bd01ff18a108 +README.md: 957d38f38ffc6ffc6804e052c3c97b947dfe41cd +README.zh.md: 5ceea8e1402cc1a5eb8dc8edc58d5940679f3575 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 185a06a400..957d38f38f 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -67,7 +67,7 @@ A successful run returns the child's final streamed assistant text as the result ### Failure and recovery -A spawn, initialization, or new-session failure rejects before publication, ordinarily after the child process is reaped. If cleanup also fails, the rejection preserves ordered safe startup and teardown facts without claiming whole-tree quiescence. Non-cancellation errors expose only fixed provider, stage, and category facts; the original failure stays on the internal cause chain and in Host diagnostics. After publication, a prompt, transport, or early-process failure resolves as `error` with a safe diagnostic, while local cancellation resolves as `aborted` without failure detail. +A spawn, initialization, or new-session failure rejects before publication, ordinarily after the managed range is proven quiescent. If cleanup also fails, the rejection preserves ordered safe startup and teardown facts without claiming whole-range quiescence. Non-cancellation errors expose only fixed provider, stage, and category facts; the original failure stays on the internal cause chain and in Host diagnostics. After publication, a prompt, transport, or early-process failure resolves as `error` with a safe diagnostic, while local cancellation resolves as `aborted` without failure detail. ### Safe diagnostics @@ -91,7 +91,7 @@ This section explains how the backend drives a child over ACP and where the obse ### Start and ownership flow -A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-tree exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence. +A start resolves the child's working directory (the configured `cwd` override, else the parent session's cwd), spawns the command through the subprocess seam, performs the ACP `initialize` and `newSession` handshake, and only then publishes the run. Fulfillment means a remote session is ready and ownership has transferred to the caller. Disposal is idempotent: it closes stdin and waits a configured grace for cooperative quiescence, then escalates through SIGTERM to SIGKILL and awaits whole-range exit. Cleanup failures remain observable as ordered safe facts and never claim quiescence. ### Stop-reason mapping diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 1594677f2e..5ceea8e140 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -67,7 +67,7 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D ### 失败与恢复 -spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子进程被回收。如果清理也失败,拒绝会保留有序、安全的启动与拆卸事实,但不会声称整棵进程树已经停稳。非取消错误只暴露固定的提供方、阶段与类别事实;原始失败保留在内部 cause 链与 Host 诊断中。发布后,提示词、传输或进程提前退出会以携带安全诊断的 `error` 结算;本地取消则以不带失败详情的 `aborted` 结算。 +spawn、初始化或新建会话失败会在发布前拒绝,通常先证明 managed range 已经完全停稳。如果清理也失败,拒绝会保留有序、安全的启动与拆卸事实,但不会声称整个 range 已经停稳。非取消错误只暴露固定的提供方、阶段与类别事实;原始失败保留在内部 cause 链与 Host 诊断中。发布后,提示词、传输或进程提前退出会以携带安全诊断的 `error` 结算;本地取消则以不带失败详情的 `aborted` 结算。 ### 安全诊断 @@ -91,7 +91,7 @@ spawn、初始化或新建会话失败会在发布前拒绝,通常先等待子 ### 启动与所有权流程 -一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整棵进程树退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。 +一次启动先解析子 agent 的工作目录(配置的 `cwd` 覆盖值,否则取父会话 cwd),经子进程 seam spawn 命令,完成 ACP `initialize` 与 `newSession` 握手,然后才发布运行。兑现意味着远程会话已就绪、所有权已转移给调用方。dispose(资源释放)是幂等的:先关闭 stdin 并按配置的宽限等待协作式完全停稳,再经 SIGTERM 升级到 SIGKILL,并等待整个 managed range 退出。清理失败会作为有序的安全事实保持可观察,且绝不声称已经完全停稳。 ### 停止原因映射 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 08b3e5dd01..3164c5d634 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -67,7 +67,7 @@ export interface AcpRunSpec { disposeGraceMs: number /** * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the - * child rides the shared scrub, tree-scoped teardown, and service-owned + * child rides the shared scrub, managed-range teardown, and service-owned * lifetime instead of a package-local child_process path. */ spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -170,8 +170,8 @@ function permissionRequestKind(kind: ToolKind | null | undefined): ToolKind | 'u : 'unknown' } -/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ -async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { +/** Bounded managed-range exit wait: observes the handle's range until it is empty or `ms` elapses. */ +async function rangeExitsWithin(child: SubprocessHandle, ms: number): Promise { const controller = new AbortController() const timer = setTimeout(() => { controller.abort() }, ms) try { @@ -183,10 +183,10 @@ async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise | `model` | Claude 原生设置 | 为本提供方实例的每次运行固定的可选非空模型名称;省略时不发送 SDK 覆盖 | | `env` | `{}` | 叠加在已清理凭据的父环境之上的显式 SDK/CLI 环境 | | `permissionMode` | `dontAsk` | 为本提供方实例的每次运行固定的原生非交互权限策略 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限 | +| `disposeGraceMs` | `3000` | 共享 managed-range owner 各终止层级之间的宽限 | | `permissionMode` 值 | 原生行为 | |---|---| @@ -109,7 +109,7 @@ dsh --profile |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:config schema、提供方注册 | | [`src/run.ts`](src/run.ts) | SDK query 生命周期、结果接受与权限处理 | -| [`src/process.ts`](src/process.ts) | dispose 时的进程树逐级终止 | +| [`src/process.ts`](src/process.ts) | dispose 时的 managed-range 逐级终止 | | [`cordis.patch.yml`](cordis.patch.yml) | 注册休眠提供方的 Profile patch 层 | ### 运行流程 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index c853fc6154..9b9abce5db 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -52,7 +52,7 @@ export interface Config { * `bypassPermissions` explicitly skips permission checks. */ permissionMode?: ClaudeCodePermissionMode - /** Grace in milliseconds for Claude Code process-tree termination. */ + /** Grace in milliseconds between Claude Code managed-range termination tiers. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts index 44fa400e16..e61a4ee735 100644 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and process-tree ownership belongs to the subprocess service. + * service and managed-range ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index e9c3d81156..b610c5bb7b 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -40,7 +40,7 @@ export function sdkEnvironmentOverlay( /** * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. - * @param graceMs - process-tree termination grace. + * @param graceMs - managed-range termination grace. * @returns the fully explicit shared subprocess request. */ export function claudeSpawnSpec( @@ -73,7 +73,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** * Project a managed process with piped stdin and stdout. - * @param child - shared handle that remains the process-tree authority. + * @param child - shared handle that remains the managed-range authority. */ constructor(private readonly child: SubprocessHandle) { this.stdin = child.stdin as NonNullable @@ -93,7 +93,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { ) } - /** Whether the SDK has requested managed tree termination. */ + /** Whether the SDK has requested managed-range termination. */ get killed(): boolean { return this.killRequested } @@ -114,7 +114,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { } /** - * Route the SDK's termination request to the tree-scoped process owner. + * Route the SDK's termination request to the managed-range process owner. * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. * @returns false only after exit or a previous termination request. */ diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 86bac9e47f..dfe6c5fee7 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its * real CLI process under the shared subprocess owner, map only strict SDK - * success to completion, and dispose to whole-tree quiescence. + * success to completion, and dispose to whole-range quiescence. * * @module @deepseek-ai/dsh-subagent-claude-code/run */ @@ -156,7 +156,7 @@ export interface ClaudeCodeRunSpec { readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record - /** Subprocess termination grace passed to the shared process-tree owner. */ + /** Subprocess termination grace passed to the shared managed-range owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -261,10 +261,10 @@ export async function consumeClaudeQuery( } /** - * Close the official query, terminate the managed process tree, and wait for - * the subprocess owner to prove it is gone. + * Close the official query, terminate the managed range, and wait for the + * subprocess owner to prove it is quiescent. * @param query - official SDK query, when creation reached that point. - * @param child - shared-service handle that owns the CLI process tree, including + * @param child - shared-service handle that owns the CLI managed range, including * a published handle whose direct result later rejects. */ export async function disposeClaudeCodeChild( diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 19b92d915e..d30246b940 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1723,7 +1723,7 @@ describe('query and process disposal', () => { .toBe('SECRET_TOKEN close failure') }) - it('does not finish disposal before the managed tree exits', async () => { + it('does not finish disposal before the managed range is empty', async () => { const child = fakeChild({ exitOnTerminate: false }) let disposed = false const disposal = disposeClaudeCodeChild( @@ -1739,7 +1739,7 @@ describe('query and process disposal', () => { expect(disposed).toBe(true) }) - it('reports close and tree-wait failures without skipping cleanup', async () => { + it('reports close and range-wait failures without skipping cleanup', async () => { const waitFailure = fakeChild({ waitForExitError: new Error('wait boom'), }) @@ -1766,7 +1766,7 @@ describe('query and process disposal', () => { expect(waitFailure.terminate).toHaveBeenCalledOnce() }) - it('reports a tree-wait failure without waiting for a pending direct outcome', async () => { + it('reports a range-wait failure without waiting for a pending direct outcome', async () => { const waitFailure = new Error('managed range observation failed') const child = fakeChild({ exitOnTerminate: false, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da4e19f820..0a58da2aa3 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.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 packages/subagent/subagent-codex/README.md -README.md: db1250987333f6431f9a05f5e440e9d47a1d36f2 -README.zh.md: 4142c3ad54abc19ff3bd86a1b7fb6708eadfe9bf +README.md: f8582b94e6451a15cd97b7b7447da17c36912007 +README.zh.md: f9206f54037444191f3b85334f162d9ad824b92c diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index db12509873..f8582b94e6 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -47,7 +47,7 @@ Removing the package withdraws the provider and its private runtime closure on t | `model` | native Codex settings | Optional non-empty model name fixed for every thread from this provider instance; omission sends no app-server override | | `env` | `{}` | Explicit child environment layered over the credential-scrubbed parent environment | | `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this provider instance | -| `disposeGraceMs` | `3000` | Grace between the shared process-tree owner's termination tiers | +| `disposeGraceMs` | `3000` | Grace between the shared managed-range owner's termination tiers | | `permissionMode` value | `thread/start` fields | Native behavior | |---|---|---| diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 4142c3ad54..f9206f5403 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -47,7 +47,7 @@ dsh --profile | `model` | Codex 原生设置 | 为本提供方实例的每个线程固定的可选非空模型名称;省略时不发送 app-server 覆盖 | | `env` | `{}` | 叠加在已清理凭据的父环境之上的显式子进程环境 | | `permissionMode` | `never` | 为本提供方实例的每个线程固定的原生非交互审批与沙箱模式 | -| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限 | +| `disposeGraceMs` | `3000` | 共享 managed-range owner 各终止层级之间的宽限 | | `permissionMode` 值 | `thread/start` 字段 | 原生行为 | |---|---|---| diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 84005ac866..9fcc73ebff 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -45,7 +45,7 @@ export interface Config { env?: Record /** Native non-interactive permission mode fixed for this Provider instance. */ permissionMode?: CodexPermissionMode - /** Grace in milliseconds for app-server process-tree termination. */ + /** Grace in milliseconds between app-server managed-range termination tiers. */ disposeGraceMs?: number } diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index ec9a6302c4..0c350fffa7 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: lifecycle pairing belongs to the shared subagent - * service and process-tree ownership belongs to the subprocess service. + * service and managed-range ownership belongs to the subprocess service. */ const install: InvariantInstaller = () => {} diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 479962c631..12abd405e3 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -1,7 +1,7 @@ /** * One-shot Codex child lifecycle: spawn the real app-server through the * subprocess seam, publish only after initialization and ephemeral thread - * creation, flatten post-publication failures, and dispose to whole-tree + * creation, flatten post-publication failures, and dispose to whole-range * quiescence. * * @module @deepseek-ai/dsh-subagent-codex/run @@ -146,7 +146,7 @@ export interface CodexRunSpec { readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record - /** Subprocess termination grace passed to the shared process-tree owner. */ + /** Subprocess termination grace passed to the shared managed-range owner. */ readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle @@ -182,10 +182,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] { } /** - * Close the private wire, terminate the managed process tree, and wait for the - * subprocess owner to prove it is gone. + * Close the private wire, terminate the managed range, and wait for the + * subprocess owner to prove it is quiescent. * @param wire - private app-server protocol connection. - * @param child - shared-service handle that owns the process tree. + * @param child - shared-service handle that owns the managed range. */ export async function disposeCodexChild( wire: CodexAppServerWire, @@ -201,7 +201,7 @@ export async function disposeCodexChild( try { child.stdin?.end() } catch { - // A concurrently closed stdin does not change tree ownership below. + // A concurrently closed stdin does not change range ownership below. } child.terminate() try { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6059399834..30e38ebb85 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -2246,7 +2246,7 @@ describe('run lifecycle and quiescence', () => { }) describe('disposeCodexChild', () => { - it('closes stdin, terminates, and waits for the managed tree', async () => { + it('closes stdin, terminates, and waits for the managed range', async () => { const child = fakeChild() const wire = defaultWire(child) const end = vi.spyOn(child.toChild, 'end') @@ -2257,7 +2257,7 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).toHaveBeenCalledWith() }) - it('does not finish disposal before the managed tree exits', async () => { + it('does not finish disposal before the managed range is empty', async () => { const child = fakeChild({ exitOnTerminate: false }) const wire = defaultWire(child) let disposed = false @@ -2292,7 +2292,7 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).toHaveBeenCalledOnce() }) - it('reports tree-wait failure with safe teardown facts', async () => { + it('reports range-wait failure with safe teardown facts', async () => { const child = fakeChild({ waitForExitError: new Error('SECRET_TOKEN wait failure'), }) diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index c4aed68628..4dc158a240 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -5,7 +5,7 @@ * working-directory resolution (config override, else the delegating parent * session's workspace), the never-reject result settlement, and the standard * run-handle publication. Backends compose these with their own wire drivers; - * the process machinery itself (spawn, env scrub, tree-scoped teardown) + * the process machinery itself (spawn, env scrub, managed-range teardown) * belongs to the `dsh-subprocess` seam. * * @module @deepseek-ai/dsh-subagent/out-of-process diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5aa19f1e0b..ac866e28a0 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -32,6 +32,7 @@ import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { launchLinuxScope, prepareLinuxTerminalScope, + probeLinuxManager, probeLinuxNative, } from './linux-scope.ts' import { launchWindowsJob, probeWindowsJob } from './windows-job.ts' @@ -56,6 +57,8 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { internals: SpawnInternals = {} /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */ private fallbackWarningIssued = false + /** Positive-only cache for the expensive Linux bootstrap and scope probe. */ + private linuxDeepProbePassed = false /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined @@ -173,7 +176,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { handle = bindManagedProcess(spec, launch, binding) } this.live.add(handle) - // Release ownership only once the whole TREE is gone, not at direct-child + // Release ownership only once the whole managed range is gone, not at direct-child // settlement — a TERM-trapping helper that outlives the leader must stay // owned so teardown can still escalate it. For the common no-survivor // case waitForExit resolves immediately after settlement. @@ -189,7 +192,14 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const platform = this.internals.platform ?? process.platform let fallbackReason: string | undefined if (platform === 'linux') { - const available = this.internals.linuxNativeAvailable?.() ?? probeLinuxNative() + const deepProbe = this.internals.linuxNativeAvailable ?? probeLinuxNative + const managerProbe = this.internals.linuxManagerAvailable + ?? this.internals.linuxNativeAvailable + ?? probeLinuxManager + const available = this.linuxDeepProbePassed + ? managerProbe() + : deepProbe() + if (available) this.linuxDeepProbePassed = true if (available) return 'linux-scope' fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable' } @@ -210,13 +220,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { this.fallbackWarningIssued = true const reason = selectedReason ?? (platform === 'darwin' ? 'macOS has no supported persistent process-range owner' - : platform === 'linux' - ? 'a modern readable user-systemd scope is unavailable' - : platform === 'win32' - ? kind === 'terminal' - ? 'Windows ConPTY remains outside Job containment' - : 'the Win32 Job runner is unavailable' - : `platform ${platform} has no native managed range`) + : platform === 'win32' + ? kind === 'terminal' + ? 'Windows ConPTY remains outside Job containment' + : 'the Win32 Job runner is unavailable' + : `platform ${platform} has no native managed range`) this.ctx.logger.warn( `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`, ) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 733267384b..80cd06c781 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -52,7 +52,13 @@ const SYSTEMCTL_TIMEOUT_MS = 5_000 const SCOPE_INITIAL_POLL_INTERVAL_MS = 50 const MISSING_UNIT = /\bunit\b[^\r\n]*(?:could not be found|not found|not loaded)/iu -function systemctlEnv(): NodeJS.ProcessEnv { +function managerEnvironment(): NodeJS.ProcessEnv { + const environment = childEnv({ LC_ALL: 'C' }) + delete environment.SYSTEMD_LOG_TARGET + return environment +} + +function quietSystemdEnvironment(): NodeJS.ProcessEnv { return childEnv({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' }) } @@ -60,7 +66,7 @@ function querySystemctl(command: string, args: readonly string[]): Promise { execFile(command, [...args], { encoding: 'utf8', - env: systemctlEnv(), + env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS, }, (error, stdout, stderr) => { const code = error === null ? 0 : (error as Error & { code?: string | number }).code @@ -115,7 +121,22 @@ export function probeLinuxScope(internals: LinuxScopeInternals = {}): boolean { `${unitBase}.scope`, '--property=ActiveState', '--value', - ], { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) + ], { env: quietSystemdEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) + return result.error === undefined && result.status === 0 +} + +/** + * Confirm that the current user manager remains reachable after a positive deep probe. + * @param internals - optional systemctl seam used by tests. + * @returns whether one lightweight manager query succeeds. + */ +export function probeLinuxManager(internals: LinuxScopeInternals = {}): boolean { + const result = (internals.spawnSync ?? spawnSync)(internals.systemctl ?? 'systemctl', [ + '--user', + 'show', + '--property=Version', + '--value', + ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) return result.error === undefined && result.status === 0 } @@ -135,10 +156,12 @@ interface DirectRange { } class SystemdScopeOwner implements BoundProcessOwner { - private established = false + private establishment: 'pending' | 'established' | 'never-created' = 'pending' private stopped = false private observation: Promise | undefined private killFailure: Error | undefined + private wakeGeneration = 0 + private wakeWaiter: { generation: number; resolve: () => void } | undefined constructor( private readonly unit: string, @@ -152,8 +175,8 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return - if (!this.established && !existsSync(this.files.requestPath)) this.established = true - const directFallbackRequired = !this.established + this.observeRequestConsumption() + const directFallbackRequired = this.establishment === 'pending' if (directFallbackRequired && this.direct.running()) this.direct.signal(signal) const result = this.runSync(this.systemctl, [ '--user', @@ -161,7 +184,8 @@ class SystemdScopeOwner implements BoundProcessOwner { '--kill-whom=all', `--signal=${signal}`, this.unit, - ], { encoding: 'utf8', env: systemctlEnv(), timeout: SYSTEMCTL_TIMEOUT_MS }) + ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS }) + this.wakeObservation() if (result.error === undefined && result.status === 0) { if (signal === 'SIGKILL') this.killFailure = undefined return @@ -189,28 +213,73 @@ class SystemdScopeOwner implements BoundProcessOwner { '--kill-whom=all', '--signal=SIGKILL', this.unit, - ], { env: systemctlEnv(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) + ], { env: managerEnvironment(), stdio: 'ignore', timeout: SYSTEMCTL_TIMEOUT_MS }) } catch { // Host exit cannot report one range; the runtime continues with the rest. } } + private observeRequestConsumption(): void { + if (this.establishment === 'pending' && !existsSync(this.files.requestPath)) { + this.establishment = 'established' + } + } + + private absentUnit(): boolean { + this.observeRequestConsumption() + if (this.establishment === 'established') return false + if (!this.direct.running() && existsSync(this.files.requestPath)) { + this.establishment = 'never-created' + return false + } + if (this.killFailure !== undefined) throw this.killFailure + return true + } + + private parseUnitState(stdout: string): { loadState: string; activeState: string } { + const values = new Map() + for (const line of stdout.split(/\r?\n/u)) { + if (line === '') continue + const separator = line.indexOf('=') + if (separator <= 0) { + throw new Error(`systemctl returned malformed state for ${this.unit}: ${JSON.stringify(stdout.trim())}`) + } + const name = line.slice(0, separator) + if (values.has(name)) { + throw new Error(`systemctl returned duplicate ${name} for ${this.unit}`) + } + values.set(name, line.slice(separator + 1)) + } + const loadState = values.get('LoadState') + const activeState = values.get('ActiveState') + if (values.size !== 2 || loadState === undefined || activeState === undefined) { + throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`) + } + return { loadState, activeState } + } + private async rangeActive(): Promise { - if (!existsSync(this.files.requestPath)) this.established = true + this.observeRequestConsumption() const result = await this.query(this.systemctl, [ '--user', 'show', this.unit, + '--property=LoadState', '--property=ActiveState', - '--value', ]) const output = `${result.stdout}\n${result.stderr}` if (result.status === 0) { - this.established = true - const state = result.stdout.trim() - if (state === 'inactive' || state === 'failed') return false - if (state !== 'active' && state !== 'activating' && state !== 'deactivating') { - throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(state)}`) + const { loadState, activeState } = this.parseUnitState(result.stdout) + if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit() + if (loadState !== 'loaded') { + throw new Error( + `systemctl returned unknown state for ${this.unit}: ${JSON.stringify({ loadState, activeState })}`, + ) + } + this.establishment = 'established' + if (activeState === 'inactive' || activeState === 'failed') return false + if (!['active', 'activating', 'reloading', 'deactivating'].includes(activeState)) { + throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`) } if (this.killFailure !== undefined) throw this.killFailure return true @@ -219,23 +288,38 @@ class SystemdScopeOwner implements BoundProcessOwner { if (result.error !== undefined) throw result.error throw new Error(`systemctl could not read ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`) } - if (this.established) return false - if (!this.direct.running() && existsSync(this.files.requestPath)) { - throw new Error(`subprocess scope ${this.unit} ended before consuming its launch request`) + return this.absentUnit() + } + + private wakeObservation(): void { + this.wakeGeneration += 1 + this.wakeWaiter?.resolve() + this.wakeWaiter = undefined + } + + private async waitForPoll(delayMs: number, generation: number): Promise { + if (generation !== this.wakeGeneration) return + const wake = Promise.withResolvers() + const waiter = { generation, resolve: wake.resolve } + this.wakeWaiter = waiter + try { + await Promise.race([this.sleep(delayMs), wake.promise]) + } finally { + if (this.wakeWaiter === waiter) this.wakeWaiter = undefined } - if (this.killFailure !== undefined) throw this.killFailure - return true } async waitForExit(): Promise { if (this.stopped) return this.observation ??= (async () => { let pollIntervalMs = SCOPE_INITIAL_POLL_INTERVAL_MS + let generation = this.wakeGeneration while (await this.rangeActive()) { - await this.sleep(pollIntervalMs) + await this.waitForPoll(pollIntervalMs, generation) + generation = this.wakeGeneration // Keep establishment responsive, then reduce systemctl process churn // while systemd remains the authoritative owner of an active range. - if (this.established) { + if (this.establishment === 'established') { pollIntervalMs = Math.min(pollIntervalMs * 2, SYSTEMCTL_TIMEOUT_MS) } } diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 2e12f9f808..18fbc08fca 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -26,14 +26,14 @@ export interface LinuxLaunchRequest { export interface SerializedRunnerError { name: string message: string - stack?: string code?: string - errno?: number syscall?: string path?: string - spawnargs?: 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 } @@ -54,7 +54,6 @@ export interface WindowsTerminateRequest { export type WindowsRunnerResult = | { type: 'target-exit'; exitCode: number } | { type: 'error'; error: SerializedRunnerError } - | { type: 'start-cancelled' } /** Private paths owned by one Linux ordinary or PTY spawn. */ export interface LinuxLaunchFiles { @@ -81,17 +80,13 @@ function isSerializedRunnerError(value: unknown): value is SerializedRunnerError if (!isRecord(value) || !hasExactKeys( value, ['name', 'message'], - ['stack', 'code', 'errno', 'syscall', 'path', 'spawnargs'], + ['code', 'syscall', 'path'], )) return false return typeof value.name === 'string' && typeof value.message === 'string' - && (value.stack === undefined || typeof value.stack === 'string') && (value.code === undefined || typeof value.code === 'string') - && (value.errno === undefined || typeof value.errno === 'number') && (value.syscall === undefined || typeof value.syscall === 'string') && (value.path === undefined || typeof value.path === 'string') - && (value.spawnargs === undefined - || (Array.isArray(value.spawnargs) && value.spawnargs.every(entry => typeof entry === 'string'))) } function parseErrorResult(value: Record): LinuxStartupError { @@ -201,7 +196,7 @@ export function isWindowsTerminateRequest(value: unknown): value is WindowsTermi } /** - * Strictly parse one of the three Windows direct-result branches. + * Strictly parse one of the two Windows direct-result branches. * @param value - untrusted IPC payload. * @returns validated direct-result message. */ @@ -209,10 +204,6 @@ export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult { if (!isRecord(value) || typeof value.type !== 'string') { throw new Error('subprocess runner emitted an invalid Windows result') } - if (value.type === 'start-cancelled') { - if (!hasExactKeys(value, ['type'])) throw new Error('subprocess runner emitted an invalid start-cancelled result') - return { type: 'start-cancelled' } - } if (value.type === 'error') return parseErrorResult(value) if (value.type === 'target-exit') { const validExitCode = typeof value.exitCode === 'number' @@ -236,18 +227,13 @@ export function parseWindowsRunnerResult(value: unknown): WindowsRunnerResult { */ export function serializeRunnerError(error: unknown): SerializedRunnerError { const source = error instanceof Error ? error : new Error(String(error)) - const node = source as NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } + const node = source as NodeJS.ErrnoException & { path?: string } return { name: source.name, message: source.message, - ...typeof source.stack === 'string' ? { stack: source.stack } : {}, ...typeof node.code === 'string' ? { code: node.code } : {}, - ...typeof node.errno === 'number' ? { errno: node.errno } : {}, ...typeof node.syscall === 'string' ? { syscall: node.syscall } : {}, ...typeof node.path === 'string' ? { path: node.path } : {}, - ...Array.isArray(node.spawnargs) && node.spawnargs.every(entry => typeof entry === 'string') - ? { spawnargs: [...node.spawnargs] } - : {}, } } @@ -259,13 +245,10 @@ export function serializeRunnerError(error: unknown): SerializedRunnerError { export function deserializeRunnerError(serialized: SerializedRunnerError): Error { const error = new Error(serialized.message) error.name = serialized.name - if (serialized.stack !== undefined) error.stack = serialized.stack return Object.assign(error, { ...serialized.code === undefined ? {} : { code: serialized.code }, - ...serialized.errno === undefined ? {} : { errno: serialized.errno }, ...serialized.syscall === undefined ? {} : { syscall: serialized.syscall }, ...serialized.path === undefined ? {} : { path: serialized.path }, - ...serialized.spawnargs === undefined ? {} : { spawnargs: [...serialized.spawnargs] }, }) } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 8860eaa29a..9a3e8dffe9 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -22,6 +22,7 @@ import { linuxLaunchFilesFromLocator, parseWindowsStartRequest, serializeRunnerError, + WINDOWS_START_CANCELLED_CODE, writeLinuxStartupError, } from './runner-protocol.ts' import type { @@ -92,58 +93,50 @@ const defaultInternals: SpawnRunnerInternals = { const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'EAGAIN', 'EMFILE', 'ENFILE', 'ENOENT']) function nodeSpawnError( - source: Pick, syscall: string, code: string, - errno: number | undefined, - details: Pick, + path?: string, ): SerializedRunnerError { const message = `${syscall} ${code}` return { name: 'Error', message, - ...source.stack === undefined ? {} : { - stack: source.stack.replace(/^[^\n]*/, () => `Error: ${message}`), - }, code, - ...errno === undefined ? {} : { errno }, syscall, - ...details, + ...path === undefined ? {} : { path }, } } function asSpawnError( error: unknown, program: string, - args: readonly string[], internals: Pick, ): SerializedRunnerError { const serialized = serializeRunnerError(error) if (!(error instanceof Win32Error)) { return serialized.code === undefined ? serialized - : nodeSpawnError(serialized, `spawn ${program}`, serialized.code, serialized.errno, { - path: program, - spawnargs: [...args], - }) + : nodeSpawnError(`spawn ${program}`, serialized.code, program) } const uv = internals.uvErrorBindings ?? loadUvErrorBindings() const errno = uv.translateSystemError(error.win32Code) const code = uv.errorName(errno) if (NODE_SPAWN_DETAIL_CODES.has(code)) { - return nodeSpawnError(serialized, `spawn ${program}`, code, errno, { - path: program, - spawnargs: [...args], - }) + return nodeSpawnError(`spawn ${program}`, code, program) } - return nodeSpawnError(serialized, 'spawn', code, errno, {}) + return nodeSpawnError('spawn', code) } -function windowsPathNotFoundError(program: string, args: readonly string[]): SerializedRunnerError { - return nodeSpawnError({}, `spawn ${program}`, 'ENOENT', -4058, { - path: program, - spawnargs: [...args], - }) +function windowsPathNotFoundError(program: string): SerializedRunnerError { + return nodeSpawnError(`spawn ${program}`, 'ENOENT', program) +} + +function windowsStartCancelledError(): SerializedRunnerError { + return { + name: 'Error', + message: 'subprocess target start was cancelled', + code: WINDOWS_START_CANCELLED_CODE, + } } function linuxPathNotFoundError(program: string): NodeJS.ErrnoException { @@ -177,7 +170,7 @@ function execLinuxTarget( ): never { const program = argv[0] as string if (program.includes('/')) return execLinuxFile(program, argv, request.env, internals) - const path = request.env.PATH ?? '/usr/bin:/bin' + const path = request.env.PATH ?? '/bin:/usr/bin' let permissionFailure: Error | undefined for (const directory of path.split(':')) { const root = directory.startsWith('/') @@ -220,7 +213,7 @@ function runLinux( } catch (error) { writeLinuxStartupError(files, { type: 'error', - error: asSpawnError(error, argv[0] as string, argv.slice(1), internals), + error: asSpawnError(error, argv[0] as string, internals), }) host.exitCode = 127 } @@ -302,7 +295,7 @@ class WindowsJobRunner { private async start(request: WindowsStartRequest): Promise { if (this.terminateRequested) { - await this.publishTerminalResult({ type: 'start-cancelled' }, 0) + await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0) return } await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) @@ -310,7 +303,7 @@ class WindowsJobRunner { // IPC may set this field while start() is suspended above. // oxlint-disable-next-line typescript/no-unnecessary-condition if (this.terminateRequested) { - await this.publishTerminalResult({ type: 'start-cancelled' }, 0) + await this.publishTerminalResult({ type: 'error', error: windowsStartCancelledError() }, 0) return } try { @@ -325,7 +318,7 @@ class WindowsJobRunner { if (applicationName === undefined) { await this.publishTerminalResult({ type: 'error', - error: windowsPathNotFoundError(command as string, args), + error: windowsPathNotFoundError(command as string), }, 0) return } @@ -351,7 +344,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.argv.slice(1), this.internals), + error: asSpawnError(error, this.argv[0] as string, this.internals), }, 0) return } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 54bfc9decc..b17787a08e 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -61,6 +61,8 @@ export interface SpawnInternals { 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 } @@ -342,7 +344,7 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } if (spec.signal?.aborted) { - throw spec.signal.reason + throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } const [program] = spec.argv if (program === undefined || program.length === 0) { diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 0c1634001a..69ad2b37f5 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -13,8 +13,8 @@ import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts import { deserializeRunnerError, parseWindowsRunnerResult, + WINDOWS_START_CANCELLED_CODE, } from './runner-protocol.ts' -import type { WindowsStartRequest } from './runner-protocol.ts' import { runnerEnvironment, runnerInvocationAvailable, @@ -65,7 +65,6 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly runner: RunnerProcess, private readonly exited: Promise, - private readonly directResultSeen: () => boolean, private readonly failInfrastructure: (error: unknown) => void, ) { void this.exited.catch(() => {}) @@ -80,7 +79,7 @@ class WindowsJobOwner implements BoundProcessOwner { this.terminationSent = true try { this.runner.send?.({ type: 'terminate' }, (error) => { - if (error === null || this.directResultSeen() || !this.runner.connected) return + if (error === null) return this.failInfrastructure(error) this.terminateForHostExit() }) @@ -139,6 +138,8 @@ export function launchWindowsJob( const direct = Promise.withResolvers() const rangeExit = Promise.withResolvers() let resultSeen = false + let runnerSpawned = false + let runnerNeverCreated = false const failInfrastructure = (error: unknown): void => { direct.reject(error) rangeExit.reject(error) @@ -147,7 +148,6 @@ export function launchWindowsJob( const owner = new WindowsJobOwner( child, rangeExit.promise, - () => resultSeen, failInfrastructure, ) child.on('message', (value: unknown) => { @@ -168,16 +168,37 @@ export function launchWindowsJob( resultSeen = true if (result.type === 'target-exit') { direct.resolve({ exitCode: result.exitCode, signal: null }) - } else if (result.type === 'start-cancelled') { + } else if (result.error.code === WINDOWS_START_CANCELLED_CODE) { direct.reject(owner.startCancellationReason()) } else { direct.reject(deserializeRunnerError(result.error)) } }) + child.once('spawn', () => { + runnerSpawned = true + try { + if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel') + child.send({ type: 'start', cwd: spec.cwd, env: targetEnv }, (error) => { + if (error === null) return + failInfrastructure(error) + owner.terminateForHostExit() + }) + } catch (error) { + failInfrastructure(error) + owner.terminateForHostExit() + } + }) child.once('error', (error) => { + if (!runnerSpawned) { + runnerNeverCreated = true + direct.reject(error) + rangeExit.resolve() + return + } failInfrastructure(error) }) child.once('close', (exitCode, signal) => { + if (runnerNeverCreated) return const clean = exitCode === 0 && signal === null && resultSeen if (clean) { rangeExit.resolve() @@ -194,19 +215,6 @@ export function launchWindowsJob( failInfrastructure(error) }) - const start: WindowsStartRequest = { type: 'start', cwd: spec.cwd, env: targetEnv } - try { - if (child.send === undefined) throw new Error('subprocess-local: Windows runner has no IPC channel') - child.send(start, (error) => { - if (error === null) return - failInfrastructure(error) - owner.terminateForHostExit() - }) - } catch (error) { - failInfrastructure(error) - owner.terminateForHostExit() - } - return { stdin: spec.stdio.stdin === 'ignore' ? null : targetStdin, stdout: child.stdio[5] as Readable | null, diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ff8e69f560..048fbc3717 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -6,6 +6,7 @@ import { launchLinuxScope, prepareLinuxTerminalScope, probeLinuxBootstrap, + probeLinuxManager, probeLinuxNative, probeLinuxScope, } from '../src/linux-scope.ts' @@ -71,7 +72,15 @@ function missingUnit() { } function activeUnit(state = 'active') { - return { status: 0, stdout: `${state}\n`, stderr: '' } + return { status: 0, stdout: `LoadState=loaded\nActiveState=${state}\n`, stderr: '' } +} + +function unloadedUnit() { + return { status: 0, stdout: 'LoadState=not-found\nActiveState=inactive\n', stderr: '' } +} + +function unitState(loadState: string, activeState: string) { + return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' } } function spec() { @@ -155,17 +164,40 @@ describe('Linux native capability selection', () => { it('uses the default command adapters and runner resolution', () => { childProcessMocks.spawnSync.mockReturnValue({ status: 0, error: undefined }) expect(probeLinuxScope()).toBe(true) - expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(1) + expect(probeLinuxManager()).toBe(true) + expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2) expect(probeLinuxBootstrap({ loadLinuxExecve: () => vi.fn() as never })).toBe(true) expect(probeLinuxBootstrap({ runnerInvocation: [process.execPath], runnerAvailable: () => true, })).toBe(process.platform !== 'win32') }) + + it('keeps quieting on the transient-scope probe but preserves manager diagnostics', () => { + const spawnSync = vi.fn(( + _command: string, + _args: readonly string[], + _options: unknown, + ) => ({ status: 0, error: undefined })) + expect(probeLinuxScope({ spawnSync: spawnSync as never })).toBe(true) + expect(probeLinuxManager({ spawnSync: spawnSync as never })).toBe(true) + const scopeOptions = spawnSync.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv } + const managerOptions = spawnSync.mock.calls[1]?.[2] as { env: NodeJS.ProcessEnv } + expect(scopeOptions.env).toMatchObject({ LC_ALL: 'C', SYSTEMD_LOG_TARGET: 'null' }) + expect(managerOptions.env).toMatchObject({ LC_ALL: 'C' }) + expect(managerOptions.env).not.toHaveProperty('SYSTEMD_LOG_TARGET') + + expect(probeLinuxManager({ + spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as never, + })).toBe(false) + expect(probeLinuxManager({ + spawnSync: vi.fn(() => ({ status: null, error: new Error('missing') })) as never, + })).toBe(false) + }) }) describe('Linux scope establishment and quiescence', () => { - it('does not mistake pre-establishment unit absence for quiescence and rejects after cancellation', async () => { + it('does not mistake pre-establishment unit absence for quiescence and settles an empty range after cancellation', async () => { const { child, result, requestPath, spawnSync } = launch(async () => missingUnit()) const waiting = result.owner.waitForExit() result.owner.signal('SIGTERM') @@ -176,13 +208,13 @@ describe('Linux scope establishment and quiescence', () => { const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed') child.exit(null, 'SIGTERM') await direct - await expect(waiting).rejects.toThrow('ended before consuming its launch request') + await expect(waiting).resolves.toBeUndefined() expect(existsSync(requestPath)).toBe(true) result.owner.cleanup?.() }) it('accepts request consumption followed by rapid --collect unload as stopped', async () => { - const states = [activeUnit(), missingUnit()] + const states = [activeUnit(), unloadedUnit()] const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit()) expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) const waiting = result.owner.waitForExit() @@ -233,6 +265,17 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) + it('treats status-zero not-found as pending until the direct launcher proves the range was never created', async () => { + const state: { child?: FakeChild } = {} + const launched = launch(async () => unloadedUnit(), { + sleep: async () => { state.child?.exit(127, null) }, + }) + state.child = launched.child + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + await expect(launched.result.direct).rejects.toThrow('before its bootstrap consumed') + launched.result.owner.cleanup?.() + }) + it('polls promptly before establishment and backs off established active scopes', async () => { const delays: number[] = [] const states = [ @@ -257,11 +300,45 @@ describe('Linux scope establishment and quiescence', () => { launched.result.owner.cleanup?.() }) - it('reports child termination before request consumption to both result and wait', async () => { + it('keeps reloading scopes active and lets terminate wake a backed-off observation', async () => { + const states = [activeUnit('reloading'), activeUnit('inactive')] + const sleeping = Promise.withResolvers() + const sleep = vi.fn(async () => { + sleeping.resolve(undefined) + await new Promise(() => {}) + }) + const launched = launch(async () => states.shift() ?? activeUnit('inactive'), { sleep }) + consumeLinuxLaunchRequest(launched.requestPath) + const waiting = launched.result.owner.waitForExit() + await sleeping.promise + launched.result.owner.signal('SIGTERM') + await expect(waiting).resolves.toBeUndefined() + expect(sleep).toHaveBeenCalledExactlyOnceWith(50) + expect(launched.spawnSync).toHaveBeenCalledOnce() + launched.result.owner.cleanup?.() + }) + + it('skips the next poll delay when terminate arrives during a manager query', async () => { + const firstQuery = Promise.withResolvers>() + const query = vi.fn() + .mockImplementationOnce(async () => await firstQuery.promise) + .mockResolvedValueOnce(activeUnit('inactive')) + const sleep = vi.fn(async () => {}) + const launched = launch(query, { sleep }) + consumeLinuxLaunchRequest(launched.requestPath) + const waiting = launched.result.owner.waitForExit() + launched.result.owner.signal('SIGTERM') + firstQuery.resolve(activeUnit()) + await expect(waiting).resolves.toBeUndefined() + expect(sleep).not.toHaveBeenCalled() + launched.result.owner.cleanup?.() + }) + + it('reports child termination before request consumption to the direct result and settles the empty range', async () => { const { child, result } = launch(async () => missingUnit()) child.exit(127, null) await expect(result.direct).rejects.toThrow('before its bootstrap consumed') - await expect(result.owner.waitForExit()).rejects.toThrow('ended before consuming its launch request') + await expect(result.owner.waitForExit()).resolves.toBeUndefined() result.owner.cleanup?.() }) @@ -292,6 +369,10 @@ describe('Linux scope establishment and quiescence', () => { await expect(unknown.result.owner.waitForExit()).rejects.toThrow('unknown ActiveState') unknown.result.owner.cleanup?.() + const unknownLoad = launch(async () => unitState('masked', 'inactive')) + await expect(unknownLoad.result.owner.waitForExit()).rejects.toThrow('unknown state') + unknownLoad.result.owner.cleanup?.() + const killFailed = launch(async () => activeUnit(), { spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never, }) @@ -303,7 +384,10 @@ describe('Linux scope establishment and quiescence', () => { it('reports command-query failures from the default systemctl adapter', async () => { childProcessMocks.execFile.mockImplementationOnce((...args: unknown[]) => { const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void - callback(null, 'inactive\n', '') + const options = args[2] as { env: NodeJS.ProcessEnv } + expect(options.env).toMatchObject({ LC_ALL: 'C' }) + expect(options.env).not.toHaveProperty('SYSTEMD_LOG_TARGET') + callback(null, 'LoadState=loaded\nActiveState=inactive\n', 'manager diagnostic remains readable') return new EventEmitter() }) const stopped = launch(undefined) @@ -321,6 +405,19 @@ describe('Linux scope establishment and quiescence', () => { failed.result.owner.cleanup?.() }) + it('rejects malformed, duplicate, incomplete, and extra manager state fields', async () => { + for (const [stdout, message] of [ + ['loaded\nActiveState=active\n', 'malformed state'], + ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'], + ['LoadState=loaded\n', 'incomplete state'], + ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'], + ] as const) { + const launched = launch(async () => ({ status: 0, stdout, stderr: '' })) + await expect(launched.result.owner.waitForExit()).rejects.toThrow(message) + launched.result.owner.cleanup?.() + } + }) + it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => { const spawnSync = vi.fn() .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index fe1d3318bb..7e695543ba 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -448,6 +448,7 @@ describe('LocalSubprocessRuntime', () => { cleanup: vi.fn(), })) const probeLinuxNative = vi.fn(() => true) + const probeLinuxManager = vi.fn(() => true) const inspector = { foregroundPgid: () => undefined, isStdinWaiting: () => false, @@ -467,6 +468,7 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope, + probeLinuxManager, probeLinuxNative, })) let fiber: { dispose(): Promise } | undefined @@ -545,6 +547,7 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope, + probeLinuxManager: () => true, probeLinuxNative: () => true, })) let fiber: { dispose(): Promise } | undefined @@ -653,7 +656,6 @@ describe('LocalSubprocessRuntime', () => { it('reports the platform-specific reason for every fallback mode', async () => { for (const [platform, kind, reason, selectedReason] of [ ['darwin', 'ordinary', 'macOS has no supported persistent process-range owner', undefined], - ['linux', 'terminal', 'a modern readable user-systemd scope is unavailable', undefined], ['linux', 'ordinary', 'the private Linux subprocess runner is unavailable', 'the private Linux subprocess runner is unavailable'], ['win32', 'ordinary', 'the Win32 Job runner is unavailable', undefined], ['win32', 'terminal', 'Windows ConPTY remains outside Job containment', undefined], @@ -677,12 +679,33 @@ 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' } const launchLinuxScope = vi.fn(() => linuxLaunch) const launchWindowsJob = vi.fn(() => windowsLaunch) const probeLinuxNative = vi.fn(() => true) + const probeLinuxManager = vi.fn(() => true) const probeWindowsJob = vi.fn(() => true) const prepareManagedProcessBinding = vi.fn(() => ({ spillDir: '/tmp/dsh-test-spill' })) const handles = [true, false, false].map((failFirstWait) => { @@ -711,6 +734,7 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope, prepareLinuxTerminalScope: vi.fn(), + probeLinuxManager, probeLinuxNative, })) vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob, probeWindowsJob })) @@ -736,7 +760,8 @@ describe('LocalSubprocessRuntime', () => { await new Promise(resolve => setImmediate(resolve)) await linuxRuntime.spawn(spec('true')).done await new Promise(resolve => setImmediate(resolve)) - expect(probeLinuxNative).toHaveBeenCalledTimes(3) + expect(probeLinuxNative).toHaveBeenCalledOnce() + expect(probeLinuxManager).toHaveBeenCalledTimes(2) expect(launchLinuxScope).toHaveBeenCalledTimes(2) const windowsContext = new Context() @@ -765,12 +790,13 @@ describe('LocalSubprocessRuntime', () => { } }) - it('does not cache failed or successful native capability probes', async () => { + it('retries failed Linux deep probes, caches the first success, and rechecks the manager', async () => { const probeLinuxNative = vi.fn() .mockReturnValueOnce(false) .mockReturnValueOnce(false) .mockReturnValueOnce(false) .mockReturnValueOnce(true) + const probeLinuxManager = vi.fn() .mockReturnValueOnce(false) .mockReturnValueOnce(true) const probeWindowsJob = vi.fn() @@ -783,6 +809,7 @@ describe('LocalSubprocessRuntime', () => { vi.doMock('../src/linux-scope.ts', () => ({ launchLinuxScope: vi.fn(), prepareLinuxTerminalScope: vi.fn(), + probeLinuxManager, probeLinuxNative, })) vi.doMock('../src/windows-job.ts', () => ({ launchWindowsJob: vi.fn(), probeWindowsJob })) @@ -805,7 +832,8 @@ describe('LocalSubprocessRuntime', () => { expect(linuxSelect('ordinary')).toBe('linux-scope') expect(linuxSelect('ordinary')).toBe('fallback') expect(linuxSelect('ordinary')).toBe('linux-scope') - expect(probeLinuxNative).toHaveBeenCalledTimes(6) + expect(probeLinuxNative).toHaveBeenCalledTimes(4) + expect(probeLinuxManager).toHaveBeenCalledTimes(2) const windowsContext = new Context() vi.spyOn(windowsContext.logger, 'warn').mockImplementation(() => {}) diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 7e5a13a57b..2afb41ed1c 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -26,7 +26,7 @@ function spec(argv: string[], graceMs = 100): SubprocessSpawnSpec { } } -type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } +type SpawnFailure = NodeJS.ErrnoException & { path?: string } function directSpawnFailure(argv: readonly string[]): Promise { return new Promise((resolve, reject) => { @@ -165,10 +165,8 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { name: expectedMissing.name, message: expectedMissing.message, code: expectedMissing.code, - errno: expectedMissing.errno, syscall: expectedMissing.syscall, path: expectedMissing.path, - spawnargs: expectedMissing.spawnargs, }) const deniedPath = join(scratch, `not-executable-${Date.now()}`) @@ -182,10 +180,8 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { name: expectedDenied.name, message: expectedDenied.message, code: expectedDenied.code, - errno: expectedDenied.errno, syscall: expectedDenied.syscall, path: expectedDenied.path, - spawnargs: expectedDenied.spawnargs, }) }) diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index 0973ee7270..f599f9f781 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -56,18 +56,16 @@ function cleanup(pid: number): void { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } -type SpawnFailure = NodeJS.ErrnoException & { path?: string; spawnargs?: string[] } +type SpawnFailure = NodeJS.ErrnoException & { path?: string } function expectedSpawnFailure(error: SpawnFailure): Record { const expected: Record = { name: error.name, message: error.message, code: error.code, - errno: error.errno, syscall: error.syscall, } if (Object.hasOwn(error, 'path')) expected.path = error.path - if (Object.hasOwn(error, 'spawnargs')) expected.spawnargs = error.spawnargs return expected } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts index 554eb7f918..f6602c8a61 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import type { Buffer } from 'node:buffer' import { existsSync } from 'node:fs' import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { @@ -10,6 +11,7 @@ import { } from '../src/runner-protocol.ts' import { runnerEnvironment, + runnerInvocationAvailable, SUBPROCESS_RUNNER_ENV, targetEnvironment, } from '../src/runner-launch.ts' @@ -19,7 +21,7 @@ import { launchWindowsJob } from '../src/windows-job.ts' const repoRoot = resolve(import.meta.dirname, '../../../..') const sourceRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/src/bin.ts') -const builtRunner = resolve(repoRoot, 'packages/subprocess/subprocess-local/lib/runner.js') +const builtRunner = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-subprocess-local/runner')) function targetEnv(): Record { return { @@ -102,7 +104,9 @@ describe('subprocess-local runner artifacts', () => { }, 30_000) it.skipIf(!existsSync(builtRunner))('executes the built ./runner subpath through the same core', async () => { - const result = await execute([process.execPath, builtRunner]) + const invocation: RunnerInvocation = [process.execPath, builtRunner] + expect(runnerInvocationAvailable(invocation)).toBe(true) + const result = await execute(invocation) expect(result).toEqual({ status: 0, stdout: `${process.execPath}|${repoRoot}|target-collision-restored`, diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 778594197f..3804693140 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -30,6 +30,7 @@ import { parseWindowsStartRequest, readLinuxStartupError, serializeRunnerError, + WINDOWS_START_CANCELLED_CODE, writeLinuxStartupError, } from '../src/runner-protocol.ts' import { @@ -148,9 +149,14 @@ describe('closed runner protocol', () => { expect(statSync(files.startupErrorPath).mode & 0o777).toBe(0o600) } const result = readLinuxStartupError(files.startupErrorPath) - expect(result).toMatchObject({ type: 'error', error: { code: 'ENOENT', path: 'tool', spawnargs: ['x'] } }) + expect(result).toEqual({ + type: 'error', + error: { + name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool', + }, + }) expect(deserializeRunnerError(result!.error)).toMatchObject({ - name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', errno: -2, + name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool', }) writeFileSync(join(files.directory, '.startup-error.tmp'), 'incomplete') cleanupLinuxLaunchFiles(files) @@ -194,7 +200,6 @@ describe('closed runner protocol', () => { expect(() => parseWindowsStartRequest({ type: 'start', cwd: 'C:\\x', env: {}, extra: 1 })).toThrow() expect(isWindowsTerminateRequest({ type: 'terminate' })).toBe(true) expect(isWindowsTerminateRequest({ type: 'terminate', reason: 'no' })).toBe(false) - expect(parseWindowsRunnerResult({ type: 'start-cancelled' })).toEqual({ type: 'start-cancelled' }) expect(parseWindowsRunnerResult({ type: 'target-exit', exitCode: 7 })).toEqual({ type: 'target-exit', exitCode: 7, }) @@ -204,6 +209,7 @@ describe('closed runner protocol', () => { for (const invalid of [ null, { type: 'unknown' }, + { type: 'start-cancelled' }, { type: 'start-cancelled', payload: 1 }, { type: 'target-exit', exitCode: -1 }, { type: 'target-exit', exitCode: 0, signal: null }, @@ -328,20 +334,21 @@ describe('runner launch inputs', () => { expect(launched.stderr).not.toContain('ERR_MODULE_NOT_FOUND') }) - it('bounds non-Error and stackless runner failures', () => { + it('serializes only the private protocol diagnostic fields', () => { expect(serializeRunnerError('plain failure')).toMatchObject({ name: 'Error', message: 'plain failure', }) - const stackless = new Error('stackless') - Reflect.deleteProperty(stackless, 'stack') - expect(serializeRunnerError(stackless)).toEqual({ name: 'Error', message: 'stackless' }) + const detailed = Object.assign(new Error('detailed'), { + code: 'ENOENT', errno: -2, syscall: 'spawn tool', path: 'tool', spawnargs: ['arg'], + }) + expect(serializeRunnerError(detailed)).toEqual({ + name: 'Error', message: 'detailed', code: 'ENOENT', syscall: 'spawn tool', path: 'tool', + }) const minimal = deserializeRunnerError({ name: 'Error', message: 'minimal' }) expect(minimal).toMatchObject({ name: 'Error', message: 'minimal' }) expect(minimal).not.toHaveProperty('code') - expect(minimal).not.toHaveProperty('errno') expect(minimal).not.toHaveProperty('syscall') expect(minimal).not.toHaveProperty('path') - expect(minimal).not.toHaveProperty('spawnargs') }) it('resolves Windows executables with target-cwd and PATH search semantics', () => { @@ -445,10 +452,8 @@ describe('Linux one-shot exec bootstrap', () => { name: 'Error', message: 'spawn tool ENOENT', code: 'ENOENT', - errno: -2, syscall: 'spawn tool', path: 'tool', - spawnargs: ['literal arg'], }, }) }) @@ -528,17 +533,15 @@ describe('Linux one-shot exec bootstrap', () => { throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', errno: -13 }) }) await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) - expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool']) + expect(execve.mock.calls.map(call => call[0])).toEqual(['/bin/tool', '/usr/bin/tool']) expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error', error: { name: 'Error', message: 'spawn tool EACCES', code: 'EACCES', - errno: -13, syscall: 'spawn tool', path: 'tool', - spawnargs: [], }, }) @@ -598,10 +601,8 @@ describe('Windows Job runner protocol owner', () => { name: 'Error', message: 'spawn tool.exe ENOENT', code: 'ENOENT', - errno: -4058, syscall: 'spawn tool.exe', path: 'tool.exe', - spawnargs: ['literal arg'], }, }]) expect(host.exitCode).toBe(0) @@ -634,18 +635,15 @@ describe('Windows Job runner protocol owner', () => { name: 'Error', message: `${syscall} ${code}`, code, - errno, syscall, }, }]) const result = parseWindowsRunnerResult(host.sent[0]) if (result.type !== 'error') throw new Error('expected runner error') - expect(result.error.stack?.split('\n')[0]).toBe(`Error: ${syscall} ${code}`) if (enriched) { - expect(result.error).toMatchObject({ path: program, spawnargs: ['literal arg'] }) + expect(result.error).toMatchObject({ path: program }) } else { expect(result.error).not.toHaveProperty('path') - expect(result.error).not.toHaveProperty('spawnargs') } } }) @@ -661,17 +659,15 @@ describe('Windows Job runner protocol owner', () => { type: 'error', error: { code: 'ENOENT', - errno: process.platform === 'win32' ? -4058 : -2, path: 'tool.exe', - spawnargs: ['literal arg'], }, }]) }) it.skipIf(process.platform !== 'win32')('preserves native EMFILE and UNKNOWN translations', async () => { - for (const [win32Code, code, errno, enriched] of [ - [4, 'EMFILE', -4066, true], - [999, 'UNKNOWN', -4094, false], + for (const [win32Code, code, enriched] of [ + [4, 'EMFILE', true], + [999, 'UNKNOWN', false], ] as const) { const host = new FakeRunnerHost() const native = internals({ @@ -681,15 +677,14 @@ describe('Windows Job runner protocol owner', () => { await runWindows(host, native) expect(host.sent).toMatchObject([{ type: 'error', - error: { code, errno }, + 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', spawnargs: ['literal arg'] }) + expect(result.error).toMatchObject({ path: 'tool.exe' }) } else { expect(result.error).not.toHaveProperty('path') - expect(result.error).not.toHaveProperty('spawnargs') } } }) @@ -769,7 +764,7 @@ describe('Windows Job runner protocol owner', () => { } }) - it('exhausts target-exit, error, and payload-free start-cancelled', async () => { + it('exhausts target-exit and strict error results, including start cancellation', async () => { const spawnHost = new FakeRunnerHost() await runWindows(spawnHost, internals({ spawnCurrentTokenJobProcess: vi.fn(() => { throw new Win32Error('CreateProcessW', 2) }), @@ -790,7 +785,14 @@ describe('Windows Job runner protocol owner', () => { cancelledHost.emit('message', { type: 'terminate' }) cancelledHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) await running - expect(cancelledHost.sent).toEqual([{ type: 'start-cancelled' }]) + expect(cancelledHost.sent).toEqual([{ + type: 'error', + error: { + name: 'Error', + message: 'subprocess target start was cancelled', + code: WINDOWS_START_CANCELLED_CODE, + }, + }]) expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() }) @@ -801,7 +803,14 @@ describe('Windows Job runner protocol owner', () => { host.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) host.emit('message', { type: 'terminate' }) await running - expect(host.sent).toEqual([{ type: 'start-cancelled' }]) + expect(host.sent).toEqual([{ + type: 'error', + error: { + name: 'Error', + message: 'subprocess target start was cancelled', + code: WINDOWS_START_CANCELLED_CODE, + }, + }]) expect(native.spawnCurrentTokenJobProcess).not.toHaveBeenCalled() }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 0edd0af591..f268fd16e2 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -313,18 +313,15 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('throws the raw signal reason when already aborted before spawn', () => { - for (const reason of ['too late', null] as const) { + it('throws a stable Error when already aborted before spawn', () => { + for (const [reason, message] of [ + ['too late', 'aborted before spawn: too late'], + [null, 'aborted before spawn: aborted'], + ] as const) { const controller = new AbortController() controller.abort(reason) - let thrown = false - try { - validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) - } catch (error) { - thrown = true - expect(error).toBe(reason) - } - expect(thrown).toBe(true) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) + .toThrow(new Error(message)) } }) @@ -1276,21 +1273,15 @@ describe('argv validation', () => { }) describe('abort edge cases', () => { - it('throws an undefined reason from a reason-less pre-aborted signal unchanged', () => { + it('uses a stable fallback for a reason-less pre-aborted signal', () => { const bare = { aborted: true, reason: undefined, addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - let thrown = false - try { - validateSubprocessSpec(spec('echo hi', { signal: bare })) - } catch (error) { - thrown = true - expect(error).toBeUndefined() - } - expect(thrown).toBe(true) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: bare })) }) + .toThrow(new Error('aborted before spawn: aborted')) }) it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 42177a2358..2fea9ba85d 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -7,6 +7,7 @@ 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 @@ -60,12 +61,14 @@ const spec = { function launch( child = new FakeChild(), request: Parameters[0] = spec, + emitSpawn = true, ) { 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'], }) + if (emitSpawn) child.emit('spawn') return { child, result, spawn } } @@ -92,6 +95,7 @@ describe('Windows Job capability', () => { const result = isolated.launchWindowsJob(spec, { TARGET: 'yes' }) expect(spawn).toHaveBeenCalledOnce() + child.emit('spawn') child.emit('message', { type: 'target-exit', exitCode: 0 }) child.connected = false child.emit('close', 0, null) @@ -224,7 +228,12 @@ describe('Windows parent runner contract', () => { const reason = new Error('caller aborted') cancelled.result.owner.signal('SIGTERM', reason) expect(cancelled.child.sent.at(-1)).toEqual({ type: 'terminate' }) - cancelled.child.emit('message', { type: 'start-cancelled' }) + cancelled.child.emit('message', { + type: 'error', + error: { + name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE, + }, + }) await expect(cancelled.result.direct).rejects.toBe(reason) cancelled.child.connected = false cancelled.child.emit('close', 0, null) @@ -233,14 +242,24 @@ describe('Windows parent runner contract', () => { const nullCancelled = launch() nullCancelled.result.owner.signal('SIGTERM', null) nullCancelled.result.owner.signal('SIGKILL', new Error('later reason')) - nullCancelled.child.emit('message', { type: 'start-cancelled' }) + nullCancelled.child.emit('message', { + type: 'error', + error: { + name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE, + }, + }) await expect(nullCancelled.result.direct).rejects.toBeNull() nullCancelled.child.connected = false nullCancelled.child.emit('close', 0, null) await expect(nullCancelled.result.owner.waitForExit()).resolves.toBeUndefined() const implicit = launch() - implicit.child.emit('message', { type: 'start-cancelled' }) + implicit.child.emit('message', { + type: 'error', + error: { + name: 'Error', message: 'subprocess target start was cancelled', code: WINDOWS_START_CANCELLED_CODE, + }, + }) await expect(implicit.result.direct).rejects.toThrow('target start was cancelled') implicit.child.connected = false implicit.child.emit('close', 0, null) @@ -281,11 +300,18 @@ describe('Windows parent runner contract', () => { 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 errored = launch(new FakeChild(), spec, false) const spawnError = new Error('runner executable missing') errored.child.emit('error', spawnError) await expect(errored.result.direct).rejects.toBe(spawnError) - await expect(errored.result.owner.waitForExit()).rejects.toBe(spawnError) + await expect(errored.result.owner.waitForExit()).resolves.toBeUndefined() + errored.child.emit('close', 127, null) + + const postSpawnError = launch() + const infrastructureError = new Error('runner failed after spawn') + postSpawnError.child.emit('error', infrastructureError) + await expect(postSpawnError.result.direct).rejects.toBe(infrastructureError) + await expect(postSpawnError.result.owner.waitForExit()).rejects.toBe(infrastructureError) const sendFailedChild = new FakeChild() sendFailedChild.sendError = new Error('IPC send failed') @@ -334,25 +360,26 @@ describe('Windows parent runner contract', () => { await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw') }) - it('ignores a terminate callback error after a direct result while the runner is connected', async () => { + it('preserves a direct result but rejects range settlement when termination delivery later fails', 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' }) + child.emit('message', { type: 'target-exit', exitCode: 7 }) + child.targetStdout.end() + child.targetStderr.end() + await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null }) + + launched.result.owner.signal('SIGTERM') expect(child.pendingSendCallbacks).toHaveLength(1) expect(child.connected).toBe(true) child.deliverNextSend(new Error('late EPIPE')) await Promise.resolve() - expect(child.killed).toEqual([]) - child.connected = false - child.emit('close', 0, null) - await expect(handle.waitForExit()).resolves.toBe(true) + expect(child.killed).toEqual(['SIGKILL']) + await expect(handle.done).resolves.toEqual({ exitCode: 7, signal: null }) + await expect(handle.waitForExit()).rejects.toThrow('late EPIPE') }) it('uses synchronous runner termination for host exit and isolates repeated control', () => { diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 4a6977c6c5..1ebe471142 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -127,13 +127,14 @@ export abstract class SubprocessRuntime extends Service { * applies no defaults. * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. * @returns the live process handle (streams/readers, signalling, outcome promise). + * @throws synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation. */ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle /** * Allocate a real terminal and start one owned process session. This is the * only non-pipe process primitive: implementations own terminal byte I/O, - * foreground groups, signals, and complete session-tree cleanup. + * foreground groups, signals, and whole-session quiescence. * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation. * @returns the live terminal handle after allocation succeeds. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 5a68231c0f..a7a9ceebdf 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -226,7 +226,7 @@ export interface SubprocessTerminalForeground { /** * One live terminal process and its owned OS session. Terminal allocation, - * foreground-group inspection/signalling, and session-tree cleanup are one + * foreground-group inspection/signalling, and whole-session quiescence are one * deep subprocess primitive because none can be reconstructed from ordinary * piped stdio without substrate-specific process control. */ diff --git a/packages/subprocess/win32-process/src/process.ts b/packages/subprocess/win32-process/src/process.ts index d8770206fc..1c0fa7f90d 100644 --- a/packages/subprocess/win32-process/src/process.ts +++ b/packages/subprocess/win32-process/src/process.ts @@ -357,6 +357,10 @@ interface ProcessStandardHandles { stderr: NativePtr } +// Koffi exposes PVOID as an unsigned 64-bit bigint on supported Windows hosts. +const UV_INVALID_OS_FILE_HANDLE = 0xffff_ffff_ffff_ffffn +const UV_INVALID_FILE_DESCRIPTOR = 0xffff_ffff_ffff_fffen + function inheritedStandardHandles(api: Win32ProcessBindings): ProcessStandardHandles { const get = (selector: number, label: string): NativePtr => { const handle = api.getStdHandle(selector) @@ -376,7 +380,11 @@ function targetCarrierHandles( ): ProcessStandardHandles { const get = (fileDescriptor: number, label: string): NativePtr => { const handle = api.uvGetOsfhandle(fileDescriptor) - if (isNullPtr(handle) || handle === -1n || handle === -2n) { + if ( + isNullPtr(handle) + || handle === UV_INVALID_OS_FILE_HANDLE + || handle === UV_INVALID_FILE_DESCRIPTOR + ) { throw new Error(`uv_get_osfhandle returned an invalid handle for target ${label} fd ${String(fileDescriptor)}`) } return handle diff --git a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts index fcdb2dd203..8b5d1011ef 100644 --- a/packages/subprocess/win32-process/tests/ordinary-process.spec.ts +++ b/packages/subprocess/win32-process/tests/ordinary-process.spec.ts @@ -241,6 +241,8 @@ describe('ordinary Job process operations', () => { expect(createProcessW).not.toHaveBeenCalled() } - for (const invalid of [null, 0n, -1n, -2n]) expectFailure(invalid as NativePtr | null) + for (const invalid of [null, 0n, 0xffff_ffff_ffff_ffffn, 0xffff_ffff_ffff_fffen]) { + expectFailure(invalid as NativePtr | null) + } }) }) diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index da39ceb5c6..661141c6ba 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,9 +26,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\"\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and complete session-tree cleanup.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -36,6 +36,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[32,36]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 05b67894254929a816527a34b24db47da45285bf Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 00:59:07 +0800 Subject: [PATCH 098/110] fix(subprocess): preserve clean range after start errors --- .../subprocess-local/src/windows-job.ts | 13 +++++++----- .../tests/windows-job.spec.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index 69ad2b37f5..a2ef3262c1 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-win32-process' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { + type WindowsRunnerResult, deserializeRunnerError, parseWindowsRunnerResult, WINDOWS_START_CANCELLED_CODE, @@ -65,6 +66,7 @@ class WindowsJobOwner implements BoundProcessOwner { constructor( private readonly runner: RunnerProcess, private readonly exited: Promise, + private readonly directResultType: () => WindowsRunnerResult['type'] | undefined, private readonly failInfrastructure: (error: unknown) => void, ) { void this.exited.catch(() => {}) @@ -79,7 +81,7 @@ class WindowsJobOwner implements BoundProcessOwner { this.terminationSent = true try { this.runner.send?.({ type: 'terminate' }, (error) => { - if (error === null) return + if (error === null || this.directResultType() === 'error') return this.failInfrastructure(error) this.terminateForHostExit() }) @@ -137,7 +139,7 @@ export function launchWindowsJob( const direct = Promise.withResolvers() const rangeExit = Promise.withResolvers() - let resultSeen = false + let directResultType: WindowsRunnerResult['type'] | undefined let runnerSpawned = false let runnerNeverCreated = false const failInfrastructure = (error: unknown): void => { @@ -148,10 +150,11 @@ export function launchWindowsJob( const owner = new WindowsJobOwner( child, rangeExit.promise, + () => directResultType, failInfrastructure, ) child.on('message', (value: unknown) => { - if (resultSeen) { + if (directResultType !== undefined) { const error = new Error('subprocess-local: Windows runner emitted more than one direct result') failInfrastructure(error) owner.terminateForHostExit() @@ -165,7 +168,7 @@ export function launchWindowsJob( owner.terminateForHostExit() return } - resultSeen = true + directResultType = result.type if (result.type === 'target-exit') { direct.resolve({ exitCode: result.exitCode, signal: null }) } else if (result.error.code === WINDOWS_START_CANCELLED_CODE) { @@ -199,7 +202,7 @@ export function launchWindowsJob( }) child.once('close', (exitCode, signal) => { if (runnerNeverCreated) return - const clean = exitCode === 0 && signal === null && resultSeen + const clean = exitCode === 0 && signal === null && directResultType !== undefined if (clean) { rangeExit.resolve() return diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 2fea9ba85d..e0b9420d1a 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -360,6 +360,27 @@ describe('Windows parent runner contract', () => { await expect(error.result.owner.waitForExit()).rejects.toThrow('send threw') }) + it('accepts clean range settlement after a direct error races redundant termination delivery', 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) + + expect(child.connected).toBe(true) + child.deliverNextSend(new Error('late EPIPE')) + await Promise.resolve() + expect(child.killed).toEqual([]) + child.connected = false + child.emit('close', 0, null) + await expect(handle.waitForExit()).resolves.toBe(true) + }) + it('preserves a direct result but rejects range settlement when termination delivery later fails', async () => { const child = new FakeChild() const launched = launch(child) From bc681d7a542d503102939897143d508d72425edc Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 01:18:40 +0800 Subject: [PATCH 099/110] fix(subprocess): harden cancellation settlement --- packages/shell/bash-local/src/index.ts | 8 +++++- .../shell/bash-local/tests/executor.spec.ts | 26 +++++++++++++++++++ packages/shell/pwsh-local/src/index.ts | 8 +++++- .../shell/pwsh-local/tests/executor.spec.ts | 17 ++++++++++++ .../subprocess-local/src/linux-scope.ts | 3 +-- .../subprocess/subprocess-local/src/spawn.ts | 8 +++++- .../subprocess-local/src/windows-job.ts | 4 +-- .../subprocess-local/tests/spawn.spec.ts | 9 +++++++ 8 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index ab021be141..0ab4c03964 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -285,7 +285,13 @@ export class LocalBashExecutor extends ShellExecutor { }, (error: unknown) => { // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + let detail = 'unprintable provider failure' + try { + detail = String(error) + } catch { + // Provider-owned rejection values cannot make ShellProcess.done reject. + } + providerFailureNote = `subprocess failed before reporting an outcome: ${detail}` this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index 7668012964..9cc63475da 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -322,6 +322,32 @@ describe('LocalBashExecutor.start (background process handles)', () => { expect(proc.readOutput().delta).toBe('') }) + it('settles an unprintable provider rejection instead of rejecting done', async () => { + const { ctx, bash } = await setup() + const emptyReader: SubprocessOutputReader = { + readFrom: () => ({ text: '', nextOffset: 0, lossy: false }), + } + const providerError = new Error('unprintable provider error') + Object.defineProperty(providerError, Symbol.toPrimitive, { + value: () => { throw new Error('provider formatting must not escape') }, + }) + vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({ + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: { stdout: emptyReader, stderr: emptyReader }, + done: Promise.reject(providerError), + terminate: vi.fn(), + waitForExit: async () => true, + } satisfies SubprocessHandle) + + const proc = bash.start(bash.resolve({ command: 'true' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('unprintable provider failure') + expect(proc.readOutput().delta).toBe('') + }) + it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => { const { bash } = await setup() const proc = bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' })) diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index 92770a2742..99d7d1564b 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -314,7 +314,13 @@ export class PwshLocalExecutor extends ShellExecutor { }, (error: unknown) => { // Background provider failures settle as killed and surface through the read path. proc.status = 'killed' - providerFailureNote = `subprocess failed before reporting an outcome: ${String(error)}` + let detail = 'unprintable provider failure' + try { + detail = String(error) + } catch { + // Provider-owned rejection values cannot make ShellProcess.done reject. + } + providerFailureNote = `subprocess failed before reporting an outcome: ${detail}` this.onProcessDone(proc, providerFailureNote, true, error) }), readOutput: (): ShellProcessRead => { diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 341826fa5a..3997ac5787 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -212,6 +212,23 @@ describe('spawn construction (pure, every platform)', () => { expect(output).not.toContain('spawn failed:') expect(proc.readOutput().delta).toBe('') }) + + it('settles an unprintable provider rejection instead of rejecting done', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessRuntime(ctx) + await ctx.plugin(PwshLocalExecutor) + const providerError = new Error('unprintable provider error') + Object.defineProperty(providerError, Symbol.toPrimitive, { + value: () => { throw new Error('provider formatting must not escape') }, + }) + subprocess.done = Promise.reject(providerError) + + const proc = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' })) + await expect(proc.done).resolves.toBeUndefined() + expect(proc.status).toBe('killed') + expect(proc.readOutput().delta).toContain('unprintable provider failure') + expect(proc.readOutput().delta).toBe('') + }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 80cd06c781..7352e55623 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -156,7 +156,7 @@ interface DirectRange { } class SystemdScopeOwner implements BoundProcessOwner { - private establishment: 'pending' | 'established' | 'never-created' = 'pending' + private establishment: 'pending' | 'established' = 'pending' private stopped = false private observation: Promise | undefined private killFailure: Error | undefined @@ -229,7 +229,6 @@ class SystemdScopeOwner implements BoundProcessOwner { this.observeRequestConsumption() if (this.establishment === 'established') return false if (!this.direct.running() && existsSync(this.files.requestPath)) { - this.establishment = 'never-created' return false } if (this.killFailure !== undefined) throw this.killFailure diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index b17787a08e..b203ea4460 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -344,7 +344,13 @@ export function validateSubprocessSpec(spec: SubprocessSpawnSpec): void { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) + let reason = 'aborted' + try { + reason = String(spec.signal.reason ?? reason) + } catch { + // Arbitrary caller-owned reasons cannot escape the stable Error boundary. + } + throw new Error(`aborted before spawn: ${reason}`) } const [program] = spec.argv if (program === undefined || program.length === 0) { diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index a2ef3262c1..d6d89b53de 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -141,7 +141,6 @@ export function launchWindowsJob( const rangeExit = Promise.withResolvers() let directResultType: WindowsRunnerResult['type'] | undefined let runnerSpawned = false - let runnerNeverCreated = false const failInfrastructure = (error: unknown): void => { direct.reject(error) rangeExit.reject(error) @@ -193,7 +192,6 @@ export function launchWindowsJob( }) child.once('error', (error) => { if (!runnerSpawned) { - runnerNeverCreated = true direct.reject(error) rangeExit.resolve() return @@ -201,7 +199,7 @@ export function launchWindowsJob( failInfrastructure(error) }) child.once('close', (exitCode, signal) => { - if (runnerNeverCreated) return + if (!runnerSpawned) return const clean = exitCode === 0 && signal === null && directResultType !== undefined if (clean) { rangeExit.resolve() diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index f268fd16e2..5916790b3b 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -323,6 +323,15 @@ describe('spawnSubprocess', () => { expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) .toThrow(new Error(message)) } + + const controller = new AbortController() + controller.abort({ + [Symbol.toPrimitive]() { + throw new Error('reason formatting must not escape') + }, + }) + expect(() => { validateSubprocessSpec(spec('echo hi', { signal: controller.signal })) }) + .toThrow(new Error('aborted before spawn: aborted')) }) it('rejects with a spawn error for a nonexistent cwd', async () => { From ff6a49660fba5514f447066f687746b231e669df Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 03:26:42 +0800 Subject: [PATCH 100/110] fix(subprocess): tighten native containment settlement --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 8 +-- ...-08-28-subprocess-native-containment.zh.md | 8 +-- .../subprocess/subprocess-local/src/index.ts | 10 +-- .../subprocess-local/src/linux-scope.ts | 16 +++-- .../subprocess-local/src/runner-protocol.ts | 3 - .../subprocess-local/src/spawn-runner.ts | 43 +++-------- .../subprocess/subprocess-local/src/spawn.ts | 6 -- .../subprocess-local/src/terminal.ts | 37 +++++++--- .../subprocess-local/src/windows-job.ts | 13 ++-- .../tests/linux-scope.spec.ts | 9 ++- .../subprocess-local/tests/local.spec.ts | 20 ------ .../tests/spawn-runner.spec.ts | 71 +++---------------- .../subprocess-local/tests/terminal.spec.ts | 28 ++++++++ .../tests/windows-job.spec.ts | 20 +++--- .../cordis-inspect-jsdoc/session.jsonl | 6 +- 16 files changed, 122 insertions(+), 180 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 35dd6ab4f3..0c2b979373 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 84ddc8dd53..ad9955f91e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index fd7bbd362b..cb3f65cdb6 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.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 保留。 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index ac866e28a0..f407495ced 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/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) diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 7352e55623..98b6847db8 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/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 + sleep?: (delayMs: number, signal?: AbortSignal) => Promise } 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 { + 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, - private readonly sleep: (delayMs: number) => Promise, + private readonly sleep: (delayMs: number, signal?: AbortSignal) => Promise, ) {} signal(signal: 'SIGTERM' | 'SIGKILL'): void { @@ -300,10 +304,12 @@ class SystemdScopeOwner implements BoundProcessOwner { if (generation !== this.wakeGeneration) return const wake = Promise.withResolvers() 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, diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index 18fbc08fca..ff1e5855bc 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/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 } diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 9a3e8dffe9..1726f74f8e 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/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): 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([ + [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, ): 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 } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index b203ea4460..8dd3f3979e 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/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 } /** diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index d8330bf71a..8d165dbd20 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/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 { - return new Promise(resolve => setTimeout(resolve, ms)) +function delay(ms: number, signal?: AbortSignal): Promise { + 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(operation: Promise, ms: number, timeout: U): Promise { + 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 { 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}`) } diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index d6d89b53de..bf162ea2e7 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/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 { @@ -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', () => { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 048fbc3717..a392d35d08 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/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() - const sleep = vi.fn(async () => { + const sleep = vi.fn(async (_delayMs: number, signal?: AbortSignal) => { sleeping.resolve(undefined) - await new Promise(() => {}) + if (signal === undefined) throw new Error('missing sleep cancellation signal') + await new Promise((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?.() }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 7e695543ba..6c1bfca239 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/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' } diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 3804693140..cdd38520a8 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/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 = {}): 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() diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 97d35c1c97..7b02470f7c 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/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() @@ -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() + 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 () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index e0b9420d1a..2294ce0a43 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/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', () => { diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index 661141c6ba..202bd4190b 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,7 +26,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} @@ -36,6 +36,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[32,36]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 14c16012fb9b991a8ae8030e3c4cdcc615d57a01 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 04:07:57 +0800 Subject: [PATCH 101/110] fix(subprocess): preserve post-commit runner errors --- .../subprocess-local/src/windows-job.ts | 17 +++++++++--- .../tests/windows-job.spec.ts | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-job.ts b/packages/subprocess/subprocess-local/src/windows-job.ts index bf162ea2e7..56dac99519 100644 --- a/packages/subprocess/subprocess-local/src/windows-job.ts +++ b/packages/subprocess/subprocess-local/src/windows-job.ts @@ -11,6 +11,7 @@ import { } from '@deepseek-ai/dsh-win32-process' import type { BoundProcessOwner, ManagedProcessLaunch } from './managed-owner.ts' import { + type SerializedRunnerError, type WindowsRunnerResult, deserializeRunnerError, parseWindowsRunnerResult, @@ -39,6 +40,14 @@ type RunnerProcess = Omit, 'send' | 'stdio'> & { stdio: Array } +function isWindowsStartCancellationError(error: SerializedRunnerError): boolean { + return error.name === 'Error' + && error.message === 'subprocess target start was cancelled' + && error.code === undefined + && error.syscall === undefined + && error.path === undefined +} + /** * Re-check the runner entry, bindings, and current Job capability for every spawn. * @param internals - optional runner and Win32 capability seams used by tests. @@ -90,8 +99,10 @@ class WindowsJobOwner implements BoundProcessOwner { } } - mapStartFailure(failure: unknown): unknown { - return this.cancellationReasonSet ? this.cancellationReason : failure + mapStartFailure(failure: unknown, serialized: SerializedRunnerError): unknown { + return this.cancellationReasonSet && isWindowsStartCancellationError(serialized) + ? this.cancellationReason + : failure } async waitForExit(): Promise { @@ -168,7 +179,7 @@ export function launchWindowsJob( if (result.type === 'target-exit') { direct.resolve({ exitCode: result.exitCode, signal: null }) } else { - direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error))) + direct.reject(owner.mapStartFailure(deserializeRunnerError(result.error), result.error)) } }) child.once('spawn', () => { diff --git a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts index 2294ce0a43..19fadad5fb 100644 --- a/packages/subprocess/subprocess-local/tests/windows-job.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-job.spec.ts @@ -266,6 +266,32 @@ describe('Windows parent runner contract', () => { await expect(implicit.result.owner.waitForExit()).resolves.toBeUndefined() }) + it('preserves a strict provider error after a termination request', async () => { + const spawned = launch() + const localReason = new Error('caller aborted after target commit') + spawned.result.owner.signal('SIGTERM', localReason) + spawned.child.emit('message', { + type: 'error', + error: { + name: 'Error', + message: 'poll failed', + code: 'EIO', + syscall: 'QueryInformationJobObject', + }, + }) + + const failure = await spawned.result.direct.catch((error: unknown) => error) + expect(failure).not.toBe(localReason) + expect(failure).toMatchObject({ + message: 'poll failed', + code: 'EIO', + syscall: 'QueryInformationJobObject', + }) + spawned.child.connected = false + spawned.child.emit('close', 127, null) + await expect(spawned.result.owner.waitForExit()).rejects.toThrow('exit code 127') + }) + it('rejects direct and wait for runner error or abnormal runner exit', async () => { const failed = launch() failed.child.emit('message', { From 0652b7b4d7a2955838edab1ce35e3825c8f38606 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 06:18:21 +0800 Subject: [PATCH 102/110] fix(subprocess): close containment review gaps --- packages/e2b/subprocess-e2b/src/index.ts | 25 +++++++++++- .../subprocess-e2b/tests/subprocess.spec.ts | 34 ++++++++++++++-- .../subprocess-local/src/managed-owner.ts | 1 - .../subprocess-local/src/runner-protocol.ts | 12 ++---- .../subprocess-local/src/spawn-runner.ts | 4 +- .../subprocess/subprocess-local/src/spawn.ts | 1 - .../tests/spawn-runner.spec.ts | 24 +---------- .../subprocess-local/tests/spawn.spec.ts | 40 ------------------- 8 files changed, 61 insertions(+), 80 deletions(-) diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index bf2bd33c54..62a8802d79 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto' import { posix } from 'node:path' +import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' @@ -48,6 +49,13 @@ function requireRepresentableGrace(graceMs: number): void { } } +function validateNoNullByte(subject: string, value: string): void { + if (!value.includes('\0')) return + const error = new TypeError(`${subject} must be a string without null bytes. Received ${inspect(value)}`) + Object.assign(error, { code: 'ERR_INVALID_ARG_VALUE' }) + throw error +} + /** E2B command manager registered as `ctx.subprocess`. */ export class E2BSubprocessRuntime extends SubprocessRuntime { static inject = ['e2b'] @@ -145,7 +153,22 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { } requireRepresentableGrace(spec.graceMs) if (spec.signal?.aborted === true) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`) + let reason = 'aborted' + try { + reason = String(spec.signal.reason ?? reason) + } catch { + // Arbitrary caller-owned reasons cannot escape the stable Error boundary. + } + throw new Error(`aborted before spawn: ${reason}`) + } + spec.argv.forEach((value, index) => { + validateNoNullByte(index === 0 ? "The argument 'file'" : `The argument 'args[${String(index - 1)}]'`, value) + }) + validateNoNullByte("The property 'options.cwd'", spec.cwd) + for (const [key, value] of Object.entries(spec.env ?? {})) { + if (value === undefined) continue + validateNoNullByte(`The property 'options.env['${key}']'`, key) + validateNoNullByte(`The property 'options.env['${key}']'`, value) } const stateDir = posix.join(this.ctx.e2b.runtimeRoot, 'processes', randomUUID()) const handle = new E2BSubprocessHandle(this.ctx.e2b, spec, stateDir, this.pollMs) diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 96b4dbfb90..c1f1a3e4d7 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -1774,9 +1774,37 @@ describe('E2BSubprocessRuntime', () => { await expect(handle.done).rejects.toThrow('start failed during disposal') }) - it('validates synchronous spawn preconditions', async () => { - const { ctx } = await service() + it('validates synchronous spawn preconditions before handle or remote work', async () => { + const fake = new FakeSandbox() + const getSandbox = vi.fn(async () => fake.sandbox) + const { ctx } = await service(fake, runtime(fake, getSandbox)) + const live = (ctx.subprocess as unknown as { live: Set }).live expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/) - expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/) + expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))) + .toThrow(new Error('aborted before spawn: stop')) + expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort(null) }))) + .toThrow(new Error('aborted before spawn: aborted')) + const throwingReason = { toString: () => { throw new Error('caller reason escaped') } } + expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort(throwingReason) }))) + .toThrow(new Error('aborted before spawn: aborted')) + + for (const invalid of [ + spec({ argv: ['bash\0'] }), + spec({ argv: ['bash', 'bad\0arg'] }), + spec({ cwd: 'bad\0cwd' }), + spec({ env: { REMOVED: undefined, 'BAD\0KEY': 'value' } }), + spec({ env: { BAD: 'bad\0value' } }), + ]) { + let thrown: unknown + try { + ctx.subprocess.spawn(invalid) + } catch (error) { + thrown = error + } + expect(thrown).toMatchObject({ name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' }) + } + expect(getSandbox).not.toHaveBeenCalled() + expect(live).toEqual(new Set()) + expect(fake.directories).toEqual([]) }) }) diff --git a/packages/subprocess/subprocess-local/src/managed-owner.ts b/packages/subprocess/subprocess-local/src/managed-owner.ts index cdb915bfa6..def20470b1 100644 --- a/packages/subprocess/subprocess-local/src/managed-owner.ts +++ b/packages/subprocess/subprocess-local/src/managed-owner.ts @@ -42,7 +42,6 @@ export async function waitWithAbort(pending: Promise, signal?: AbortSignal const aborted = Promise.withResolvers() const onAbort = (): void => { aborted.resolve(false) } signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) onAbort() try { return await Promise.race([pending.then(() => true), aborted.promise]) } finally { diff --git a/packages/subprocess/subprocess-local/src/runner-protocol.ts b/packages/subprocess/subprocess-local/src/runner-protocol.ts index ff1e5855bc..90d09add3e 100644 --- a/packages/subprocess/subprocess-local/src/runner-protocol.ts +++ b/packages/subprocess/subprocess-local/src/runner-protocol.ts @@ -6,7 +6,6 @@ import { lstatSync, mkdtempSync, readFileSync, - renameSync, rmdirSync, unlinkSync, writeFileSync, @@ -14,8 +13,6 @@ import { import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join } from 'node:path' -const STARTUP_ERROR_TEMPORARY = '.startup-error.tmp' - /** Target state restored by the Linux bootstrap after systemd establishes the scope. */ export interface LinuxLaunchRequest { cwd: string @@ -31,7 +28,7 @@ export interface SerializedRunnerError { path?: string } -/** A Linux pre-exec failure published atomically beside its consumed request. */ +/** A Linux pre-exec failure published beside its consumed request. */ export type LinuxStartupError = { type: 'error'; error: SerializedRunnerError } @@ -148,14 +145,12 @@ export function consumeLinuxLaunchRequest(requestPath: string): LinuxLaunchReque } /** - * Atomically publish one strict 0600 Linux pre-exec error. + * Publish one strict 0600 Linux pre-exec error. * @param files - private paths for this launch. * @param error - bounded spawn or runner failure to publish. */ export function writeLinuxStartupError(files: LinuxLaunchFiles, error: LinuxStartupError): void { - const temporary = join(files.directory, STARTUP_ERROR_TEMPORARY) - writeFileSync(temporary, JSON.stringify(error), { flag: 'wx', mode: 0o600 }) - renameSync(temporary, files.startupErrorPath) + writeFileSync(files.startupErrorPath, JSON.stringify(error), { flag: 'wx', mode: 0o600 }) } /** @@ -262,7 +257,6 @@ export function cleanupLinuxLaunchFiles(files: LinuxLaunchFiles): void { for (const path of [ files.requestPath, files.startupErrorPath, - join(files.directory, STARTUP_ERROR_TEMPORARY), ]) { try { unlinkSync(path) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 1726f74f8e..7b4afaf57f 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -70,6 +70,7 @@ const NODE_SPAWN_DETAIL_CODES = new Set(['EACCES', 'ENOENT']) const WINDOWS_SPAWN_ERROR_CODES = new Map([ [2, 'ENOENT'], [3, 'ENOENT'], + [267, 'ENOENT'], [5, 'EPERM'], [193, 'EFTYPE'], [740, 'EACCES'], @@ -315,9 +316,6 @@ class WindowsJobRunner { for (const fileDescriptor of [4, 5, 6]) { this.internals.closeFileDescriptor(fileDescriptor) } - // Descriptor cleanup may synchronously re-enter the IPC handler. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (this.terminateRequested) this.terminateOwnedJob() this.pollTimer = setInterval(() => { this.poll() }, 10) } catch (error) { if (this.jobHandle === undefined && error instanceof Win32Error && error.api === 'CreateProcessW') { diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8dd3f3979e..c18a0c0c50 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -546,7 +546,6 @@ export function bindManagedProcess( // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { terminateWithReason(spec.signal?.reason) } spec.signal?.addEventListener('abort', onAbort, { once: true }) - if (spec.signal?.aborted === true) onAbort() // Batch stdin is written and closed up front; process exit and captured // output remain authoritative, so write errors (EPIPE) are best-effort. diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index cdd38520a8..86a5c39ebf 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -153,7 +153,6 @@ describe('closed runner protocol', () => { expect(deserializeRunnerError(result!.error)).toMatchObject({ name: 'SpawnError', message: 'spawn missing', code: 'ENOENT', syscall: 'spawn tool', path: 'tool', }) - writeFileSync(join(files.directory, '.startup-error.tmp'), 'incomplete') cleanupLinuxLaunchFiles(files) expect(existsSync(files.directory)).toBe(false) }) @@ -607,6 +606,7 @@ describe('Windows Job runner protocol owner', () => { for (const [win32Code, code, enriched, program] of [ [2, 'ENOENT', true, 'tool.exe'], [3, 'ENOENT', true, 'tool.exe'], + [267, 'ENOENT', true, 'tool.exe'], [740, 'EACCES', true, '$&.exe'], [5, 'EPERM', false, 'tool.exe'], [193, 'EFTYPE', false, 'tool.exe'], @@ -810,27 +810,7 @@ describe('Windows Job runner protocol owner', () => { expect(sendFailureHost.exitCode).toBe(127) }) - it('handles commit-time termination reentrancy and termination failure', async () => { - const reentrantHost = new FakeRunnerHost() - const reentrant = internals({ - closeFileDescriptor: vi.fn((fileDescriptor) => { - if (fileDescriptor === 4) reentrantHost.emit('message', { type: 'terminate' }) - }), - pollProcessExit: vi.fn(() => undefined), - isJobEmpty: vi.fn(() => false), - }) - const reentrantRun = runSpawnRunner( - WINDOWS_RUNNER_SELECTION, - ['--', 'tool.exe'], - hostArgument(reentrantHost), - reentrant, - ) - reentrantHost.emit('message', { type: 'start', cwd: 'C:\\target', env: {} }) - await new Promise((resolveImmediate) => { setImmediate(resolveImmediate) }) - expect(reentrant.terminateJob).toHaveBeenCalledTimes(2) - reentrantHost.disconnect() - await reentrantRun - + it('reports a post-commit termination failure', async () => { const failedHost = new FakeRunnerHost() const failed = internals({ pollProcessExit: vi.fn(() => undefined), diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 5916790b3b..c46851e182 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -952,31 +952,6 @@ describe('coverage seams', () => { } }) - it('delivers an already-aborted managed spawn reason before target settlement', async () => { - const reason = null - const controller = new AbortController() - controller.abort(reason) - const signal = vi.fn() - const handle = bindManagedProcess(spec('true', { - signal: controller.signal, - stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, - }), { - stdin: null, - stdout: null, - stderr: null, - direct: Promise.resolve({ exitCode: 0, signal: null }), - owner: { - signal, - waitForExit: async () => {}, - terminateForHostExit: vi.fn(), - }, - }) - - expect(signal).toHaveBeenCalledExactlyOnceWith('SIGTERM', reason) - await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) - await expect(handle.waitForExit()).resolves.toBe(true) - }) - it('contains a late wait rejection after an already-aborted observation', async () => { const pending = Promise.withResolvers() await expect(waitWithAbort(pending.promise, AbortSignal.abort())).resolves.toBe(false) @@ -984,21 +959,6 @@ describe('coverage seams', () => { await Promise.resolve() }) - it('closes the abort-listener registration race', async () => { - let aborted = false - const removeEventListener = vi.fn() - const signal = { - get aborted() { return aborted }, - addEventListener(_type: string, listener: () => void) { - aborted = true - listener() - }, - removeEventListener, - } as unknown as AbortSignal - await expect(waitWithAbort(new Promise(() => {}), signal)).resolves.toBe(false) - expect(removeEventListener).toHaveBeenCalledOnce() - }) - it('taskkillProcessTree ignores an unpublished pid and contains a missing binary', () => { expect(() => { taskkillProcessTree(undefined) }).not.toThrow() // On POSIX there is no taskkill; spawnSync reports the failure in its From 29c68b6da90af0b00d2315931741c08df668c6ce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 07:03:00 +0800 Subject: [PATCH 103/110] docs(subprocess): sync native containment evidence --- .../2026-08-28-subprocess-native-containment.i18n.yaml | 4 ++-- .../architecture/2026-08-28-subprocess-native-containment.md | 2 +- .../2026-08-28-subprocess-native-containment.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 0c2b979373..cfb6233913 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: ad9955f91ec00fc89e975d64ef99e37bcf5c4288 -2026-08-28-subprocess-native-containment.zh.md: cb3f65cdb65e77cae2d31cd337965acf0cd5c5e9 +2026-08-28-subprocess-native-containment.md: 108ace2d42cbc7b5286818b420aef584dc3b515a +2026-08-28-subprocess-native-containment.zh.md: e945d3160cfe543736846758365cbdd0cb605c52 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index ad9955f91e..108ace2d42 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -55,7 +55,7 @@ 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 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. +- 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`/`267` 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index cb3f65cdb6..e945d3160c 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -55,7 +55,7 @@ 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`、带未胜出 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。 +- Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `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 保留。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 522f3d6d37..1c81bb04ef 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 93ad88c956ffaa2e244fc3cfc32c2c955807a95d -config-catalog.zh.md: bbe940e571130d0f25ffb8d14572222b256a05d2 +config-catalog.md: 9e8c44cb8cb19066dcb815dcb98da6e7bcfb0ff4 +config-catalog.zh.md: 76f4f5f8c333ba4a254a41067f29e0eac2317df8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 93ad88c956..9e8c44cb8c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2486,7 +2486,7 @@ export interface Config { } ``` -Source: [`packages/e2b/subprocess-e2b/src/index.ts:25`](../packages/e2b/subprocess-e2b/src/index.ts) +Source: [`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bbe940e571..76f4f5f8c3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2488,7 +2488,7 @@ export interface Config { } ``` -来源:[`packages/e2b/subprocess-e2b/src/index.ts:25`](../packages/e2b/subprocess-e2b/src/index.ts) +来源:[`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts) From ed25d4e15bb28cca5c8905befaabd85518b3861e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 08:58:31 +0800 Subject: [PATCH 104/110] docs(subprocess): correct probe and terminal failure contracts --- packages/subprocess/subprocess-local/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess-local/README.md | 2 +- packages/subprocess/subprocess-local/README.zh.md | 2 +- packages/subprocess/subprocess/src/types.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index f10e06c551..a5bd5e8d65 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.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 packages/subprocess/subprocess-local/README.md -README.md: 0034a8038b6650c8e8b030c92808ca3bcec4e030 -README.zh.md: 213af475fb0c27736f01f056b333f81e8624f007 +README.md: 7deec3a05bd4b1480ef9bea5dac670a49928b262 +README.zh.md: eefa9bcb9e19f42fc475f799fb8ec22c05a97a17 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 0034a8038b..7deec3a05b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -130,7 +130,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. - **Native ownership has explicit host requirements** — Linux needs a readable user manager and `systemd-run --expand-environment=no`; older systemd versions use the warned PGID fallback. macOS always uses that fallback because no supported public persistent owner exists. -- **Native selection has bounded per-spawn costs** — Linux rechecks the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope support before every eligible ordinary or terminal spawn; Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Only the fallback warning is retained across spawns. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. +- **Native selection has bounded per-spawn costs** — Linux repeats the bootstrap entry, libc `execve`/`fcntl` bindings, live user manager, and literal-argv scope probe until it first succeeds; later eligible ordinary or terminal spawns recheck only the live user manager. Windows rechecks the runner entry, bindings, and current Job support before every ordinary spawn. Successful Linux deep-probe state and fallback-warning de-duplication persist for the provider lifetime. All probes finish before the user command can run, and child-process probes have a 5-second timeout. Each Linux launch creates a private request directory, checks unresolved scope establishment every 50 milliseconds, then exponentially backs off an established active scope to at most 5 seconds between queries; a Windows ordinary launch keeps one runner and IPC channel until the Job reports zero active processes. Target standard handles are inherited directly, with no named-pipe stdio or result files. - **Windows Job inheritance has defined exclusions** — ordinary descendants inherit the Job by default, but breakaway processes are outside the guarantee. The target starts only after Job assignment; external termination of the runner in the narrow create-to-assignment interval can leave a suspended target behind. - **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **Fallback terminal ownership remains observational** — on macOS or Linux without usable user-systemd, a child that reparents before any foreground-inspection snapshot or leaves the owned terminal session can escape the process-table scan. The local provider does not add a continuous process-table monitor; supported Linux native mode instead retains these descendants through scope membership. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 213af475fb..eefa9bcb9e 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -130,7 +130,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。 - **native ownership 有明确宿主要求**——Linux 需要可读的 user manager 与 `systemd-run --expand-environment=no`;旧版 systemd 使用带告警的 PGID fallback。macOS 因没有受支持的公开 persistent owner,始终使用该 fallback。 -- **native 选择具有有界的每次 spawn 成本**——Linux 会在每次符合条件的普通命令或终端 spawn 前重新检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持;Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。跨 spawn 只保留 fallback 告警。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 +- **native 选择具有有界的每次 spawn 成本**——Linux 会重复检查 bootstrap 入口、libc `execve`/`fcntl` bindings、存活的 user manager 与 literal-argv scope 支持,直到这套完整探测首次成功;后续符合条件的普通命令或终端 spawn 只重新检查存活的 user manager。Windows 会在每次普通 spawn 前重新检查 runner 入口、bindings 与当前 Job 支持。Linux 深度探测的成功状态与 fallback 告警去重会在 provider 生命周期内持续保留。所有探测都会在用户命令可能运行前完成,子进程探测的超时为 5 秒。每次 Linux 启动都会创建私有请求目录,以 50 毫秒间隔检查尚未确定的 scope 建立状态;scope 已建立且仍 active 后,查询间隔按指数增长,最多为 5 秒。Windows 普通命令会保留一个 runner 与一条 IPC 通道,直到 Job 报告活动进程数为零。目标会直接继承标准句柄,不使用 named-pipe stdio 或结果文件。 - **Windows Job inheritance 有明确排除项**——普通后代默认继承 Job,但 breakaway 进程不在保证范围。目标只在 Job 分配后启动;runner 若在 create-to-assignment 极窄区间遭外力终止,可能留下 suspended target。 - **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默与计时档覆盖。 - **fallback 终端 ownership 仍依赖观察**——在 macOS 或缺少可用 user-systemd 的 Linux 上,子进程如果在任何前台检查快照之前重新设定父进程,或离开自有终端 session,就可能逃出进程表扫描。本地提供方不会新增持续进程表监视器;受支持的 Linux native 模式改由 scope membership 持有这些后代。 diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index a7a9ceebdf..8f07e05fa9 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -235,7 +235,7 @@ export interface SubprocessTerminalHandle { readonly pid: number /** UTF-8 terminal output bytes in delivery order; ends after queued output when the terminal exits. */ readonly output: Readable - /** Resolves when the top-level process exits; rejects only for a live transport failure. */ + /** Resolves when the top-level process exits; rejects for a terminal startup, provider, or live transport failure. */ readonly done: Promise /** * Write text to the terminal input. From c8393a2798b00ab8eca772034a9144fd6e8f60ee Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 09:12:29 +0800 Subject: [PATCH 105/110] test(pwsh): cover background outcome status mapping --- .../shell/pwsh-local/tests/executor.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 3997ac5787..7f74071c66 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -229,6 +229,33 @@ describe('spawn construction (pure, every platform)', () => { expect(proc.readOutput().delta).toContain('unprintable provider failure') expect(proc.readOutput().delta).toBe('') }) + + it('preserves an explicit kill stamp and maps an aborted direct outcome to killed', async () => { + const ctx = new Context() + const subprocess = new CapturingSubprocessRuntime(ctx) + await ctx.plugin(PwshLocalExecutor) + + const killedOutcome = Promise.withResolvers() + subprocess.done = killedOutcome.promise + const killed = ctx.shell.start(ctx.shell.resolve({ command: 'Write-Output maybe-ran' })) + expect(killed.kill()).toBe(true) + killedOutcome.resolve({ exitCode: 0, signal: null }) + await killed.done + expect(killed.status).toBe('killed') + expect(killed.exitCode).toBe(0) + + const abortedOutcome = Promise.withResolvers() + subprocess.done = abortedOutcome.promise + const controller = new AbortController() + const aborted = ctx.shell.start(ctx.shell.resolve({ + command: 'Write-Output maybe-ran', + signal: controller.signal, + })) + controller.abort() + abortedOutcome.resolve({ exitCode: 0, signal: null }) + await aborted.done + expect(aborted.status).toBe('killed') + }) }) describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { From e5dbccce8c2122fd8ec24b1cb809847a40e557fb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 19:59:26 +0800 Subject: [PATCH 106/110] fix(subprocess): move SEA bootstrap to runtime packaging --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 2 +- ...8-22-single-dsh-application-launcher.zh.md | 2 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 2 +- ...-08-28-subprocess-native-containment.zh.md | 2 +- apps/cli/package.json | 2 +- apps/cli/src/bin.ts | 63 +++++++++++-------- apps/cli/tsconfig.json | 3 - apps/cli/tsdown.config.ts | 10 ++- pnpm-lock.yaml | 6 +- .../sdk-runtime/runtime-bootstrap.mjs | 9 +-- scripts/build-exe-for-python-sdk.spec.ts | 29 ++++++++- scripts/build-exe-for-python-sdk.ts | 25 +++++++- .../verify-application-entrypoints.spec.ts | 9 +++ scripts/verify-application-entrypoints.ts | 5 +- 22 files changed, 127 insertions(+), 66 deletions(-) rename apps/cli/src/runtime-bootstrap.ts => python/sdk-runtime/runtime-bootstrap.mjs (63%) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 16f0c9fc87..aa877ef599 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: f1e8d6ce56d4d8eb49ec0fd7218885234778f9f0 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 852dcedeae11e253b23b57efbd6526309d9fcdaf +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 6ab54fe40007808cc8b9a3c89061738df647a6dd +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2dde9b51c456b234b628f0460e54816b9fd32faf diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index f1e8d6ce56..6ab54fe400 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The packaging-owned bootstrap imports the public CLI for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → stage [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 852dcedeae..2dde9b51c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。由打包层拥有的 bootstrap 在普通启动时导入公开 CLI,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为载体根目录的 `runtime-bootstrap.mjs`,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 6d05137fba..3c8447434f 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 352b6076191b03493f75df469b7f7fd8e3df098a -2026-08-22-single-dsh-application-launcher.zh.md: ae3b8c1984f973560a083bc046d868058db8d9ef +2026-08-22-single-dsh-application-launcher.md: 66749e2e23e638243e9238e4fa7012199a425e97 +2026-08-22-single-dsh-application-launcher.zh.md: 6948a80b2c8c03755eec3bd97fd70d6d96cea213 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 352b607619..66749e2e23 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel packages `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` through the private `dsh-python-runtime-closure` deploy manifest. Its ordinary branch imports the public CLI; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. +The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index ae3b8c1984..6948a80b2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest 打包 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js`。其普通分支导入公开 CLI;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 +Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个载体。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index d649a92344..30b0013c24 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: e6dbe4c5a81093201edfb476a8f1b9594a923903 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 888fcb097d592d295df88c56f11cca3e4dec81c2 +2026-08-23-python-sdk-dsh-profile-runtime.md: 89aa19bfbf20ceb9a5b82cbca450df8057e28a81 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9095831952656087dea6cbfb3f2158cf3e6623be diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index e6dbe4c5a8..89aa19bfbf 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe ### Executable packaging -The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. That packaging-owned bootstrap imports the ordinary public CLI when no private selection is present; for a selected subprocess runner it consumes the one private environment value and enters `@deepseek-ai/dsh-subprocess-local/runner` without parsing a hidden CLI argument or adding a second Node executable. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch and runner protocol. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. +The zero-code deployment manifest is `dsh-python-runtime-closure`. The build stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) with profile, bundle, preset, native-addon, and shared-library assets in `deepseek-harness-sdk-runtime--`. That Python runtime-owned bootstrap calls the ordinary public CLI export when no private selection is present; for a selected subprocess runner it consumes the one private environment value and enters `@deepseek-ai/dsh-subprocess-local/runner` without parsing a hidden CLI argument or adding a second Node executable. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch and runner protocol. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 888fcb097d..9095831952 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 可执行程序打包 -零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。没有私有选择值时,这个由打包层拥有的 bootstrap 会导入普通公共 CLI;选择了 subprocess runner 时,它会消费唯一的私有环境值并进入 `@deepseek-ai/dsh-subprocess-local/runner`,不解析隐藏 CLI 参数,也不增加第二个 Node 可执行程序。[原生收容决策](2026-08-28-subprocess-native-containment.zh.md)拥有这项私有分派与 runner 协议。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 +零代码部署 manifest 是 `dsh-python-runtime-closure`。构建流程将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 连同 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。没有私有选择值时,这个由 Python runtime 拥有的 bootstrap 会调用普通公共 CLI export;选择了 subprocess runner 时,它会消费唯一的私有环境值并进入 `@deepseek-ai/dsh-subprocess-local/runner`,不解析隐藏 CLI 参数,也不增加第二个 Node 可执行程序。[原生收容决策](2026-08-28-subprocess-native-containment.zh.md)拥有这项私有分派与 runner 协议。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index cfb6233913..d8cb5bd69d 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.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: 108ace2d42cbc7b5286818b420aef584dc3b515a -2026-08-28-subprocess-native-containment.zh.md: e945d3160cfe543736846758365cbdd0cb605c52 +2026-08-28-subprocess-native-containment.md: 078665c5c9067ff73b2ab25314cb3d430246b695 +2026-08-28-subprocess-native-containment.zh.md: 91ed7a34140172a1b56ab35bb71743d98208cf19 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 108ace2d42..078665c5c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -36,7 +36,7 @@ The parent permanently latches a validated numeric `target-exit` as soon as it a ### Private dispatch and protocol -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. +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 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs). The Python runtime owns that bootstrap. It calls the public CLI export 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, `apps/cli` builds only its public entry, 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 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. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index e945d3160c..91ed7a3414 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -36,7 +36,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 可执行程序。 +source 启动通过 TypeScript source launcher 执行包内 runner 入口,built 启动解析 `@deepseek-ai/dsh-subprocess-local/runner` export,Python SDK 单文件可执行程序则从 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 进入。该 bootstrap 由 Python runtime 拥有;私有 selector 不存在时,它调用公共 CLI export;否则会删除 selector,并分派到同一 subprocess runner core。公共 `dsh` 参数解析器没有隐藏 runner mode,`apps/cli` 只构建公共入口,打包也不提供第二个 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` record。取消 reason 不跨 wire 传递,因此 parent cancellation latch 会原样恢复第一个本地 reason,包括 `null` 或 `undefined`。缺失、额外、类型错误或未知字段都会 fail closed。target 环境可以包含 selector 名称及其 Windows 大小写变体,因为 provider 会单独传递 target 状态,并且只在私有选择值消费后才恢复该状态。 diff --git a/apps/cli/package.json b/apps/cli/package.json index e496d70071..9a17e1dcfb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -67,7 +67,6 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", @@ -136,6 +135,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 321849f2d9..9659f76861 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -7,7 +7,8 @@ /* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */ import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' @@ -21,30 +22,42 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } -const invocation = parseDshArgs(process.argv.slice(2), readVersion()) +/** + * Run the public dsh command-line interface. + * @param argv - user arguments after the executable name. + * @returns a promise that settles when the selected command mode finishes. + */ +export async function runCli(argv: string[] = process.argv.slice(2)): Promise { + const invocation = parseDshArgs(argv, readVersion()) -switch (invocation.mode) { - case 'profile': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - args: invocation.args, - }) - break + switch (invocation.mode) { + case 'profile': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + args: invocation.args, + }) + break + } + case 'plugin': { + const { runPlugin } = await import('./plugin.ts') + process.exit(runPlugin(invocation.profile, invocation.args)) + break + } + case 'dump-config': { + const { runDumpConfig } = await import('./dump-config.ts') + runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + break + } + default: + invocation satisfies never + throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) } - case 'plugin': { - const { runPlugin } = await import('./plugin.ts') - process.exit(runPlugin(invocation.profile, invocation.args)) - break - } - case 'dump-config': { - const { runDumpConfig } = await import('./dump-config.ts') - runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) - break - } - default: - invocation satisfies never - throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) +} + +const invokedPath = process.argv[1] +if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) { + await runCli() } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 80522294c7..7b0a769721 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -70,9 +70,6 @@ }, { "path": "../../packages/shell/tool-bash" - }, - { - "path": "../../packages/subprocess/subprocess-local" } ] } diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index 4fb14b0646..51dec0dc6c 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -1,15 +1,13 @@ import { defineConfig } from 'tsdown' /** - * The public package bin remains `bin`; `runtime-bootstrap` is selected only - * by the Python single-file packaging pipeline. + * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`. + * The root tsdown builds only `lib/types/index.js`, so this override points at + * `lib/types/bin.js` instead; its reachable mode modules bundle with it. * Declarations come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ - entry: { - bin: 'lib/types/bin.js', - 'runtime-bootstrap': 'lib/types/runtime-bootstrap.js', - }, + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c85328a215..a726e4225f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,9 +244,6 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-terminal': specifier: workspace:^ version: link:../../packages/terminal/terminal @@ -446,6 +443,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn-in-process': specifier: workspace:^ version: link:../../packages/subagent/subagent-spawn-in-process + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt diff --git a/apps/cli/src/runtime-bootstrap.ts b/python/sdk-runtime/runtime-bootstrap.mjs similarity index 63% rename from apps/cli/src/runtime-bootstrap.ts rename to python/sdk-runtime/runtime-bootstrap.mjs index b45fc89f2e..bc2fbede75 100644 --- a/apps/cli/src/runtime-bootstrap.ts +++ b/python/sdk-runtime/runtime-bootstrap.mjs @@ -1,15 +1,12 @@ #!/usr/bin/env node -/** Packaging-only entry that keeps private runner dispatch outside the public CLI. */ - -/* v8 ignore file -- packaged-runtime smoke exercises this physical entry. */ +/** Private entry owned by the Python single-file runtime packaging. */ const selectorName = 'DSH_SUBPROCESS_RUNNER' const selection = process.env[selectorName] -export {} - if (selection === undefined) { - await import('./bin.ts') + const { runCli } = await import('@deepseek-ai/dsh/lib/bin.js') + await runCli() } else { Reflect.deleteProperty(process.env, selectorName) const { runSelectedSubprocessRunner } = await import('@deepseek-ai/dsh-subprocess-local/runner') diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 06c3f7fce3..0b2829d472 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -23,6 +23,31 @@ function run(env: NodeJS.ProcessEnv, ...args: string[]) { } describe('Python runtime executable builder CLI', () => { + it('keeps the single-file dispatcher on the Python packaging surface', () => { + const bootstrapPath = resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs') + const bootstrap = readFileSync(bootstrapPath, 'utf8') + const cliConfig = readFileSync(resolve(root, 'apps/cli/tsdown.config.ts'), 'utf8') + const cliTsconfig = readFileSync(resolve(root, 'apps/cli/tsconfig.json'), 'utf8') + const cliManifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as { + dependencies?: Record + devDependencies?: Record + } + const runtimeManifest = JSON.parse(readFileSync(resolve(root, 'python/sdk-runtime/package.json'), 'utf8')) as { + dependencies?: Record + } + + expect(existsSync(resolve(root, 'apps/cli/src/runtime-bootstrap.ts'))).toBe(false) + expect(cliConfig).not.toContain('runtime-bootstrap') + expect(cliTsconfig).not.toContain('packages/subprocess/subprocess-local') + expect(cliManifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subprocess-local') + expect(cliManifest.devDependencies).toHaveProperty('@deepseek-ai/dsh-subprocess-local') + expect(runtimeManifest.dependencies).toHaveProperty('@deepseek-ai/dsh-subprocess-local') + expect(bootstrap).toContain("import('@deepseek-ai/dsh/lib/bin.js')") + expect(bootstrap).toContain('await runCli()') + expect(bootstrap).toContain("import('@deepseek-ai/dsh-subprocess-local/runner')") + expect(bootstrap).toContain('await runSelectedSubprocessRunner(selection)') + }) + it('runs pnpm through its JavaScript entrypoint without a command shell', () => { const result = run( { npm_execpath: 'C:\\tools\\pnpm.cjs' }, @@ -34,6 +59,8 @@ describe('Python runtime executable builder CLI', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + expect(result.stdout).toContain('python/sdk-runtime/runtime-bootstrap.mjs') + expect(result.stdout).toContain('runtime/node/runtime-bootstrap.mjs') expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`) expect(result.stdout).not.toMatch(/pnpm\.cmd/i) }) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f26c4c8e91..c451e3ce00 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -17,8 +17,10 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure' -/** The sole application launcher inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/runtime-bootstrap.js' +/** The Python runtime-owned source staged as the single-file entry. */ +const ENTRY_SOURCE = 'python/sdk-runtime/runtime-bootstrap.mjs' +/** The sole executable entry inside the deployed closure. */ +const ENTRY_BIN = 'runtime-bootstrap.mjs' /** Python-visible executable basename. */ const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime' /** Default Node major; SEA mode requires at least Node 22. */ @@ -306,6 +308,22 @@ class SingleExeBuild { } } + /** Copy the Python runtime-owned dispatcher into the deployed closure root. */ + async stageRuntimeBootstrap(): Promise { + const source = resolve(root, ENTRY_SOURCE) + const destination = join(this.staging, ENTRY_BIN) + if (!existsSync(source)) { + throw new Error(`build-exe-for-python-sdk: packaging bootstrap is missing at ${source}.`) + } + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return + } + await copyFile(source, destination) + await chmod(destination, 0o755) + console.log(`build-exe-for-python-sdk: staged ${destination}`) + } + /** * Restore direct packages that pnpm's legacy hoister places beside the deploy * source instead of in the target. The runtime manifest supplies every peer, @@ -406,7 +424,7 @@ class SingleExeBuild { throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`) } if (!existsSync(join(this.staging, ENTRY_BIN))) { - throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`) + throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`) } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`) @@ -613,6 +631,7 @@ async function main(): Promise { await pipeline.verifyClosure() await pipeline.build() await pipeline.deployStaging() + await pipeline.stageRuntimeBootstrap() await pipeline.injectPkgConfig() const products: string[] = [] for (const target of cli.targets) products.push(...await pipeline.pack(target)) diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index a03240eb1c..0c84043dfc 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -74,6 +74,15 @@ describe('application entrypoints', () => { ]) }) + it('rejects a packaging dispatcher owned by the CLI workspace', () => { + const root = fixture() + write(root, 'apps/cli/src/runtime-bootstrap.ts', '#!/usr/bin/env node\n') + + expect(applicationEntrypointViolations(root)).toEqual([ + 'apps/cli/src/runtime-bootstrap.ts: executable source has no application/build/test classification', + ]) + }) + it('rejects a private Python application carrier outside dsh', () => { const root = fixture() write(root, 'packages/sdk/rogue-python-runtime/package.json', JSON.stringify({ private: true })) diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index dbc63f5870..1ec5342b4b 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -29,10 +29,9 @@ const MANIFEST_BIN_ALLOWLIST = new Map([ ['packages/experimental/webworker-packer/package.json', { 'dsh-pack-vfs-image': './bin.js' }], ]) -/** Every executable in a Node application workspace has one explicit role. */ +/** Every JavaScript executable in an application or packaging workspace has one explicit role. */ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['apps/cli/src/bin.ts', 'supported dsh application launcher'], - ['apps/cli/src/runtime-bootstrap.ts', 'private packaging-only runtime dispatcher'], ['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'], ['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'], ['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'], @@ -45,6 +44,7 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/driver.ts', 'test-only subprocess driver'], ['packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts', 'test-only subprocess driver'], ['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'], + ['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'], ]) /** Root demos are application wrappers and therefore must visibly select dsh. */ @@ -66,6 +66,7 @@ const SOURCE_PATTERNS = [ 'packages/**/*.js', 'packages/**/*.mjs', 'packages/**/*.cjs', + 'python/sdk-runtime/*.mjs', ] const SOURCE_EXCLUDES = [ From 423412b7bf4b31054a102b1bc8f889f2f017ea94 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 3 Sep 2026 21:18:27 +0800 Subject: [PATCH 107/110] fix(runtime): simplify executable entry dispatch --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 ++-- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- apps/cli/src/bin.ts | 11 ++++------ apps/cli/tests/built-bin.e2e.ts | 21 ++++++++++++++++++- apps/cli/tsdown.config.ts | 2 +- .../subprocess/subprocess-local/src/bin.ts | 17 +++------------ scripts/build-exe-for-python-sdk.spec.ts | 5 +++-- scripts/build-exe-for-python-sdk.ts | 19 ----------------- 9 files changed, 35 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index aa877ef599..4ee681f5f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 6ab54fe40007808cc8b9a3c89061738df647a6dd -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2dde9b51c456b234b628f0460e54816b9fd32faf +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 9c7f48607c15841515c1aa26e5569799ec6ef847 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2e12a0495e231dd4bbc7d54e3a49c4bf04be6296 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 6ab54fe400..9c7f48607c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → stage [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 2dde9b51c4..2e12a0495e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为载体根目录的 `runtime-bootstrap.mjs`,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 9659f76861..20038abb5b 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -7,8 +7,7 @@ /* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */ import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { fileURLToPath } from 'node:url' import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' @@ -24,11 +23,10 @@ function readVersion(): string { /** * Run the public dsh command-line interface. - * @param argv - user arguments after the executable name. * @returns a promise that settles when the selected command mode finishes. */ -export async function runCli(argv: string[] = process.argv.slice(2)): Promise { - const invocation = parseDshArgs(argv, readVersion()) +export async function runCli(): Promise { + const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { @@ -57,7 +55,6 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise { + const installation = mkdtempSync(join(tmpdir(), 'dsh-bin-link-')) + const installedBin = join(installation, 'dsh') + symlinkSync(dshBin, installedBin) + try { + const result = await execa(process.execPath, [installedBin, '--version'], { + input: '', + timeout: SPAWN_TIMEOUT_MS, + killSignal: 'SIGKILL', + reject: false, + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe(cliVersion) + expect(result.stderr).toBe('') + } finally { + rmSync(installation, { recursive: true, force: true }) + } + }) + it('fails loud on a nonexistent profile with the plugin-command hint', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-')) try { diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index 51dec0dc6c..cfb50e6af4 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -14,5 +14,5 @@ export default defineConfig({ target: 'es2024', fixedExtension: false, dts: false, - clean: false, + clean: ['lib/*.js'], }) diff --git a/packages/subprocess/subprocess-local/src/bin.ts b/packages/subprocess/subprocess-local/src/bin.ts index bc72389109..2ea28d6df1 100644 --- a/packages/subprocess/subprocess-local/src/bin.ts +++ b/packages/subprocess/subprocess-local/src/bin.ts @@ -1,32 +1,21 @@ /** Thin executable/importable entry for the provider-private runner core. */ -import { resolve } from 'node:path' -import { pathToFileURL } from 'node:url' import { consumeRunnerSelection } from './runner-launch.ts' import { reportSpawnRunnerFailure, runSpawnRunner } from './spawn-runner.ts' /** * Run a selector already removed by a packaging bootstrap. * @param selection - private runner selector or Linux launch-request locator. - * @param argv - private runner arguments beginning with the target delimiter. */ -export async function runSelectedSubprocessRunner( - selection: string, - argv: readonly string[] = process.argv.slice(2), -): Promise { +export async function runSelectedSubprocessRunner(selection: string): Promise { try { - await runSpawnRunner(selection, argv) + await runSpawnRunner(selection, process.argv.slice(2)) } catch (error) { await reportSpawnRunnerFailure(selection, error) } } -function isExecutedEntry(): boolean { - const entry = process.argv[1] - return entry !== undefined && pathToFileURL(resolve(entry)).href === import.meta.url -} - -if (isExecutedEntry()) { +if (import.meta.main) { const selection = consumeRunnerSelection() if (selection === undefined) { process.exitCode = 127 diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 0b2829d472..5f512a7703 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -38,6 +38,7 @@ describe('Python runtime executable builder CLI', () => { expect(existsSync(resolve(root, 'apps/cli/src/runtime-bootstrap.ts'))).toBe(false) expect(cliConfig).not.toContain('runtime-bootstrap') + expect(cliConfig).toContain("clean: ['lib/*.js']") expect(cliTsconfig).not.toContain('packages/subprocess/subprocess-local') expect(cliManifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subprocess-local') expect(cliManifest.devDependencies).toHaveProperty('@deepseek-ai/dsh-subprocess-local') @@ -59,8 +60,8 @@ describe('Python runtime executable builder CLI', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) - expect(result.stdout).toContain('python/sdk-runtime/runtime-bootstrap.mjs') - expect(result.stdout).toContain('runtime/node/runtime-bootstrap.mjs') + expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')) + expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"') expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`) expect(result.stdout).not.toMatch(/pnpm\.cmd/i) }) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index c451e3ce00..f950cdaa1d 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -17,8 +17,6 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure' -/** The Python runtime-owned source staged as the single-file entry. */ -const ENTRY_SOURCE = 'python/sdk-runtime/runtime-bootstrap.mjs' /** The sole executable entry inside the deployed closure. */ const ENTRY_BIN = 'runtime-bootstrap.mjs' /** Python-visible executable basename. */ @@ -308,22 +306,6 @@ class SingleExeBuild { } } - /** Copy the Python runtime-owned dispatcher into the deployed closure root. */ - async stageRuntimeBootstrap(): Promise { - const source = resolve(root, ENTRY_SOURCE) - const destination = join(this.staging, ENTRY_BIN) - if (!existsSync(source)) { - throw new Error(`build-exe-for-python-sdk: packaging bootstrap is missing at ${source}.`) - } - if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) - return - } - await copyFile(source, destination) - await chmod(destination, 0o755) - console.log(`build-exe-for-python-sdk: staged ${destination}`) - } - /** * Restore direct packages that pnpm's legacy hoister places beside the deploy * source instead of in the target. The runtime manifest supplies every peer, @@ -631,7 +613,6 @@ async function main(): Promise { await pipeline.verifyClosure() await pipeline.build() await pipeline.deployStaging() - await pipeline.stageRuntimeBootstrap() await pipeline.injectPkgConfig() const products: string[] = [] for (const target of cli.targets) products.push(...await pipeline.pack(target)) From d82fd86c5c949f23e9c278eb3d072da81ef4281b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 4 Sep 2026 00:24:52 +0800 Subject: [PATCH 108/110] fix(subprocess): isolate native runner bootstrap --- .../subprocess-local/src/runner-launch.ts | 13 ++++++-- .../subprocess-local/src/spawn-runner.ts | 2 +- .../tests/native-containment.spec.ts | 19 ++++++++---- .../tests/spawn-runner.spec.ts | 30 ++++++++++++++++++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/runner-launch.ts b/packages/subprocess/subprocess-local/src/runner-launch.ts index 7e199eabe5..0da6b95813 100644 --- a/packages/subprocess/subprocess-local/src/runner-launch.ts +++ b/packages/subprocess/subprocess-local/src/runner-launch.ts @@ -18,6 +18,7 @@ export const WINDOWS_RUNNER_SELECTION = 'windows' as const export type RunnerInvocation = [string, ...string[]] const SOURCE_TSCONFIG_PATH = fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url)) +const RUNNER_CONTROL_ENV_PREFIXES = ['NODE_', 'TSX_'] as const /** * Resolve the source, built, or packaged entry that calls the same runner core. @@ -67,11 +68,19 @@ export function runnerEnvironment( invocation?: RunnerInvocation, ): NodeJS.ProcessEnv { const entry = invocation?.at(-1) - return childEnv({ + const env = childEnv() + for (const name of Object.keys(env)) { + const normalized = name.toUpperCase() + if (RUNNER_CONTROL_ENV_PREFIXES.some(prefix => normalized.startsWith(prefix))) { + Reflect.deleteProperty(env, name) + } + } + return { + ...env, [SUBPROCESS_RUNNER_ENV]: selection, SYSTEMD_LOG_TARGET: 'null', ...entry?.endsWith('.ts') === true ? { TSX_TSCONFIG_PATH: SOURCE_TSCONFIG_PATH } : {}, - }) + } } /** diff --git a/packages/subprocess/subprocess-local/src/spawn-runner.ts b/packages/subprocess/subprocess-local/src/spawn-runner.ts index 7b4afaf57f..cc332ae8a5 100644 --- a/packages/subprocess/subprocess-local/src/spawn-runner.ts +++ b/packages/subprocess/subprocess-local/src/spawn-runner.ts @@ -150,7 +150,7 @@ function execLinuxTarget( ): never { const program = argv[0] as string if (program.includes('/')) return execLinuxFile(program, argv, request.env, internals) - const path = request.env.PATH ?? '/bin:/usr/bin' + const path = request.env.PATH ?? '/usr/bin:/bin' let permissionFailure: Error | undefined for (const directory of path.split(':')) { const root = directory.startsWith('/') diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 2afb41ed1c..67bc46ecff 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -149,11 +149,20 @@ describe.skipIf(!linuxNative)('Linux user-systemd native containment', () => { const command = `setsid sh -c 'echo $$ > "$1"; trap "" TERM; while :; do sleep 60; done' sh ${JSON.stringify(pidFile)} & wait` const request = spec(['bash', '-c', command], 80) const handle = bindManagedProcess(request, launchLinuxScope(request, targetEnvironment(request))) - const descendant = await waitForPid(pidFile) - handle.terminate() - await handle.done - await expect(handle.waitForExit()).resolves.toBe(true) - await waitGone(descendant) + let descendant: number | undefined + try { + descendant = await waitForPid(pidFile) + handle.terminate() + await handle.done + await expect(handle.waitForExit()).resolves.toBe(true) + await waitGone(descendant) + } finally { + handle.terminate() + await Promise.allSettled([handle.done, handle.waitForExit()]) + if (descendant !== undefined) { + try { process.kill(descendant, 'SIGKILL') } catch { /* already contained */ } + } + } }) it('preserves Node-shaped ENOENT and EACCES spawn failures without replay', async () => { diff --git a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts index 86a5c39ebf..bbaeb07745 100644 --- a/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn-runner.spec.ts @@ -276,6 +276,34 @@ describe('runner launch inputs', () => { }, true, 17)).toEqual(['ignore', 'ignore', 'ignore', 'ipc', 17, 1, 'pipe']) }) + it('removes ambient Node and tsx controls from the bootstrap environment only', () => { + vi.stubEnv('NODE_OPTIONS', '--require /tmp/runner-bootstrap-control.cjs') + vi.stubEnv('NODE_DEBUG', 'esm') + vi.stubEnv('TSX_DISABLE_CACHE', '1') + vi.stubEnv('TSX_TSCONFIG_PATH', '/ambient/tsconfig.json') + try { + const sourceEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/bin.ts']) + const builtEnv = runnerEnvironment('/tmp/request', [process.execPath, '/repo/runner.js']) + expect(sourceEnv.NODE_OPTIONS).toBeUndefined() + expect(sourceEnv.NODE_DEBUG).toBeUndefined() + expect(sourceEnv.TSX_DISABLE_CACHE).toBeUndefined() + expect(sourceEnv.TSX_TSCONFIG_PATH) + .toBe(resolve(import.meta.dirname, '../../../..', 'tsconfig.base.json')) + expect(builtEnv.NODE_OPTIONS).toBeUndefined() + expect(builtEnv.NODE_DEBUG).toBeUndefined() + expect(builtEnv.TSX_DISABLE_CACHE).toBeUndefined() + expect(builtEnv.TSX_TSCONFIG_PATH).toBeUndefined() + expect(targetEnvironment(spec)).toMatchObject({ + NODE_OPTIONS: '--require /tmp/runner-bootstrap-control.cjs', + NODE_DEBUG: 'esm', + TSX_DISABLE_CACHE: '1', + TSX_TSCONFIG_PATH: '/ambient/tsconfig.json', + }) + } finally { + vi.unstubAllEnvs() + } + }) + it('validates every Node-baseline NUL location before launch', () => { expect(targetEnvironment(spec)).toMatchObject({ EXPLICIT: 'yes' }) expect(targetEnvironment({ ...spec, env: { '=C:': 'C:\\target' } })) @@ -527,7 +555,7 @@ describe('Linux one-shot exec bootstrap', () => { throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES', errno: -13 }) }) await runSpawnRunner(files.requestPath, ['--', 'tool'], hostArgument(new FakeRunnerHost()), internals({ execve })) - expect(execve.mock.calls.map(call => call[0])).toEqual(['/bin/tool', '/usr/bin/tool']) + expect(execve.mock.calls.map(call => call[0])).toEqual(['/usr/bin/tool', '/bin/tool']) expect(readLinuxStartupError(files.startupErrorPath)).toMatchObject({ type: 'error', error: { From c7dc4d3833dd14322433a6a085d09983608e6ae4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 4 Sep 2026 01:30:10 +0800 Subject: [PATCH 109/110] test(subprocess): restore Cordis API projection --- .../session/cordis-inspect-jsdoc/session.v2.jsonl | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v2.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v2.jsonl index d29ea26a77..36188dda08 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v2.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v2.jsonl @@ -2,20 +2,25 @@ {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the exact tools service API, tools/pre-execute event, and subprocess service API with cordis_inspect_query, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788233487763,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788233487763,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788233487763,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788233487763,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788233487763,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788233487803,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788233487803,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788233487803,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788233487803,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788233487803,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From e82e5ffd7a1717d2b9e1f565017a814251de6eb2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 4 Sep 2026 02:58:09 +0800 Subject: [PATCH 110/110] fix(subprocess): normalize PTY scope launcher environment --- .../subprocess/subprocess-local/src/index.ts | 6 +++- .../subprocess-local/tests/local.spec.ts | 10 ++++-- .../tests/native-windows.spec.ts | 33 ++++++++++--------- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index f407495ced..5671043e6e 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -245,7 +245,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { const inspector = this.terminalInspector ?? createProcessInspector() const containmentMode = this.selectContainmentMode('terminal') const scope = containmentMode === 'linux-scope' - ? prepareLinuxTerminalScope(spec, env) + ? prepareLinuxTerminalScope(spec, { + ...env, + PWD: spec.cwd, + TERM: 'dumb', + }) : undefined if (scope !== undefined) { options.cwd = scope.cwd diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 6c1bfca239..4ca9f64439 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -480,14 +480,20 @@ describe('LocalSubprocessRuntime', () => { runtime.internals = { platform: 'linux' } runtime.terminalInspector = inspector + const targetCwd = process.cwd() const handle = await runtime.spawnTerminal({ - argv: ['shell', '--literal'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['shell', '--literal'], + cwd: targetCwd, + rows: 24, + cols: 80, + graceMs: 10, + env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' }, }) expect(probeLinuxNative).toHaveBeenCalledOnce() expect(prepareLinuxTerminalScope).toHaveBeenCalledWith( expect.objectContaining({ argv: ['shell', '--literal'] }), - expect.any(Object), + expect.objectContaining({ PWD: targetCwd, TERM: 'dumb', TARGET_VALUE: 'preserved' }), ) expect(nodePtySpawn).toHaveBeenCalledWith( '/usr/bin/systemd-run', diff --git a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts index f599f9f781..2d14ca026d 100644 --- a/packages/subprocess/subprocess-local/tests/native-windows.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-windows.spec.ts @@ -157,20 +157,21 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } as const, } const handle = bindManagedProcess(request, launchWindowsJob(request, targetEnvironment(request))) - if (handle.stdout === undefined) throw new Error('expected piped stdout') - if (handle.stderr === undefined) throw new Error('expected piped stderr') - handle.stdout.resume() - handle.stderr.resume() - const stdoutEnded = new Promise((resolve, reject) => { - handle.stdout?.once('end', resolve) - handle.stdout?.once('error', reject) - }) - const stderrEnded = new Promise((resolve, reject) => { - handle.stderr?.once('end', resolve) - handle.stderr?.once('error', reject) - }) - const descendant = await waitForPid(pidFile) + let descendant: number | undefined try { + if (handle.stdout === undefined) throw new Error('expected piped stdout') + if (handle.stderr === undefined) throw new Error('expected piped stderr') + handle.stdout.resume() + handle.stderr.resume() + const stdoutEnded = new Promise((resolve, reject) => { + handle.stdout?.once('end', resolve) + handle.stdout?.once('error', reject) + }) + const stderrEnded = new Promise((resolve, reject) => { + handle.stderr?.once('end', resolve) + handle.stderr?.once('error', reject) + }) + descendant = await waitForPid(pidFile) await expect(handle.done).resolves.toEqual({ exitCode: 42, signal: null }) await expect(Promise.race([ Promise.all([stdoutEnded, stderrEnded]).then(() => true), @@ -181,13 +182,15 @@ describe.skipIf(!windowsNative)('Windows Job native containment', () => { value: 'explicit', arg: 'literal $HOME ${UNCHANGED}', })) - rmSync(targetCwd, { recursive: true }) await expect(handle.waitForExit(AbortSignal.timeout(30))).resolves.toBe(false) handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) await waitGone(descendant) } finally { - cleanup(descendant) + handle.terminate() + await Promise.allSettled([handle.done, handle.waitForExit()]) + if (descendant !== undefined) cleanup(descendant) + rmSync(targetCwd, { recursive: true, force: true }) } })