mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.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 运行器)以及塑造它的每一项默认值。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 Note(agent 决策记录)](../../../.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 测试矩阵之外。
|
||||
- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 使用 `ps` 快照。
|
||||
- **守护化的终端后代可能逃离已捕获进程树**:子进程若在拆卸前重新设定父进程,便无法再从 `node-pty` 根发现。本地提供方接受这个缺口,不向根 PID 的 POSIX 会话发送信号,因为其中可能包含无关的启动器进程。
|
||||
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
||||
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
|
||||
|
||||
原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
331
packages/subprocess/subprocess-local/src/process-inspector.ts
Normal file
331
packages/subprocess/subprocess-local/src/process-inspector.ts
Normal 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}`)
|
||||
}
|
||||
226
packages/subprocess/subprocess-local/src/terminal.ts
Normal file
226
packages/subprocess/subprocess-local/src/terminal.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
275
packages/subprocess/subprocess-local/tests/terminal.spec.ts
Normal file
275
packages/subprocess/subprocess-local/tests/terminal.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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、ACP(Agent 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、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、拆卸阶梯与原因分类归调用方所有)。
|
||||
- `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 Note(agent 决策记录)](../../../.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 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。
|
||||
- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合方式(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。
|
||||
- **由 SDK 管理的 spawn 仍在服务之外**:自行拥有内部 spawn 的 SDK 传输层无法经该服务路由这次调用;它仍可导入 `scrubbedParentEnv`,使环境策略保持单一来源。
|
||||
- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user