refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-07-28 23:00:00 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions

View File

@@ -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: 187ea5b778a4bc1f9c3c9121adb18bda11cc7b58
README.zh.md: dd3a975daec014131877d7b1523810bd932619d8
README.md: c4bb1da1a172b05afa63834db4e5b1fa974baabb
README.zh.md: f68bc1c720eeb5282bb9c94c951524a021e38b2f

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
This family runs host subprocesses behind an explicit process-lifecycle service.
The shared process substrate for one execution world: canonical cwd/runtime storage, 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 complete session cleanup. Command defaulting, shell semantics, deadlines, protocol framing, readiness, and presentation stay with consumers — the [bash executors](../bash/README.md), [LSP host](../lsp/README.md), [PTY shell backend](../pty/README.md), [subprocess code runtime](../code-runtime/code-runtime-subprocess/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 | Role | ctx key |
| Package | ctx key | Role |
|---|---|---|
| [`subprocess/`](subprocess/README.md) | Defines subprocess launch, stream, termination, and disposal contracts | `ctx.subprocess` |
| [`subprocess-local/`](subprocess-local/README.md) | Implements local process-tree execution | registers on `ctx.subprocess` |
| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: execution-world coordinates and 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`) | — | The local implementation: detached process trees, bounded collection/spill, `node-pty`, foreground/session inspection, tree signalling, runtime storage, and terminate-and-join disposal |
The service owns process lifetime; each consumer owns what the process does and which defaults apply.
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.

View File

@@ -1,12 +1,12 @@
# subprocess/进程能力家族
# subprocess/:进程管理能力家族
[English](README.md) | 中文
本家族通过显式的进程生命周期服务运行宿主子进程
同一执行世界中的共享进程基底:规范化 cwd运行时存储、可执行文件查找、采用原始或收集式 stdio 的完全显式受管子进程树,以及一项负责 PTY 分配、前台进程组和完整会话清理的深层终端进程原语。命令默认值补全、shell 语义、deadline、协议分帧、就绪检测与呈现留在消费方[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)、[PTY shell 后端](../pty/README.md)、[基于进程管理的 Code Runtime](../code-runtime/code-runtime-subprocess/README.md)与 [ACPAgent Client Protocolsubagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)
| 包 | 职责 | ctx 键 |
| 包package | ctx 键 | 角色 |
|---|---|---|
| [`subprocess/`](subprocess/README.md) | 定义子进程启动、流、终止和 dispose资源释放契约 | `ctx.subprocess` |
| [`subprocess-local/`](subprocess-local/README.md) | 实现本地进程树执行 | 注册到 `ctx.subprocess` |
| [`subprocess`](subprocess/README.md)`@deepseek-ai/dsh-subprocess` | `ctx.subprocess` | seam 本体:执行世界坐标与可执行文件查找、普通受管 spawn、终端进程原语、句柄生命周期以及共享的环境输出词汇 |
| [`subprocess-local`](subprocess-local/README.md)`@deepseek-ai/dsh-subprocess-local` | 无 | 本地实现detached 进程树、有界收集spill、`node-pty`、前台/会话检查、进程树信号发送、运行时存储,以及先终止再等待退出的资源释放 |
服务负责进程生命周期;每个消费方负责进程执行的工作以及所应用的默认值。
服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。

View File

@@ -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: af9f92db714398dd52c9b5e7aeb64d5af71021da
README.zh.md: da78cbcfbff174eb5fda5b8323fbdc84b50c9997
README.md: 6bc1003ae5903bb5640728bc79c3f9042fddbe7a
README.zh.md: 3c9ce73c7fcbeec9b17d73f212ecdcb6842ec133

View File

@@ -2,14 +2,16 @@
English | [中文](README.zh.md)
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)).
Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam. `LocalSubprocessService` owns a private runtime directory, 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 seams ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-pty-local`](../../pty/pty-local/README.md), and [`dsh-code-runtime-subprocess`](../../code-runtime/code-runtime-subprocess/README.md)).
## Behavior (and where it came from)
- **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 <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 — the same caveat as the surveyed tools.
- **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 <pid> /T /F` (injectable for tests). `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 — the same caveat as the surveyed tools.
- **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-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*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-surface.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.
- **Execution-world coordinates** — `cwd` is the host process cwd, `runtimeRoot` is an owner-private temporary directory removed on disposal, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. Linux `/proc`/syscall and macOS `ps` inspectors retain exact pid/start identity so pid reuse cannot redirect cleanup; the higher PTY backend owns prompt readiness, buffers, and model-facing operations.
- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement.
## Model Experience
@@ -22,8 +24,10 @@ 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 <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary.
- **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.
- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix.
- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
- **A daemonized terminal descendant can escape the captured tree** — a child that reparents before teardown is no longer discoverable from the `node-pty` root. The local provider accepts this gap rather than signal the root PID's POSIX session, which can include unrelated launcher processes.
- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) 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.

View File

@@ -2,15 +2,17 @@
[English](README.md) | 中文
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现`LocalSubprocessService` 将每个 spec 的 argv spawn detached 进程树,依照 spec 中按流划分的 stdio 处置方式disposition完成接线原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围发送信号,按 SIGTERM→SIGKILL 逐级升级。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 传入,因此随部署变化的可调参数留在各调用方 seam 的配置里[`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。
[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现`LocalSubprocessService` 拥有私有运行时目录,解析本地可执行文件,以显式 stdio spawn 普通 detached 进程树,并通过 `node-pty` 与平台进程检查实现终端进程。该实现没有任何配置:每项处置方式、限制、终端尺寸、宽限期与目录都来自调用方 seam[`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-pty-local`](../../pty/pty-local/README.md)和 [`dsh-code-runtime-subprocess`](../../code-runtime/code-runtime-subprocess/README.md))。
## 行为(以及设计来源)
- **以适合平台的方式发送信号的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树。`terminate()`(句柄唯一的终止操作)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;流水线与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH重新指定父进程并脱离该组的 daemon 仍可能存活,这与调研工具的局限相同。
- **按流划分的处置方式**`'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-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合
- **带平台正确信号发送的 detached 进程树**POSIX 子进程使用 `detached` spawn拥有独立进程组信号以负 pgid 发送并以直接子进程作为回退Windows 通过 `taskkill /PID <pid> /T /F` 终止进程树(可为测试注入)`terminate()`(句柄唯一的终止动词)先发送 SIGTERM经过 spec 的宽限期后再发送 SIGKILL沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **执行世界坐标**`cwd` 是宿主进程 cwd`runtimeRoot` 是所有者私有的临时目录,在资源释放时删除;`resolveExecutable` 检查绝对文件,或使用平台感知的可执行扩展名在清理后的有效 PATH 中查找
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并先于顶层 shell 清理后代。Linux 的 `/proc`syscall 检查器与 macOS 的 `ps` 检查器会保留精确的 pid启动身份使 PID 复用无法把清理重定向到其他进程;上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
## 模型体验
@@ -18,12 +20,14 @@
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作
- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外
- **终端进程检查仅支持 LinuxmacOS**检查器没有受支持的平台实现时终端原语会失败Linux 精确探针覆盖 x64 与 arm64macOS 使用 `ps` 快照
- **守护化的终端后代可能逃离已捕获进程树**:子进程若在拆卸前重新设定父进程,便无法再从 `node-pty` 根发现。本地提供方接受这个缺口,不向根 PID 的 POSIX 会话发送信号,因为其中可能包含无关的启动器进程。
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*``*SECRET*``*TOKEN*`;名称不同的 secret例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
原始进程处理位于 `src/spawn.ts``src/index.ts` 负责服务接线。

View File

@@ -21,8 +21,12 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"scripts/ensure-spawn-helper.mjs",
"lib/types/**/*.d.ts"
],
"scripts": {
"postinstall": "node scripts/ensure-spawn-helper.mjs"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -30,6 +34,9 @@
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-pty": "^1.1.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",

View File

@@ -0,0 +1,16 @@
/** Restore the executable bit stripped from node-pty's prebuilt helper. */
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const entry = fileURLToPath(import.meta.resolve('node-pty'))
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}

View File

@@ -7,11 +7,26 @@
* @module @deepseek-ai/dsh-subprocess-local
*/
import { constants } from 'node:fs'
import { mkdtempSync } from 'node:fs'
import { access, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, extname, isAbsolute, join } from 'node:path'
import { Context } from 'cordis'
import * as nodePty from 'node-pty'
import type { IPtyForkOptions } from 'node-pty'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from './spawn.ts'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import { childEnv, spawnSubprocess } from './spawn.ts'
import type { SpawnInternals } from './spawn.ts'
import { createProcessInspector } from './process-inspector.ts'
import type { ProcessInspector } from './process-inspector.ts'
import { LocalTerminalHandle } from './terminal.ts'
/**
* Local subprocess service: detached process trees, Node-shaped stdio
@@ -20,10 +35,16 @@ import type { SpawnInternals } from './spawn.ts'
* SIGTERM→grace→SIGKILL escalation.
*/
export class LocalSubprocessService extends SubprocessService {
readonly cwd = process.cwd()
readonly runtimeRoot = mkdtempSync(join(tmpdir(), 'dsh-subprocess-runtime-'))
/** Live handles retained only so disposal can terminate and join them. */
private live = new Set<SubprocessHandle>()
/** Live terminal sessions retained through whole-session quiescence. */
private terminals = new Set<SubprocessTerminalHandle>()
/** Test seam: spill and platform knobs forwarded to spawnSubprocess. */
internals: SpawnInternals = {}
/** Test seam for platform process inspection; production resolves lazily on terminal spawn. */
terminalInspector: ProcessInspector | undefined
constructor(ctx: Context) {
super(ctx)
@@ -37,11 +58,58 @@ export class LocalSubprocessService extends SubprocessService {
// Spawn-failure rejections already settled and left the live set.
pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
}
for (const terminal of this.terminals) {
terminal.terminate()
// Cleanup may reject before the top-level process exits (for example,
// an identity-fenced descendant survives escalation). Await the cleanup
// transaction directly so disposal reports that failure rather than
// waiting forever on `done`.
pending.push(terminal.waitForExit())
}
this.live.clear()
this.terminals.clear()
await Promise.all(pending)
await rm(this.runtimeRoot, { recursive: true, force: true })
}, 'local subprocess teardown')
}
async resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<string> {
if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty')
signal?.throwIfAborted()
const environment = childEnv(env)
const absolute = isAbsolute(command)
const candidates = absolute ? [command] : this.executableCandidates(command, environment)
for (const candidate of candidates) {
signal?.throwIfAborted()
try {
const info = await stat(candidate)
if (!info.isFile()) continue
await access(candidate, constants.X_OK)
signal?.throwIfAborted()
return candidate
} catch {
// Try the next PATH candidate; the final miss receives one stable error.
}
}
signal?.throwIfAborted()
throw new Error(absolute
? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`
: `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)
}
private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] {
const path = env.PATH ?? ''
const extensions = process.platform === 'win32' && extname(command) === ''
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
: ['']
return path.split(delimiter).flatMap(directory =>
directory === '' ? [] : extensions.map(extension => join(directory, command + extension)))
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const handle = spawnSubprocess(spec, this.internals)
this.live.add(handle)
@@ -54,6 +122,38 @@ export class LocalSubprocessService extends SubprocessService {
handle.done.then(release, release)
return handle
}
// Local PTY allocation is synchronous, but the provider seam permits remote asynchronous allocation.
// eslint-disable-next-line @typescript-eslint/require-await
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
const file = spec.argv[0]
if (file === undefined || file.length === 0) {
throw new Error('subprocess-local: terminal argv must contain a program')
}
for (const [name, value] of [['rows', spec.rows], ['cols', spec.cols], ['graceMs', spec.graceMs]] as const) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`subprocess-local: terminal ${name} must be a positive safe integer`)
}
}
spec.signal?.throwIfAborted()
const options: IPtyForkOptions = {
name: 'dumb',
rows: spec.rows,
cols: spec.cols,
cwd: spec.cwd,
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, spec.signal)
this.terminals.add(handle)
const release = async (): Promise<void> => {
await handle.waitForExit()
this.terminals.delete(handle)
}
void handle.done.then(release, release).catch(() => {})
return handle
}
}
export default LocalSubprocessService

View File

@@ -0,0 +1,331 @@
/** Platform process-table inspection for terminal readiness, signals, and teardown. */
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
/** PID plus start identity, preventing teardown escalation after PID reuse. */
export interface ProcessIdentity {
pid: number
started: string
}
/** Injectable OS process operations used by one local PTY session. */
export interface ProcessInspector {
foregroundPgid(shellPid: number): number | undefined
isStdinWaiting(pgid: number): boolean
/** Return the root and its current transitive descendants, children first. */
processTree(rootPid: number): ProcessIdentity[]
/** Return whether the exact identity remains a non-quiescent process. */
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
/** Testable boundary around filesystem, process-table, and signal syscalls. */
export interface ProcessInspectorInternals {
readFile(path: string): string
readDir(path: string): string[]
open(path: string): number
read(fd: number, buffer: Buffer, length: number, position: number): number
close(fd: number): void
exec(file: string, args: string[]): string
kill(pid: number, signal: NodeJS.Signals): void
}
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
readFile: path => readFileSync(path, 'utf8'),
readDir: path => readdirSync(path),
open: path => openSync(path, 'r'),
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
close: closeSync,
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
kill: (pid, signal) => process.kill(pid, signal),
}
/* v8 ignore stop */
interface ProcStat {
pid: number
parentPid: number
pgrp: number
session: number
state: string
tpgid: number
started: string
}
/**
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
* @param text - complete stat line.
* @returns Parsed identity/group fields, or undefined for malformed input.
*/
export function parseProcStat(text: string): ProcStat | undefined {
const open = text.indexOf('(')
const close = text.lastIndexOf(')')
if (open <= 0 || close <= open) return undefined
const pid = Number(text.slice(0, open).trim())
const rest = text.slice(close + 2).trim().split(/\s+/)
const state = rest[0] || ''
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|| state.length !== 1 || started === undefined) return undefined
return { pid, parentPid, pgrp, session, state, tpgid, started }
}
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
try {
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
} catch (_unreadableProcEntry) {
return undefined
}
}
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
try {
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
} catch (_unreadableProcDirectory) {
return []
}
}
interface SyscallInfo {
number: number
args: number[]
}
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
try {
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
if (text === 'running' || text.startsWith('-1 ')) return undefined
const fields = text.split(/\s+/)
const number = Number(fields[0])
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
return { number, args }
} catch (_unreadableSyscall) {
return undefined
}
}
function readMemory(
internals: ProcessInspectorInternals,
pid: number,
address: number,
length: number,
): Buffer | undefined {
let fd: number | undefined
try {
fd = internals.open(`/proc/${pid}/mem`)
const buffer = Buffer.alloc(length)
const count = internals.read(fd, buffer, length, address)
return buffer.subarray(0, count)
} catch (_unreadableProcessMemory) {
return undefined
} finally {
if (fd !== undefined) internals.close(fd)
}
}
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
}
function pollHasStdin(
internals: ProcessInspectorInternals,
pid: number,
address: number,
count: number,
): boolean {
if (address === 0 || count <= 0) return false
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
if (memory === undefined) return false
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
}
return false
}
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
try {
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
.split('\n')
.some(line => /^tfd:\s+0\b/.test(line.trim()))
} catch (_unreadableFdInfo) {
return false
}
}
interface SyscallTable {
read: number
select?: number
pselect: number
poll?: number
ppoll: number
epollWait?: number
epollPwait: number
}
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
}
function syscallWaitsOnStdin(
internals: ProcessInspectorInternals,
pid: number,
syscall: SyscallInfo,
table: SyscallTable,
): boolean {
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
if (syscall.number === table.read) return a0 === 0
if (syscall.number === table.select || syscall.number === table.pselect) {
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
}
if (syscall.number === table.poll || syscall.number === table.ppoll) {
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
}
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
return a2 >= 1 && epollHasStdin(internals, pid, a0)
}
return false
}
abstract class PosixProcessInspector implements ProcessInspector {
constructor(protected readonly internals: ProcessInspectorInternals) {}
abstract foregroundPgid(shellPid: number): number | undefined
abstract isStdinWaiting(pgid: number): boolean
abstract processTree(rootPid: number): ProcessIdentity[]
abstract isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
this.internals.kill(-pgid, signal)
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
}
}
interface ProcessTreeEntry extends ProcessIdentity {
parentPid: number
}
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
const root = byPid.get(rootPid)
if (root === undefined) return []
const byParent = new Map<number, ProcessTreeEntry[]>()
for (const entry of entries) {
const children = byParent.get(entry.parentPid) ?? []
children.push(entry)
byParent.set(entry.parentPid, children)
}
const visited = new Set<number>()
const result: ProcessIdentity[] = []
const visit = (entry: ProcessTreeEntry): void => {
if (visited.has(entry.pid)) return
visited.add(entry.pid)
for (const child of byParent.get(entry.pid) ?? []) visit(child)
result.push({ pid: entry.pid, started: entry.started })
}
visit(root)
return result
}
class LinuxProcessInspector extends PosixProcessInspector {
constructor(
private readonly arch: NodeJS.Architecture,
internals: ProcessInspectorInternals,
) {
super(internals)
}
foregroundPgid(shellPid: number): number | undefined {
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
}
isStdinWaiting(pgid: number): boolean {
const table = SYSCALLS[this.arch]
if (table === undefined) return false
for (const pid of numericEntries(this.internals, '/proc')) {
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
const syscall = readSyscall(this.internals, pid, tid)
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
}
}
return false
}
processTree(rootPid: number): ProcessIdentity[] {
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
const stat = readLinuxStat(this.internals, pid)
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
})
return processTree(entries, rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
}
}
interface PsEntry extends ProcessTreeEntry {}
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
})
}
class MacProcessInspector extends PosixProcessInspector {
foregroundPgid(shellPid: number): number | undefined {
try {
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
return Number.isSafeInteger(value) && value > 0 ? value : undefined
} catch (_missingProcess) {
return undefined
}
}
isStdinWaiting(_pgid: number): boolean {
return false
}
processTree(rootPid: number): ProcessIdentity[] {
return processTree(macProcessTable(this.internals), rootPid)
}
isAlive(identity: ProcessIdentity): boolean {
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
}
}
/**
* Create the supported platform inspector or fail at plugin load.
* @param platform - target Node platform.
* @param arch - target CPU architecture for Linux syscall numbers.
* @param internals - filesystem/process boundary, injectable for deterministic tests.
* @returns Platform process inspector.
*/
export function createProcessInspector(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
): ProcessInspector {
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
if (platform === 'darwin') return new MacProcessInspector(internals)
throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
}

View File

@@ -0,0 +1,226 @@
/** Local node-pty terminal-process implementation for the subprocess seam. */
import { Buffer } from 'node:buffer'
import { constants } from 'node:os'
import { PassThrough } from 'node:stream'
import type { IDisposable, IPty } from 'node-pty'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
} from '@deepseek-ai/dsh-subprocess'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function signalName(number: number | undefined): NodeJS.Signals | null {
if (number === undefined || number === 0) return null
for (const [name, value] of Object.entries(constants.signals)) {
if (value === number) return name as NodeJS.Signals
}
return null
}
/** A local terminal whose process-session ownership stays below the PTY backend. */
export class LocalTerminalHandle implements SubprocessTerminalHandle {
readonly pid: number
readonly output = new PassThrough()
readonly done: Promise<SubprocessOutcome>
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
private readonly dataDisposable: IDisposable
private readonly exitDisposable: IDisposable
private exited = false
private termination: Promise<void> | undefined
private removeAbort: (() => void) | undefined
/**
* @param terminal - allocated node-pty process.
* @param inspector - platform process/session operations.
* @param graceMs - TERM-to-KILL and exit-wait grace.
* @param signal - optional lifetime cancellation.
*/
constructor(
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly graceMs: number,
signal?: AbortSignal,
) {
this.pid = terminal.pid
this.done = this.outcome.promise
this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) })
this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
if (this.exited) return
this.exited = true
this.output.end()
this.outcome.resolve({
exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
signal: signalName(exitSignal),
})
this.terminate()
})
if (signal !== undefined) {
const onAbort = (): void => { this.terminate() }
signal.addEventListener('abort', onAbort, { once: true })
this.removeAbort = () => { signal.removeEventListener('abort', onAbort) }
if (signal.aborted) this.terminate()
}
}
// node-pty writes synchronously; the seam returns a promise for remote transports.
// eslint-disable-next-line @typescript-eslint/require-await
async write(data: Uint8Array): Promise<void> {
if (this.exited) throw new Error('terminal process has exited')
let text: string
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(data)
} catch (error: unknown) {
throw new Error('terminal input must be valid UTF-8', { cause: error })
}
this.terminal.write(text)
}
// Local inspection is synchronous; the seam returns a promise for remote transports.
// eslint-disable-next-line @typescript-eslint/require-await
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
const processGroupId = this.inspector.foregroundPgid(this.pid)
if (processGroupId === undefined) return undefined
return {
processGroupId,
inputWaiting: this.inspector.isStdinWaiting(processGroupId),
}
}
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
const foreground = await this.inspectForeground()
if (foreground === undefined) {
throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
}
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
}
this.inspector.signalGroup(foreground.processGroupId, signal)
return foreground.processGroupId
}
terminate(): void {
this.termination ??= this.closeOnce().catch((error: unknown) => {
this.termination = undefined
throw error
})
void this.termination.catch(() => {})
}
async waitForExit(signal?: AbortSignal): Promise<boolean> {
// A caller may begin waiting before the top-level process exits. The exit
// callback starts descendant cleanup in the same turn, so resolve that
// eventual transaction after `done` instead of snapshotting only `done`.
const quiescence = this.termination ?? this.done.then(() => this.termination)
if (signal === undefined) {
await quiescence
return true
}
if (signal.aborted) return false
return await new Promise<boolean>((resolve, reject) => {
const onAbort = (): void => { cleanup(); resolve(false) }
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
void quiescence.then(
() => { cleanup(); resolve(true) },
(error: unknown) => {
cleanup()
// The owned cleanup transaction only throws Error diagnostics.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(error)
},
)
})
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForMembers(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const until = Date.now() + this.graceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < until) {
await delay(Math.min(25, Math.max(1, until - Date.now())))
survivors = this.survivors(members)
}
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// The exact process identity is rechecked; a same-tick exit is success.
}
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = `${member.pid}:${member.started}`
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForMembers(captured)
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForMembers(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {
if (!this.exited) {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit callback is authoritative.
}
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
}
if (!this.exited) {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit callback is authoritative.
}
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
}
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
}
private async closeOnce(): Promise<void> {
const survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
}
await this.stopShell()
this.removeAbort?.()
this.removeAbort = undefined
this.dataDisposable.dispose()
this.exitDisposable.dispose()
}
}

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { stat } from 'node:fs/promises'
import { basename, delimiter, dirname } from 'node:path'
import { Context } from 'cordis'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
return {
@@ -18,6 +21,133 @@ function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): Su
}
describe('LocalSubprocessService', () => {
it('publishes execution-world paths and removes its private runtime directory', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const root = ctx.subprocess.runtimeRoot
expect(ctx.subprocess.cwd).toBe(process.cwd())
expect((await stat(root)).isDirectory()).toBe(true)
await fiber.dispose()
await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('resolves absolute and PATH executables and honors lookup cancellation', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
expect(await ctx.subprocess.resolveExecutable(process.execPath)).toBe(process.execPath)
expect(await ctx.subprocess.resolveExecutable(basename(process.execPath), {
PATH: dirname(process.execPath),
})).toBe(process.execPath)
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
.rejects.toThrow('was not found on PATH')
await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
.rejects.toThrow('is not an executable file')
await expect(ctx.subprocess.resolveExecutable(process.cwd()))
.rejects.toThrow('is not an executable file')
await expect(ctx.subprocess.resolveExecutable(process.execPath, {}, AbortSignal.abort('stop')))
.rejects.toBe('stop')
await fiber.dispose()
})
it('builds Windows executable candidates without empty PATH entries', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const service = ctx.subprocess as LocalSubprocessService
const candidates = (service as unknown as {
executableCandidates(command: string, env: NodeJS.ProcessEnv): string[]
}).executableCandidates.bind(service)
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
expect(candidates('tool', { PATH: `${delimiter}/bin`, PATHEXT: '.EXE;.CMD' }))
.toEqual(['/bin/tool.EXE', '/bin/tool.CMD'])
expect(candidates('tool.exe', {})).toEqual([])
expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
} finally {
platform.mockRestore()
await fiber.dispose()
}
})
it('validates terminal spawn specs before allocating a PTY', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const base: SubprocessTerminalSpawnSpec = {
argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10,
}
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program')
await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program')
await expect(ctx.subprocess.spawnTerminal({ ...base, rows: 1.5 })).rejects.toThrow('rows')
await expect(ctx.subprocess.spawnTerminal({ ...base, cols: 0 })).rejects.toThrow('cols')
await expect(ctx.subprocess.spawnTerminal({ ...base, graceMs: 0 })).rejects.toThrow('graceMs')
await expect(ctx.subprocess.spawnTerminal({ ...base, signal: AbortSignal.abort('stop') })).rejects.toBe('stop')
await fiber.dispose()
})
it('terminates and joins an owned terminal during disposal', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)
const terminate = vi.fn()
const waitForExit = vi.fn(async () => true)
const terminal: SubprocessTerminalHandle = {
pid: 1,
output: new PassThrough(),
done: Promise.resolve({ exitCode: 0, signal: null }),
write: async () => {},
inspectForeground: async () => undefined,
signalForeground: async () => 1,
terminate,
waitForExit,
}
;(ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.add(terminal)
await fiber.dispose()
expect(terminate).toHaveBeenCalledOnce()
expect(waitForExit).toHaveBeenCalledOnce()
})
it('contains a terminal release failure after top-level exit', async () => {
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const terminal = {
pid: 123,
onData: () => ({ dispose: () => {} }),
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
exitListener = listener
return { dispose: () => {} }
},
write: () => {},
kill: () => {},
}
vi.resetModules()
vi.doMock('node-pty', () => ({ spawn: () => terminal }))
try {
const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
const ctx = new Context()
const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
const alive = new Set([124])
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
foregroundPgid: () => 123,
isStdinWaiting: () => false,
processTree: () => [{ pid: 124, started: 'child' }],
isAlive: identity => alive.has(identity.pid),
signalGroup: () => {},
signalProcess: () => {},
}
const handle = await ctx.subprocess.spawnTerminal({
argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
})
exitListener?.({ exitCode: 0 })
await handle.done
await new Promise(resolve => setTimeout(resolve, 10))
alive.clear()
handle.terminate()
await handle.waitForExit()
await fiber.dispose()
} finally {
vi.doUnmock('node-pty')
vi.resetModules()
}
})
it('registers as ctx.subprocess and spawns managed handles', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalSubprocessService)

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest'
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
}
function syscall(number: number, ...args: number[]): string {
const six = [...args]
while (six.length < 6) six.push(0)
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
}
function fakeInternals() {
const files = new Map<string, string>()
const dirs = new Map<string, string[]>()
const memories = new Map<string, Buffer>()
const fds = new Map<number, string>()
const kills: Array<[number, NodeJS.Signals]> = []
let nextFd = 10
let ps = ''
let tpgid = '0'
const internals: ProcessInspectorInternals = {
readFile(path) {
const value = files.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
readDir(path) {
const value = dirs.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
open(path) {
if (!memories.has(path)) throw new Error(`missing ${path}`)
const fd = nextFd++
fds.set(fd, path)
return fd
},
read(fd, buffer, length, position) {
const path = fds.get(fd)
if (path === undefined) throw new Error('bad fd')
const source = memories.get(path)
if (source === undefined) throw new Error('missing memory')
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
},
close(fd) { fds.delete(fd) },
exec(_file, args) {
if (args.includes('tpgid=')) return tpgid
return ps
},
kill(pid, signal) { kills.push([pid, signal]) },
}
return {
internals, files, dirs, memories, kills,
setPs(value: string) { ps = value },
setTpgid(value: string) { tpgid = value },
}
}
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).toBeUndefined()
expect(parseProcStat('1 () ')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100', '101'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
fake.dirs.set('/proc/100/task', ['100'])
fake.dirs.set('/proc/101/task', ['101', '102'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
fake.files.set('/proc/100/task/100/syscall', 'running')
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
const fdSet = Buffer.alloc(0x11)
fdSet[0x10] = 1
fake.memories.set('/proc/101/mem', fdSet)
expect(inspector.isStdinWaiting(77)).toBe(true)
const poll = Buffer.alloc(8)
poll.writeInt32LE(0, 0)
poll.writeInt16LE(1, 4)
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
expect(inspector.isStdinWaiting(77)).toBe(true)
})
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(999))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.delete('/proc/100/task')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.set('/proc', ['100', '200'])
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
})
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
const noStdinPoll = Buffer.alloc(0x28)
noStdinPoll.writeInt32LE(2, 0x20)
noStdinPoll.writeInt16LE(1, 0x24)
fake.memories.set('/proc/100/mem', noStdinPoll)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
})
})
describe('macOS process inspector', () => {
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
const fake = fakeInternals()
fake.setTpgid('55\n')
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55)).toBe(false)
expect(inspector.processTree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
fake.internals.exec = () => { throw new Error('gone') }
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32')
})
})

View File

@@ -0,0 +1,275 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IDisposable, IPty } from 'node-pty'
import { LocalTerminalHandle } from '@deepseek-ai/dsh-subprocess-local/src/terminal.ts'
import type {
ProcessIdentity,
ProcessInspector,
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
class FakePty {
pid = 123
readonly writes: string[] = []
readonly kills: string[] = []
autoExitOnKill = true
throwKill = false
private readonly dataListeners = new Set<(data: string) => void>()
private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
readonly onData = (listener: (data: string) => void): IDisposable => {
this.dataListeners.add(listener)
return { dispose: () => { this.dataListeners.delete(listener) } }
}
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
this.exitListeners.add(listener)
return { dispose: () => { this.exitListeners.delete(listener) } }
}
emitData(data: string): void {
for (const listener of this.dataListeners) listener(data)
}
emitExit(exitCode = 0, signal?: number): void {
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
}
write(data: string): void { this.writes.push(data) }
kill(signal?: string): void {
if (this.throwKill) throw new Error('process raced')
this.kills.push(signal ?? 'SIGHUP')
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
asPty(): IPty {
return this as unknown as IPty
}
}
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
readonly alive = new Set<number>()
readonly groups: Array<[number, SubprocessTerminalSignal]> = []
readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
throwGroup = false
throwProcess = false
removeOnSignal = true
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: SubprocessTerminalSignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
if (this.throwProcess) throw new Error('process raced')
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
}
afterEach(() => { vi.useRealTimers() })
describe('LocalTerminalHandle', () => {
it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.waiting = true
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
const chunks: Buffer[] = []
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
pty.emitData('hello €')
await handle.write(Buffer.from('input\r'))
expect(pty.writes).toEqual(['input\r'])
expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
expect(await handle.signalForeground('SIGINT')).toBe(456)
expect(inspector.groups).toEqual([[456, 'SIGINT']])
pty.emitExit(7, 9)
pty.emitExit(0)
expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
expect(await handle.waitForExit()).toBe(true)
expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
})
it('rejects invalid input and unsafe foreground signals', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
await expect(handle.write(Uint8Array.from([0xff]))).rejects.toThrow('valid UTF-8')
inspector.pgid = handle.pid
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
inspector.pgid = undefined
expect(await handle.inspectForeground()).toBeUndefined()
await expect(handle.signalForeground('SIGTERM')).rejects.toThrow('cannot resolve')
pty.emitExit(3)
expect(await handle.done).toEqual({ exitCode: 3, signal: null })
await handle.waitForExit()
await expect(handle.write(Buffer.from('late'))).rejects.toThrow('has exited')
})
it('keeps the shell alive until forced descendants leave', async () => {
vi.useFakeTimers()
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
handle.terminate()
const quiescent = handle.waitForExit()
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(pty.kills).toEqual([])
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
expect(await quiescent).toBe(true)
expect(pty.kills).toEqual(['SIGTERM'])
})
it('keeps an early exit wait pending through descendant cleanup', async () => {
vi.useFakeTimers()
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
const waiting = handle.waitForExit()
let settled = false
void waiting.then(() => { settled = true })
pty.emitExit()
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
expect(await waiting).toBe(true)
})
it('rescans for descendants forked during TERM', async () => {
const pty = new FakePty()
const inspector = new FakeInspector()
let reads = 0
inspector.processTree = () => {
reads += 1
if (reads === 1) {
inspector.alive.add(124)
return [{ pid: 124, started: 'first' }]
}
if (reads === 2) {
inspector.alive.add(125)
return [{ pid: 125, started: 'late' }]
}
return []
}
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
handle.terminate()
await handle.waitForExit()
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
expect(pty.kills).toEqual(['SIGTERM'])
})
it('retains captured descendants after reparenting', async () => {
vi.useFakeTimers()
const pty = new FakePty()
const inspector = new FakeInspector()
const captured = { pid: 124, started: 'captured' }
let reads = 0
inspector.alive.add(captured.pid)
inspector.processTree = () => reads++ === 0 ? [captured] : []
inspector.signalProcess = (identity, signal) => {
inspector.processes.push([identity.pid, signal])
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
}
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
handle.terminate()
const quiescent = handle.waitForExit()
await vi.advanceTimersByTimeAsync(25)
expect(await quiescent).toBe(true)
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
})
it('allows cleanup to retry after a surviving descendant leaves', async () => {
vi.useFakeTimers()
const pty = new FakePty()
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.removeOnSignal = false
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
handle.terminate()
const first = expect(handle.waitForExit(new AbortController().signal)).rejects.toThrow('surviving pids: 124')
await vi.advanceTimersByTimeAsync(25)
await first
inspector.alive.delete(124)
handle.terminate()
expect(await handle.waitForExit()).toBe(true)
expect(pty.kills).toEqual(['SIGTERM'])
})
it('bounds waits and reports a top-level process that ignores escalation', async () => {
vi.useFakeTimers()
const pty = new FakePty()
pty.autoExitOnKill = false
const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
expect(await handle.waitForExit(AbortSignal.abort())).toBe(false)
const controller = new AbortController()
const bounded = handle.waitForExit(controller.signal)
controller.abort()
expect(await bounded).toBe(false)
handle.terminate()
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pid: 123')
await vi.advanceTimersByTimeAsync(25)
await failed
expect(pty.kills).toEqual(['SIGTERM', 'SIGKILL'])
pty.emitExit(0, 999)
expect(await handle.done).toEqual({ exitCode: null, signal: null })
handle.terminate()
expect(await handle.waitForExit()).toBe(true)
})
it('contains process races and reacts to lifetime cancellation', async () => {
const pty = new FakePty()
pty.throwKill = true
const inspector = new FakeInspector()
inspector.members = [{ pid: 124, started: 'child' }]
inspector.alive.add(124)
inspector.throwProcess = true
const controller = new AbortController()
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1, controller.signal)
controller.abort()
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
await failed
inspector.alive.delete(124)
pty.throwKill = false
handle.terminate()
await handle.waitForExit()
const preAbortedPty = new FakePty()
const preAborted = new LocalTerminalHandle(
preAbortedPty.asPty(),
new FakeInspector(),
1,
AbortSignal.abort('stop'),
)
await preAborted.waitForExit()
expect(preAbortedPty.kills).toEqual(['SIGTERM'])
})
})

View File

@@ -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: e59dd96df036826f36bd0286c977438d2d87d1cf
README.zh.md: e8fb89dfd1f8c41a0caefc96469c13d9ae7d415d
README.md: 84b4b0c11c74c96929fa97b58fb33156d44e6ef1
README.zh.md: dbd80a1c975719884481501f5cc43798e464a4fb

View File

@@ -2,15 +2,17 @@
English | [中文](README.zh.md)
The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). 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 `SubprocessService` exposes its canonical `cwd`, private `runtimeRoot`, 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).
## 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.
- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). Grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so the implementation can represent it with one Node timer instead of accepting a value that Node collapses to one millisecond. `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself.
- `cwd` and `runtimeRoot` are absolute paths in the provider's execution world. Consumers materialize private helpers below `runtimeRoot`, never in a host-only temp directory. `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 calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` 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).
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a string deliberately forwards or overrides a value, while an `undefined` tombstone removes an ordinary ambient entry. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub.
- `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, valid-UTF-8 byte I/O, foreground-process-group inspection/signalling, TERM-to-KILL whole-session cleanup, and a quiescence wait. The output stream ends after queued output when the top-level process exits; a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or prove and clean the complete terminal session; 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.
See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md).
@@ -25,5 +27,5 @@ No direct invalidation; the named consumers own any request-prefix changes.
## Known Limitations and Deferred Work
- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced.
- **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).

View File

@@ -2,28 +2,30 @@
[English](README.md) | 中文
进程 seam`ctx.subprocess`)。抽象的 `SubprocessService` 只暴露一个方法:`spawn(spec): SubprocessHandle`,外加所有消费方共享的词汇:完全显式的 `SubprocessSpawnSpec`、携带基于偏移量的非消费式输出读取器的 `SubprocessHandle``SubprocessOutcome``CollectedOutput`,以及受管的 `DSH_*` 环境命名空间(`DSH_ENV_PREFIX``DshEnvironment`。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。
进程管理器 seam`ctx.subprocess`是同一执行世界中的进程侧。抽象的 `SubprocessService` 公开其规范化 `cwd`、私有 `runtimeRoot`、可执行文件查找、普通受管 `spawn` 和一项终端进程原语;其词汇涵盖原始/收集式 stdio、进程与终端句柄、退出事实、进程树会话清理以及受管的 `DSH_*` 环境命名空间。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。
## 契约
- `spawn(spec)` 立即返回一个活动句柄;`done` 在进程关闭时以退出事实 resolve`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的子进程默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样实现便可用一个 Node 定时器表示它,而不会接受会被 Node 折叠为 1 毫秒的值。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 风格:`'pipe'` 把原始流交给调用方做自己的协议分帧LSP 的 JSON-RPC、ACPAgent Client Protocol的 ndjson`'inherit'` 直通父进程描述符以承载诊断输出收集模式collect`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取
- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。
- `cwd``runtimeRoot` 是提供方执行世界中的绝对路径。消费方在 `runtimeRoot` 下物化私有辅助程序,绝不使用仅宿主可见的临时目录。`resolveExecutable(command, env?, signal?)` 验证绝对命令,或根据该执行世界清理后的 PATH 加显式覆盖来解析裸名称
- spec 完全显式argv、cwd、按流划分的 stdio 处置方式disposition、宽限期因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`
- stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧LSP 的 JSON-RPC、ACPAgent Client Protocol的 ndjson`'inherit'` 直通父进程描述符以承载诊断输出收集模式collect`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。
- 终止在每个平台上都以进程树为范围POSIX 用 detached 进程组并以直接子进程回退Windows 用 `taskkill /T``terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit(signal?)` 观察整棵进程树的存活状态使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止但绝不判定原因deadline、拆卸阶梯与原因分类归调用方所有
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的环境清理定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃spec 的显式 `env` 在清理后合并且不做命名空间校验——字符串会有意转发或覆盖某个值,而 `undefined` tombstone 则会删除普通的环境条目。无法把 spawn 路由到该服务的进程启动方node-pty 后端、由 SDK 管理的传输层)会导入该环境清理定义
- `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、有效 UTF-8 字节 I/O、前台进程组检查信号发送、TERM→KILL 全会话清理,以及等待完全停稳。顶层进程退出后,输出流会在排完队列中的输出后结束;存活期间的传输故障会拒绝 `done`。这些操作仍属于一项基底原语因为普通管道无法分配控制终端也无法证明并清理完整的终端会话就绪检测、scrollback 与所有者策略仍归 PTY 消费方所有
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地普通 spawn 与终端 spawn 都应用这一定义;自行拥有 spawn 的 SDK 管理传输层可以直接导入它。
- 服务自身的 dispose资源释放会终止所有仍在运行的受管进程并等待其退出。
参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与[seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。
## 模型体验
通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出生命周期的全部面向模型渲染均由消费方负责
通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出生命周期面向模型的全部渲染归消费方所有
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
不会直接失效;请求前缀变更由具名消费方负责。
## 已知限制与暂缓事项
- **node-pty 与由 SDK 管理的 spawn 只共享环境清理**PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seamfork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。
- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合方式ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。
- **由 SDK 管理的 spawn 仍在服务之外**:自行拥有内部 spawn 的 SDK 传输层无法经该服务路由这次调用;它仍可导入 `scrubbedParentEnv`,使环境策略保持单一来源。
- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。

View File

@@ -1,10 +1,9 @@
/**
* The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into
* managed process trees with Node-shaped stdio dispositions — raw pipes for
* protocol streams, inherit for diagnostics, bounded spill-backed collection
* for batch output — plus tree-scoped signalling. Command defaulting, shell
* semantics, deadlines, teardown ladders, framing, and presentation belong to
* consumers; the bash executor seam is the owning template. The local implementation lives in
* The subprocess seam (`ctx.subprocess`): execution-world process coordinates,
* executable lookup, fully specified managed process trees 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
* `@deepseek-ai/dsh-subprocess-local`.
* @module @deepseek-ai/dsh-subprocess
*/
@@ -12,6 +11,7 @@
import { Context, Service } from 'cordis'
import { DSH_ENV_PREFIX } from './types.ts'
import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export type {
@@ -28,6 +28,10 @@ export type {
SubprocessSpawnSpec,
SubprocessStdinMode,
SubprocessStdio,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
SubprocessTerminalSpawnSpec,
} from './types.ts'
/**
@@ -74,6 +78,8 @@ declare module 'cordis' {
* duplicate-service behavior).
*
* Implementations must honor these semantics:
* - {@link cwd}, {@link runtimeRoot}, and 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.
* - Collect-mode readers are offset-based and non-consuming, so independent
@@ -87,12 +93,37 @@ declare module 'cordis' {
* quiescence.
* - Disposal of the service terminates all still-running managed processes
* and awaits their exit.
* - {@link spawnTerminal} owns terminal allocation, byte transport,
* foreground groups, signalling, and whole-session quiescence; readiness
* and persistent-shell policy stay in the PTY consumer. Its output stream
* ends after queued terminal output when the top-level process exits.
*/
export abstract class SubprocessService extends Service {
constructor(ctx: Context) {
super(ctx, 'subprocess')
}
/** Canonical default cwd in this provider's execution world. */
abstract readonly cwd: string
/** Private directory for runtime artifacts in this provider's execution world. */
abstract readonly runtimeRoot: string
/**
* 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.
* @param command - absolute executable path or bare PATH name.
* @param env - explicit environment entries used for lookup.
* @param signal - aborts remote or local lookup.
* @returns a canonical executable path.
*/
abstract resolveExecutable(
command: string,
env?: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<string>
/**
* Start one managed child process from a fully-specified spec; this seam
* applies no defaults.
@@ -100,6 +131,15 @@ export abstract class SubprocessService extends Service {
* @returns the live process handle (streams/readers, signalling, outcome promise).
*/
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.
* @param spec - fully specified argv, cwd, environment, dimensions, grace, and cancellation.
* @returns the live terminal handle after allocation succeeds.
*/
abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>
}
export default SubprocessService

View File

@@ -192,3 +192,71 @@ export interface SubprocessHandle {
*/
waitForExit(signal?: AbortSignal): Promise<boolean>
}
/** Signals supported by the terminal-process primitive. */
export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** A fully specified terminal-process spawn. */
export interface SubprocessTerminalSpawnSpec {
/** Executable and arguments; `argv[0]` is the program. */
argv: readonly string[]
/** Working directory in this subprocess provider's execution world. */
cwd: string
/** Explicit environment layered after the provider's ambient scrub. */
env?: Record<string, string> | undefined
/** Initial terminal row count. */
rows: number
/** Initial terminal column count. */
cols: number
/** TERM-to-KILL cleanup grace for the complete terminal session. */
graceMs: number
/** Cancellation of setup or the live terminal session. */
signal?: AbortSignal | undefined
}
/** Current foreground process-group facts for one terminal. */
export interface SubprocessTerminalForeground {
/** Foreground process-group id published by the terminal driver. */
processGroupId: number
/** Whether the provider can currently prove that group is waiting on terminal input. */
inputWaiting: boolean
}
/**
* One live terminal process and its owned OS session. Terminal allocation,
* foreground-group inspection/signalling, and session-tree cleanup are one
* deep subprocess primitive because none can be reconstructed from ordinary
* piped stdio without substrate-specific process control.
*/
export interface SubprocessTerminalHandle {
/** Top-level terminal process id. */
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. */
readonly done: Promise<SubprocessOutcome>
/**
* Write bytes to the terminal input.
* @param data - valid UTF-8 bytes to deliver without implicit newline conversion.
*/
write(data: Uint8Array): Promise<void>
/**
* Inspect the current foreground process group.
* @returns its id and input-wait fact, or undefined when no foreground group can be resolved.
*/
inspectForeground(): Promise<SubprocessTerminalForeground | undefined>
/**
* Deliver a signal to the current foreground process group.
* @param signal - permitted terminal signal.
* @returns the exact group id that received it.
*/
signalForeground(signal: SubprocessTerminalSignal): Promise<number>
/** Begin idempotent TERM-to-KILL cleanup of the complete terminal session. */
terminate(): void
/**
* Await whole-session quiescence, not only top-level process exit.
* @param signal - optional bound for this wait.
* @returns true after quiescence, false when `signal` aborts first.
*/
waitForExit(signal?: AbortSignal): Promise<boolean>
}

View File

@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'
import { PassThrough } from 'node:stream'
import { Context } from 'cordis'
import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessOutputRead,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
/**
* Minimal concrete service: a hand-built handle. The seam is spawn-only —
@@ -9,6 +16,13 @@ import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from
* is all an implementation owes the abstract class.
*/
class StubSubprocessService extends SubprocessService {
readonly cwd = '/stub'
readonly runtimeRoot = '/stub/.runtime'
async resolveExecutable(command: string): Promise<string> {
return `/bin/${command}`
}
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false }
const collected = spec.stdio.stdout !== 'pipe' && spec.stdio.stdout !== 'inherit'
@@ -25,6 +39,19 @@ class StubSubprocessService extends SubprocessService {
waitForExit: () => Promise.resolve(true),
}
}
async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
return {
pid: spec.argv.length,
output: new PassThrough(),
done: Promise.resolve({ exitCode: 0, signal: null }),
write: async () => {},
inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }),
signalForeground: async () => 1,
terminate: () => {},
waitForExit: async () => true,
}
}
}
describe('SubprocessService seam', () => {