@deepseek-ai/dsh-subprocess-local 0.1.1-rc.2 → 0.1.2-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +122 -17
- package/README.zh.md +124 -19
- package/lib/index.js +112 -59
- package/lib/types/process-inspector.d.ts +77 -6
- package/lib/types/windows-inspector.d.ts +3 -4
- package/package.json +10 -10
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: 5e609bf96002c62d18684517bcd815e6f4fc0bb0
|
|
6
|
+
README.zh.md: 9e53c9f9444c0c19ed4ec337357a9151d8f90328
|
package/README.md
CHANGED
|
@@ -1,23 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "The local host provider for the subprocess service: run managed process trees and real terminal sessions on the host machine."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-subprocess-local
|
|
2
7
|
|
|
3
8
|
English | [中文](README.zh.md)
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Mount `dsh-subprocess-local` in any composition that runs child processes on the host: it resolves local executables, spawns detached process trees with explicit stdio, and provides real terminal sessions through `node-pty`. It has no configuration, so every disposition, limit, terminal size, and grace arrives on the spawn request from the calling capability seam. Output collection keeps a bounded in-memory tail with optional spill files for full-stream recovery, children start from a scrubbed environment, and disposal terminates and joins every running tree.
|
|
13
|
+
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
27
|
+
|
|
28
|
+
Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host.
|
|
29
|
+
|
|
30
|
+
### Mounting the provider
|
|
31
|
+
|
|
32
|
+
Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration.
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- name: '@deepseek-ai/dsh-subprocess-local'
|
|
36
|
+
- name: '@deepseek-ai/dsh-bash-local'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Resolving executables
|
|
40
|
+
|
|
41
|
+
Absolute executable paths are verified; bare names resolve against the scrubbed PATH with platform-aware executable extensions (`.COM`/`.EXE`/`.BAT`/`.CMD` on Windows). Relative paths containing separators are rejected — provide an absolute path or a bare PATH name — and relative PATH entries resolve from the host process cwd.
|
|
42
|
+
|
|
43
|
+
### Collecting output
|
|
44
|
+
|
|
45
|
+
Collect mode keeps the last `maxBytes` of a stream in memory — errors and final results cluster at the end — and, when a `spill` cap is configured, appends the complete stream to a private file under a per-process directory in the OS temp dir (a `0700` directory, `0600` random-named files). A stream larger than the spill cap discards its incomplete spill and returns only the marked truncated tail. Reads are offset-based and non-consuming, so background and batch readers coexist before and after exit.
|
|
46
|
+
|
|
47
|
+
### Running terminal sessions
|
|
48
|
+
|
|
49
|
+
`spawnTerminal` allocates a real PTY and bridges UTF-8 text; you can inspect and signal the current foreground process group and await a `terminate()` that settles every session member the provider can still observe. On Linux, an exact input wait requires a foreground thread whose fd 0 identifies the shell's controlling terminal and whose current syscall waits on that fd. If the kernel denies the syscall probe, the provider reports no exact wait and leaves the higher PTY backend to its idle inference; process sleep state is not evidence. On Windows, SIGINT is delivered as a Ctrl-C input write, SIGTSTP and SIGHUP are unsupported, and teardown verifies the shell's termination through the process table because an externally killed shell may never fire the PTY exit notification.
|
|
50
|
+
|
|
51
|
+
### Shutdown behavior
|
|
52
|
+
|
|
53
|
+
Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
|
|
54
|
+
|
|
55
|
+
### What can go wrong
|
|
56
|
+
|
|
57
|
+
An executable that cannot be resolved fails loud with a stable error; a spawn that never starts rejects `done`. A read past the retained tail is `lossy` and points at the spill file when one exists. A daemonized descendant that leaves the tree or terminal session can outlive cleanup — see the limitations below.
|
|
58
|
+
|
|
59
|
+
-----
|
|
6
60
|
|
|
7
|
-
|
|
61
|
+
<a id="understand-the-implementation"></a>
|
|
62
|
+
## Understand the implementation
|
|
8
63
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
|
12
|
-
- **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.
|
|
13
|
-
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
|
|
14
|
-
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
|
15
|
-
- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes.
|
|
16
|
-
- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
|
|
64
|
+
<details>
|
|
65
|
+
<summary>Implementation internals — click to expand</summary>
|
|
17
66
|
|
|
67
|
+
This section explains the design decisions behind the provider and points at the code that realizes them; the observable behavior is covered in [Use this package](#use-this-package).
|
|
68
|
+
|
|
69
|
+
### Design concept
|
|
70
|
+
|
|
71
|
+
The provider treats the process tree as the unit of lifetime. POSIX children spawn detached (their own process group) so the whole tree is signalled by negative group id with a direct-child fallback; Windows terminates by root pid through `taskkill /T`. Signalling, escalation, and teardown guard on tree liveness rather than direct-child settlement, so a TERM-trapping helper cannot outlive the handle unnoticed.
|
|
72
|
+
|
|
73
|
+
### Source map
|
|
74
|
+
|
|
75
|
+
| File | Role |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [`src/index.ts`](src/index.ts) | Service wiring: live-handle sets, disposal, host-exit finalization, executable lookup |
|
|
78
|
+
| [`src/spawn.ts`](src/spawn.ts) | Process plumbing: detached spawn, tail-keep collection, spill files, escalation, tree-exit observer |
|
|
79
|
+
| [`src/terminal.ts`](src/terminal.ts) | `node-pty` terminal handle: foreground inspection, session cleanup, Windows teardown |
|
|
80
|
+
| [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX process-tree and session inspection |
|
|
81
|
+
| [`src/windows-inspector.ts`](src/windows-inspector.ts) | Windows Toolhelp32 process-table inspection via koffi |
|
|
82
|
+
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam owns the contract) |
|
|
83
|
+
|
|
84
|
+
### Main flow
|
|
85
|
+
|
|
86
|
+
A spawn builds the scrubbed child environment, starts the detached process, attaches collectors to the collected streams, and returns a handle. `done` settles at process close after a bounded pipe-drain grace, so a surviving descendant that inherited a pipe cannot hold the outcome open indefinitely; the escalation timer survives direct-child settlement so SIGKILL still reaches tree survivors. Terminal cleanup sweeps descendants by exact identity, stops the shell, re-sweeps, and verifies absence through the process table.
|
|
87
|
+
|
|
88
|
+
### Safety invariants
|
|
89
|
+
|
|
90
|
+
Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Process identities carry start times, so cleanup never follows PID reuse. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence.
|
|
91
|
+
|
|
92
|
+
</details>
|
|
93
|
+
|
|
94
|
+
-----
|
|
95
|
+
|
|
96
|
+
<a id="further-exploration"></a>
|
|
97
|
+
## Further Exploration
|
|
98
|
+
|
|
99
|
+
Read these pages when the provider-level contract is not enough. They move from the exhaustive type reference to the abstract contract and the decisions behind the host mechanics.
|
|
100
|
+
|
|
101
|
+
- [Subprocess subsystem](../../../docs/subsystems/subprocess.md) — spawn specs, output readers, outcomes, and the `DSH_*` environment in full.
|
|
102
|
+
- [dsh-subprocess](../subprocess/README.md) — the abstract contract this provider implements.
|
|
103
|
+
- [dsh-bash-local](../../shell/bash-local/README.md) — the largest consumer and the concrete stdio shapes it asks for.
|
|
104
|
+
- [Subprocess seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md) — why the process half became its own seam.
|
|
105
|
+
- [Synchronous subprocess exit cleanup](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) — the host-exit finalization decision and its failure modes.
|
|
106
|
+
|
|
107
|
+
-----
|
|
108
|
+
|
|
109
|
+
<a id="model-experience"></a>
|
|
18
110
|
## Model Experience
|
|
19
111
|
|
|
20
|
-
Indirectly, through
|
|
112
|
+
Indirectly, through consumer seams such as the bash executor family, which own all model-facing rendering of spawned process output and lifecycle.
|
|
21
113
|
|
|
22
114
|
#### KV Cache effect
|
|
23
115
|
|
|
@@ -25,11 +117,24 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
|
|
25
117
|
|
|
26
118
|
## Known Limitations and Deferred Work
|
|
27
119
|
|
|
120
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog.
|
|
124
|
+
|
|
28
125
|
- **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.
|
|
29
|
-
- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation.
|
|
30
|
-
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the
|
|
31
|
-
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event
|
|
32
|
-
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently
|
|
33
|
-
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill
|
|
126
|
+
- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation.
|
|
127
|
+
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor.
|
|
128
|
+
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner.
|
|
129
|
+
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
|
|
130
|
+
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them.
|
|
131
|
+
|
|
132
|
+
<a id="dev-note"></a>
|
|
133
|
+
### Dev Note
|
|
134
|
+
|
|
135
|
+
<details>
|
|
136
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
137
|
+
|
|
138
|
+
None.
|
|
34
139
|
|
|
35
|
-
|
|
140
|
+
</details>
|
package/README.zh.md
CHANGED
|
@@ -1,35 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "面向部署方与维护者的子进程服务本地宿主提供方说明:在宿主机器上运行受管进程树与真实终端会话。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-subprocess-local
|
|
2
7
|
|
|
3
8
|
[English](README.md) | 中文
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## 概述
|
|
11
|
+
|
|
12
|
+
在需要于宿主机上运行子进程的组合中挂载 `dsh-subprocess-local`:它解析本地可执行文件、以显式 stdio 运行 detached 进程树,并通过 `node-pty` 提供真实终端会话。它没有任何配置,因此每项处置方式、限制、终端尺寸与宽限期都随 spawn 请求来自调用方能力 seam。输出收集在内存中保留一段有界尾部,并可选地用 spill 文件恢复完整流;子进程从清理后的环境起步;dispose(资源释放)会终止并等待每棵仍在运行的进程树退出。
|
|
13
|
+
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
24
|
+
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
27
|
+
|
|
28
|
+
把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。
|
|
29
|
+
|
|
30
|
+
### 挂载提供方
|
|
31
|
+
|
|
32
|
+
在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- name: '@deepseek-ai/dsh-subprocess-local'
|
|
36
|
+
- name: '@deepseek-ai/dsh-bash-local'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 解析可执行文件
|
|
40
|
+
|
|
41
|
+
绝对可执行文件路径会被验证;裸名称根据清理后的 PATH 并以平台感知的可执行文件扩展名(Windows 上为 `.COM`/`.EXE`/`.BAT`/`.CMD`)解析。含分隔符的相对路径会被拒绝——请提供绝对路径或裸 PATH 名称——相对 PATH 条目从宿主进程 cwd 解析。
|
|
42
|
+
|
|
43
|
+
### 收集输出
|
|
44
|
+
|
|
45
|
+
收集模式在内存中保留一条流的最后 `maxBytes`——错误与最终结果通常聚集在末尾——并在配置了 `spill` 上限时把完整流追加到 OS 临时目录下每进程目录中的私有文件(`0700` 目录、`0600` 随机命名文件)。某条流大于 spill 上限时,会丢弃不完整的 spill,只返回带截断标记的尾部。读取基于偏移量且从不消费,因此后台读取与批量读取在退出前后都可以共存。
|
|
46
|
+
|
|
47
|
+
### 运行终端会话
|
|
48
|
+
|
|
49
|
+
`spawnTerminal` 分配真实 PTY 并桥接 UTF-8 文本;你可以检查当前前台进程组并向其发送信号,还可以等待一次 `terminate()`,让提供方仍可观察到的每个会话成员完全停稳。在 Linux 上,精确输入等待要求前台线程的 fd 0 标识 shell 的控制终端,且线程当前的 syscall 正在等待该 fd。如果内核拒绝 syscall 探测,提供方不会报告精确等待,而由上层 PTY 后端使用空闲推断;进程睡眠状态不能作为证据。在 Windows 上,SIGINT 以 Ctrl-C 输入写入投递,SIGTSTP 与 SIGHUP 不受支持,拆卸会通过进程表验证 shell 已终止,因为被外部终止的 shell 可能永远不会触发 PTY 退出通知。
|
|
50
|
+
|
|
51
|
+
### 关闭行为
|
|
52
|
+
|
|
53
|
+
正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
|
|
54
|
+
|
|
55
|
+
### 可能出错的地方
|
|
56
|
+
|
|
57
|
+
无法解析的可执行文件会以稳定的错误快速失败;从未启动成功的 spawn 会让 `done` reject。越过保留尾部的读取是 `lossy` 的,并在 spill 文件存在时指向它。脱离进程树或终端会话的 daemon 化后代可能比清理更长寿——见下文限制。
|
|
58
|
+
|
|
59
|
+
-----
|
|
6
60
|
|
|
7
|
-
|
|
61
|
+
<a id="understand-the-implementation"></a>
|
|
62
|
+
## 理解实现
|
|
8
63
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。
|
|
12
|
-
- **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
|
13
|
-
- **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
|
|
14
|
-
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
|
|
15
|
-
- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
|
|
16
|
-
- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。
|
|
64
|
+
<details>
|
|
65
|
+
<summary>实现细节——点击展开</summary>
|
|
17
66
|
|
|
67
|
+
本节解释提供方背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中说明。
|
|
68
|
+
|
|
69
|
+
### 设计理念
|
|
70
|
+
|
|
71
|
+
本提供方把进程树视为生命周期单元。POSIX 子进程以 detached 方式 spawn(拥有独立进程组),因此整棵进程树以负进程组 id 发送信号,并以直接子进程作为回退;Windows 通过 `taskkill /T` 按根 pid 终止。信号发送、升级与拆卸都以进程树存活状态为守卫,而非以直接子进程结算为准,因此拦截 TERM 的辅助进程无法在无人察觉的情况下比句柄更长寿。
|
|
72
|
+
|
|
73
|
+
### 源码地图
|
|
74
|
+
|
|
75
|
+
| 文件 | 职责 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| [`src/index.ts`](src/index.ts) | 服务接线:存活句柄集合、dispose、宿主退出最终清理、可执行文件查找 |
|
|
78
|
+
| [`src/spawn.ts`](src/spawn.ts) | 进程管道:detached spawn、保尾收集、spill 文件、升级、进程树退出观察器 |
|
|
79
|
+
| [`src/terminal.ts`](src/terminal.ts) | `node-pty` 终端句柄:前台检查、会话清理、Windows 拆卸 |
|
|
80
|
+
| [`src/process-inspector.ts`](src/process-inspector.ts) | POSIX 进程树与会话检查 |
|
|
81
|
+
| [`src/windows-inspector.ts`](src/windows-inspector.ts) | 经 koffi 的 Windows Toolhelp32 进程表检查 |
|
|
82
|
+
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;约定归 seam 所有) |
|
|
83
|
+
|
|
84
|
+
### 主流程
|
|
85
|
+
|
|
86
|
+
一次 spawn 会构建清理后的子进程环境、启动 detached 进程、把收集器挂到收集模式的流上,然后返回句柄。`done` 在进程关闭后、经过一段有界管道排空宽限期才结算,因此继承了管道的存活后代无法无限期拖住结果;升级定时器在直接子进程结算后依然存活,使 SIGKILL 仍能到达进程树幸存者。终端清理按精确身份清扫后代、停止 shell、再次清扫,并通过进程表验证其已不存在。
|
|
87
|
+
|
|
88
|
+
### 安全不变式
|
|
89
|
+
|
|
90
|
+
spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。进程身份携带启动时间,因此清理绝不会跟随 PID 复用。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。
|
|
91
|
+
|
|
92
|
+
</details>
|
|
93
|
+
|
|
94
|
+
-----
|
|
95
|
+
|
|
96
|
+
<a id="further-exploration"></a>
|
|
97
|
+
## 进一步探索
|
|
98
|
+
|
|
99
|
+
当提供方级约定不够用时阅读以下页面。它们从穷尽式类型参考逐步进入抽象约定,以及宿主机制背后的决策。
|
|
100
|
+
|
|
101
|
+
- [子进程子系统](../../../docs/subsystems/subprocess.zh.md)——spawn spec、输出读取器、结果与完整的 `DSH_*` 环境。
|
|
102
|
+
- [dsh-subprocess](../subprocess/README.zh.md)——本提供方实现的抽象约定。
|
|
103
|
+
- [dsh-bash-local](../../shell/bash-local/README.zh.md)——最大的消费方及其请求的具体 stdio 形态。
|
|
104
|
+
- [subprocess seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md)——进程部分为何成为独立的 seam。
|
|
105
|
+
- [同步子进程退出清理](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)——宿主退出最终清理决策及其失败模式。
|
|
106
|
+
|
|
107
|
+
-----
|
|
108
|
+
|
|
109
|
+
<a id="model-experience"></a>
|
|
18
110
|
## 模型体验
|
|
19
111
|
|
|
20
|
-
|
|
112
|
+
通过消费方 seam(例如 bash 执行器家族)间接影响,它们负责所 spawn 进程的输出与生命周期的全部面向模型渲染。
|
|
21
113
|
|
|
22
114
|
#### KV Cache 影响
|
|
23
115
|
|
|
24
116
|
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
|
25
117
|
|
|
26
|
-
##
|
|
118
|
+
## 已知限制与延期工作
|
|
119
|
+
|
|
120
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用平台对比或任务积压。
|
|
124
|
+
|
|
125
|
+
- **Windows 进程树支持仅为尽力而为**——终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
|
|
126
|
+
- **Windows 终端信号是控制台级的**——SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 因不可用而被拒绝;不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。
|
|
127
|
+
- **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。
|
|
128
|
+
- **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
|
|
129
|
+
- **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
|
130
|
+
- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。
|
|
131
|
+
|
|
132
|
+
<a id="dev-note"></a>
|
|
133
|
+
### 开发备注
|
|
134
|
+
|
|
135
|
+
<details>
|
|
136
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
27
137
|
|
|
28
|
-
|
|
29
|
-
- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
|
|
30
|
-
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
|
|
31
|
-
- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
|
|
32
|
-
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
|
33
|
-
- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。
|
|
138
|
+
无。
|
|
34
139
|
|
|
35
|
-
|
|
140
|
+
</details>
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
2
|
import { access, stat } from "node:fs/promises";
|
|
3
3
|
import { delimiter, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import * as nodePty from "node-pty";
|
|
@@ -70,19 +70,21 @@ var WindowsProcessInspector = class {
|
|
|
70
70
|
foregroundPgid(shellPid) {
|
|
71
71
|
return shellPid;
|
|
72
72
|
}
|
|
73
|
-
isStdinWaiting(_pgid) {
|
|
73
|
+
isStdinWaiting(_pgid, _shellPid) {
|
|
74
74
|
return false;
|
|
75
75
|
}
|
|
76
|
-
processTree(rootPid) {
|
|
77
|
-
return windowsProcessTree(this.internals.snapshot(), rootPid, (pid) => this.internals.processState(pid)?.started);
|
|
78
|
-
}
|
|
79
|
-
processSession(_sessionId) {
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
76
|
isAlive(identity) {
|
|
83
77
|
const state = this.internals.processState(identity.pid);
|
|
84
78
|
return state?.active === true && state.started === identity.started;
|
|
85
79
|
}
|
|
80
|
+
snapshot() {
|
|
81
|
+
let entries;
|
|
82
|
+
return {
|
|
83
|
+
tree: (rootPid) => windowsProcessTree(entries ??= this.internals.snapshot(), rootPid, (pid) => this.internals.processState(pid)?.started),
|
|
84
|
+
session: () => [],
|
|
85
|
+
alive: (identity) => this.isAlive(identity)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
86
88
|
signalGroup(pgid, signal) {
|
|
87
89
|
this.internals.taskkill(pgid, signal === "SIGKILL");
|
|
88
90
|
}
|
|
@@ -264,6 +266,8 @@ function defaultWindowsProcessInternals() {
|
|
|
264
266
|
const DEFAULT_INTERNALS = {
|
|
265
267
|
readFile: (path) => readFileSync(path, "utf8"),
|
|
266
268
|
readDir: (path) => readdirSync(path),
|
|
269
|
+
readLink: (path) => readlinkSync(path, "utf8"),
|
|
270
|
+
stat: (path) => statSync(path),
|
|
267
271
|
open: (path) => openSync(path, "r"),
|
|
268
272
|
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
|
|
269
273
|
close: closeSync,
|
|
@@ -285,6 +289,7 @@ function parseProcStat(text) {
|
|
|
285
289
|
const parentPid = Number(rest[1]);
|
|
286
290
|
const pgrp = Number(rest[2]);
|
|
287
291
|
const session = Number(rest[3]);
|
|
292
|
+
const ttyDevice = Number(rest[4]);
|
|
288
293
|
const tpgid = Number(rest[5]);
|
|
289
294
|
const started = rest[19];
|
|
290
295
|
if (![
|
|
@@ -292,6 +297,7 @@ function parseProcStat(text) {
|
|
|
292
297
|
parentPid,
|
|
293
298
|
pgrp,
|
|
294
299
|
session,
|
|
300
|
+
ttyDevice,
|
|
295
301
|
tpgid
|
|
296
302
|
].every(Number.isSafeInteger) || state.length !== 1 || started === void 0) return void 0;
|
|
297
303
|
return {
|
|
@@ -300,6 +306,7 @@ function parseProcStat(text) {
|
|
|
300
306
|
pgrp,
|
|
301
307
|
session,
|
|
302
308
|
state,
|
|
309
|
+
ttyDevice,
|
|
303
310
|
tpgid,
|
|
304
311
|
started
|
|
305
312
|
};
|
|
@@ -311,6 +318,21 @@ function readLinuxStat(internals, pid) {
|
|
|
311
318
|
return;
|
|
312
319
|
}
|
|
313
320
|
}
|
|
321
|
+
function linuxDeviceNumber(value) {
|
|
322
|
+
return value >>> 0;
|
|
323
|
+
}
|
|
324
|
+
function readLinuxTerminalDevice(internals, pid, ttyDevice, tid) {
|
|
325
|
+
const terminalDevice = linuxDeviceNumber(ttyDevice);
|
|
326
|
+
if (terminalDevice === 0) return void 0;
|
|
327
|
+
const path = tid === void 0 ? `/proc/${pid}/fd/0` : `/proc/${pid}/task/${tid}/fd/0`;
|
|
328
|
+
try {
|
|
329
|
+
if (internals.readLink(path) === "/dev/tty") return terminalDevice;
|
|
330
|
+
const status = internals.stat(path);
|
|
331
|
+
return status.isCharacterDevice() && linuxDeviceNumber(status.rdev) === terminalDevice ? terminalDevice : void 0;
|
|
332
|
+
} catch (_unreadableStdinDevice) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
314
336
|
/**
|
|
315
337
|
* Report whether a Linux process group has an executing member. `false`
|
|
316
338
|
* means the group contains only zombie/dead entries; `undefined` means the
|
|
@@ -382,9 +404,9 @@ function pollHasStdin(internals, pid, address, count) {
|
|
|
382
404
|
for (let offset = 0; offset + 8 <= memory.length; offset += 8) if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 1) !== 0) return true;
|
|
383
405
|
return false;
|
|
384
406
|
}
|
|
385
|
-
function epollHasStdin(internals, pid, epfd) {
|
|
407
|
+
function epollHasStdin(internals, pid, tid, epfd) {
|
|
386
408
|
try {
|
|
387
|
-
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
|
|
409
|
+
return internals.readFile(`/proc/${pid}/task/${tid}/fdinfo/${epfd}`).split("\n").some((line) => /^tfd:\s+0\b/.test(line.trim()));
|
|
388
410
|
} catch (_unreadableFdInfo) {
|
|
389
411
|
return false;
|
|
390
412
|
}
|
|
@@ -406,12 +428,20 @@ const SYSCALLS = {
|
|
|
406
428
|
epollPwait: 22
|
|
407
429
|
}
|
|
408
430
|
};
|
|
409
|
-
|
|
431
|
+
const SUPPORTED_SYSCALL_TABLES = Object.values(SYSCALLS);
|
|
432
|
+
function linuxSyscallTables(arch) {
|
|
433
|
+
const primary = SYSCALLS[arch];
|
|
434
|
+
if (primary === void 0) return void 0;
|
|
435
|
+
return [primary, ...SUPPORTED_SYSCALL_TABLES.filter((table) => table !== primary)];
|
|
436
|
+
}
|
|
437
|
+
function syscallWaitsOnStdin(internals, pid, tid, syscall, tables) {
|
|
410
438
|
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
439
|
+
for (const table of tables) {
|
|
440
|
+
if (syscall.number === table.read) return a0 === 0;
|
|
441
|
+
if (syscall.number === table.select || syscall.number === table.pselect) return a0 >= 1 && fdSetHasStdin(internals, pid, a1);
|
|
442
|
+
if (syscall.number === table.poll || syscall.number === table.ppoll) return a1 >= 1 && pollHasStdin(internals, pid, a0, a1);
|
|
443
|
+
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) return a2 >= 1 && epollHasStdin(internals, pid, tid, a0);
|
|
444
|
+
}
|
|
415
445
|
return false;
|
|
416
446
|
}
|
|
417
447
|
var PosixProcessInspector = class {
|
|
@@ -426,6 +456,30 @@ var PosixProcessInspector = class {
|
|
|
426
456
|
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal);
|
|
427
457
|
}
|
|
428
458
|
};
|
|
459
|
+
function quiescent(state) {
|
|
460
|
+
return state !== void 0 && /^[ZXx]$/.test(state);
|
|
461
|
+
}
|
|
462
|
+
var PosixProcessSnapshot = class {
|
|
463
|
+
rows;
|
|
464
|
+
byPid;
|
|
465
|
+
constructor(rows) {
|
|
466
|
+
this.rows = rows;
|
|
467
|
+
this.byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
468
|
+
}
|
|
469
|
+
tree(rootPid) {
|
|
470
|
+
return processTree(this.rows, rootPid);
|
|
471
|
+
}
|
|
472
|
+
session(sessionId) {
|
|
473
|
+
return this.rows.flatMap((row) => row.session === sessionId ? [{
|
|
474
|
+
pid: row.pid,
|
|
475
|
+
started: row.started
|
|
476
|
+
}] : []);
|
|
477
|
+
}
|
|
478
|
+
alive(identity) {
|
|
479
|
+
const row = this.byPid.get(identity.pid);
|
|
480
|
+
return row?.started === identity.started && !quiescent(row.state);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
429
483
|
function processTree(entries, rootPid) {
|
|
430
484
|
const root = new Map(entries.map((entry) => [entry.pid, entry])).get(rootPid);
|
|
431
485
|
if (root === void 0) return [];
|
|
@@ -459,40 +513,38 @@ var LinuxProcessInspector = class extends PosixProcessInspector {
|
|
|
459
513
|
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid;
|
|
460
514
|
return tpgid !== void 0 && tpgid > 0 ? tpgid : void 0;
|
|
461
515
|
}
|
|
462
|
-
isStdinWaiting(pgid) {
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
516
|
+
isStdinWaiting(pgid, shellPid) {
|
|
517
|
+
const tables = linuxSyscallTables(this.arch);
|
|
518
|
+
if (tables === void 0) return false;
|
|
519
|
+
const shell = readLinuxStat(this.internals, shellPid);
|
|
520
|
+
if (shell === void 0) return false;
|
|
521
|
+
const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice);
|
|
522
|
+
if (terminalDevice === void 0) return false;
|
|
465
523
|
for (const pid of numericEntries(this.internals, "/proc")) {
|
|
466
|
-
|
|
524
|
+
const process = readLinuxStat(this.internals, pid);
|
|
525
|
+
if (process?.pgrp !== pgid) continue;
|
|
467
526
|
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
|
|
468
527
|
const syscall = readSyscall(this.internals, pid, tid);
|
|
469
|
-
if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, syscall,
|
|
528
|
+
if (syscall !== void 0 && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables) && readLinuxTerminalDevice(this.internals, pid, process.ttyDevice, tid) === terminalDevice) return true;
|
|
470
529
|
}
|
|
471
530
|
}
|
|
472
531
|
return false;
|
|
473
532
|
}
|
|
474
|
-
|
|
475
|
-
|
|
533
|
+
isAlive(identity) {
|
|
534
|
+
const stat = readLinuxStat(this.internals, identity.pid);
|
|
535
|
+
return stat?.started === identity.started && !quiescent(stat.state);
|
|
536
|
+
}
|
|
537
|
+
snapshot() {
|
|
538
|
+
return new PosixProcessSnapshot(numericEntries(this.internals, "/proc").flatMap((pid) => {
|
|
476
539
|
const stat = readLinuxStat(this.internals, pid);
|
|
477
540
|
return stat === void 0 ? [] : [{
|
|
478
541
|
pid,
|
|
479
542
|
parentPid: stat.parentPid,
|
|
480
|
-
started: stat.started
|
|
543
|
+
started: stat.started,
|
|
544
|
+
session: stat.session,
|
|
545
|
+
state: stat.state
|
|
481
546
|
}];
|
|
482
|
-
})
|
|
483
|
-
}
|
|
484
|
-
processSession(sessionId) {
|
|
485
|
-
return numericEntries(this.internals, "/proc").flatMap((pid) => {
|
|
486
|
-
const stat = readLinuxStat(this.internals, pid);
|
|
487
|
-
return stat?.session === sessionId ? [{
|
|
488
|
-
pid,
|
|
489
|
-
started: stat.started
|
|
490
|
-
}] : [];
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
isAlive(identity) {
|
|
494
|
-
const stat = readLinuxStat(this.internals, identity.pid);
|
|
495
|
-
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state);
|
|
547
|
+
}));
|
|
496
548
|
}
|
|
497
549
|
};
|
|
498
550
|
function macProcessTable(internals) {
|
|
@@ -502,7 +554,9 @@ function macProcessTable(internals) {
|
|
|
502
554
|
return [{
|
|
503
555
|
pid: Number(match[1]),
|
|
504
556
|
parentPid: Number(match[2]),
|
|
505
|
-
started: match[3]
|
|
557
|
+
started: match[3],
|
|
558
|
+
session: void 0,
|
|
559
|
+
state: void 0
|
|
506
560
|
}];
|
|
507
561
|
});
|
|
508
562
|
}
|
|
@@ -520,18 +574,15 @@ var MacProcessInspector = class extends PosixProcessInspector {
|
|
|
520
574
|
return;
|
|
521
575
|
}
|
|
522
576
|
}
|
|
523
|
-
isStdinWaiting(_pgid) {
|
|
577
|
+
isStdinWaiting(_pgid, _shellPid) {
|
|
524
578
|
return false;
|
|
525
579
|
}
|
|
526
|
-
processTree(rootPid) {
|
|
527
|
-
return processTree(macProcessTable(this.internals), rootPid);
|
|
528
|
-
}
|
|
529
|
-
processSession(_sessionId) {
|
|
530
|
-
return [];
|
|
531
|
-
}
|
|
532
580
|
isAlive(identity) {
|
|
533
581
|
return macProcessTable(this.internals).some((entry) => entry.pid === identity.pid && entry.started === identity.started);
|
|
534
582
|
}
|
|
583
|
+
snapshot() {
|
|
584
|
+
return new PosixProcessSnapshot(macProcessTable(this.internals));
|
|
585
|
+
}
|
|
535
586
|
};
|
|
536
587
|
/**
|
|
537
588
|
* Create the supported platform inspector or fail at plugin load.
|
|
@@ -997,7 +1048,7 @@ var LocalTerminalHandle = class {
|
|
|
997
1048
|
this.graceMs = graceMs;
|
|
998
1049
|
this.platform = platform;
|
|
999
1050
|
this.pid = terminal.pid;
|
|
1000
|
-
this.rootIdentity = inspector.
|
|
1051
|
+
this.rootIdentity = inspector.snapshot().tree(this.pid).find((member) => member.pid === this.pid);
|
|
1001
1052
|
this.done = this.outcome.promise;
|
|
1002
1053
|
this.dataDisposable = terminal.onData((data) => {
|
|
1003
1054
|
this.output.write(Buffer$1.from(data, "utf8"));
|
|
@@ -1017,12 +1068,12 @@ var LocalTerminalHandle = class {
|
|
|
1017
1068
|
this.terminal.write(data);
|
|
1018
1069
|
}
|
|
1019
1070
|
async inspectForeground() {
|
|
1020
|
-
this.descendants();
|
|
1071
|
+
this.descendants(this.inspector.snapshot());
|
|
1021
1072
|
const processGroupId = this.inspector.foregroundPgid(this.pid);
|
|
1022
1073
|
if (processGroupId === void 0) return void 0;
|
|
1023
1074
|
return {
|
|
1024
1075
|
processGroupId,
|
|
1025
|
-
inputWaiting: this.inspector.isStdinWaiting(processGroupId)
|
|
1076
|
+
inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid)
|
|
1026
1077
|
};
|
|
1027
1078
|
}
|
|
1028
1079
|
async signalForeground(signal) {
|
|
@@ -1069,22 +1120,23 @@ var LocalTerminalHandle = class {
|
|
|
1069
1120
|
this.terminal.kill("SIGKILL");
|
|
1070
1121
|
} catch (_unidentifiedShellExitedDuringHostExit) {}
|
|
1071
1122
|
}
|
|
1072
|
-
survivors(members) {
|
|
1073
|
-
return members.filter((member) =>
|
|
1123
|
+
survivors(members, observed) {
|
|
1124
|
+
return members.filter((member) => observed.alive(member));
|
|
1074
1125
|
}
|
|
1075
|
-
descendants() {
|
|
1076
|
-
const tree =
|
|
1126
|
+
descendants(observed) {
|
|
1127
|
+
const tree = observed.tree(this.pid);
|
|
1077
1128
|
const root = tree.find((member) => member.pid === this.pid);
|
|
1078
1129
|
const rootVerified = this.rootIdentity !== void 0 && root !== void 0 && root.started === this.rootIdentity.started;
|
|
1079
|
-
this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree,
|
|
1130
|
+
this.trackedDescendants = this.survivors(this.unionMembers(this.trackedDescendants, ...rootVerified ? [tree, observed.session(this.pid)] : []).filter((member) => member.pid !== this.pid), observed);
|
|
1080
1131
|
return this.trackedDescendants;
|
|
1081
1132
|
}
|
|
1082
1133
|
async waitForMembers(members) {
|
|
1134
|
+
if (members.length === 0) return [];
|
|
1083
1135
|
const until = Date.now() + this.graceMs;
|
|
1084
|
-
let survivors = this.survivors(members);
|
|
1136
|
+
let survivors = this.survivors(members, this.inspector.snapshot());
|
|
1085
1137
|
while (survivors.length > 0 && Date.now() < until) {
|
|
1086
1138
|
await delay(Math.min(25, Math.max(1, until - Date.now())));
|
|
1087
|
-
survivors = this.survivors(members);
|
|
1139
|
+
survivors = this.survivors(members, this.inspector.snapshot());
|
|
1088
1140
|
}
|
|
1089
1141
|
return survivors;
|
|
1090
1142
|
}
|
|
@@ -1096,7 +1148,7 @@ var LocalTerminalHandle = class {
|
|
|
1096
1148
|
forceStopDescendants() {
|
|
1097
1149
|
let members = this.trackedDescendants;
|
|
1098
1150
|
try {
|
|
1099
|
-
members = this.descendants();
|
|
1151
|
+
members = this.descendants(this.inspector.snapshot());
|
|
1100
1152
|
} catch (_processTableUnavailableDuringHostExit) {}
|
|
1101
1153
|
this.signalMembers(members, "SIGKILL");
|
|
1102
1154
|
}
|
|
@@ -1112,13 +1164,14 @@ var LocalTerminalHandle = class {
|
|
|
1112
1164
|
return members;
|
|
1113
1165
|
}
|
|
1114
1166
|
async stopDescendants() {
|
|
1115
|
-
const captured = this.descendants();
|
|
1167
|
+
const captured = this.descendants(this.inspector.snapshot());
|
|
1116
1168
|
this.signalMembers(captured, "SIGTERM");
|
|
1117
1169
|
const capturedSurvivors = await this.waitForMembers(captured);
|
|
1118
|
-
const members = this.unionMembers(capturedSurvivors, this.descendants());
|
|
1170
|
+
const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot()));
|
|
1119
1171
|
this.signalMembers(members, "SIGKILL");
|
|
1120
1172
|
const survivors = await this.waitForMembers(members);
|
|
1121
|
-
|
|
1173
|
+
const observed = this.inspector.snapshot();
|
|
1174
|
+
return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed);
|
|
1122
1175
|
}
|
|
1123
1176
|
async stopShell() {
|
|
1124
1177
|
if (this.platform === "win32") {
|
|
@@ -5,23 +5,93 @@ export interface ProcessIdentity {
|
|
|
5
5
|
pid: number;
|
|
6
6
|
started: string;
|
|
7
7
|
}
|
|
8
|
+
interface FileStatus {
|
|
9
|
+
readonly rdev: number;
|
|
10
|
+
isCharacterDevice(): boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One observation of the platform process table, shared by every question a
|
|
14
|
+
* single readiness poll or teardown pass asks.
|
|
15
|
+
*
|
|
16
|
+
* The table is read at most once, on the first question that needs it — a
|
|
17
|
+
* `/bin/ps` fork on macOS, a `/proc` walk on Linux, a Toolhelp32 enumeration on
|
|
18
|
+
* Windows. Later questions never re-read it, which is what keeps a poll's cost
|
|
19
|
+
* independent of how many descendants the running command spawned. Windows
|
|
20
|
+
* liveness needs no table at all: wait state is a per-handle question there, so
|
|
21
|
+
* a snapshot asked only for liveness never enumerates.
|
|
22
|
+
*
|
|
23
|
+
* A snapshot answers what the process table showed, which is what batch
|
|
24
|
+
* filtering wants and what signalling must not use: {@link ProcessInspector.isAlive}
|
|
25
|
+
* is the fence a signal takes, because it reads current state instead.
|
|
26
|
+
*/
|
|
27
|
+
export interface ProcessSnapshot {
|
|
28
|
+
/**
|
|
29
|
+
* Return the root and its transitive descendants as observed, children first.
|
|
30
|
+
* @param rootPid - tree root to descend from.
|
|
31
|
+
* @returns Observed root and descendants, children before parents.
|
|
32
|
+
*/
|
|
33
|
+
tree(rootPid: number): ProcessIdentity[];
|
|
34
|
+
/**
|
|
35
|
+
* Return observed members of one POSIX process session.
|
|
36
|
+
* @param sessionId - POSIX session identifier.
|
|
37
|
+
* @returns Observed session members, empty where the platform's table omits session ids.
|
|
38
|
+
*/
|
|
39
|
+
session(sessionId: number): ProcessIdentity[];
|
|
40
|
+
/**
|
|
41
|
+
* Return whether the exact identity was a non-quiescent process.
|
|
42
|
+
* @param identity - PID plus start identity to match.
|
|
43
|
+
* @returns Whether that exact identity — not merely that PID — was running.
|
|
44
|
+
*/
|
|
45
|
+
alive(identity: ProcessIdentity): boolean;
|
|
46
|
+
}
|
|
8
47
|
/** Injectable OS process operations used by one local PTY session. */
|
|
9
48
|
export interface ProcessInspector {
|
|
10
49
|
foregroundPgid(shellPid: number): number | undefined;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Report whether the foreground group waits on the terminal shell's stdin.
|
|
52
|
+
*
|
|
53
|
+
* @param pgid Foreground process-group identifier.
|
|
54
|
+
* @param shellPid Persistent terminal shell process identifier.
|
|
55
|
+
* @returns Whether a group member is blocked reading the shell's terminal input.
|
|
56
|
+
*/
|
|
57
|
+
isStdinWaiting(pgid: number, shellPid: number): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Read the process table once and answer tree, session, and liveness from it.
|
|
60
|
+
* @returns A process-table observation whose reads are shared.
|
|
61
|
+
*/
|
|
62
|
+
snapshot(): ProcessSnapshot;
|
|
63
|
+
/**
|
|
64
|
+
* Return whether the exact identity is a non-quiescent process right now.
|
|
65
|
+
*
|
|
66
|
+
* Reads the narrowest per-identity source the platform offers rather than a
|
|
67
|
+
* whole table, so a signalling round can re-check every target without
|
|
68
|
+
* paying for a scan. Callers filtering many members at once want
|
|
69
|
+
* {@link ProcessSnapshot.alive} instead.
|
|
70
|
+
*
|
|
71
|
+
* @param identity - PID plus start identity to match.
|
|
72
|
+
* @returns Whether that exact identity — not merely that PID — is running.
|
|
73
|
+
*/
|
|
17
74
|
isAlive(identity: ProcessIdentity): boolean;
|
|
18
75
|
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void;
|
|
76
|
+
/**
|
|
77
|
+
* Signal one exact process identity, fenced against PID reuse.
|
|
78
|
+
*
|
|
79
|
+
* The fence reads current state immediately before the signal. An observation
|
|
80
|
+
* taken earlier in the same round cannot stand in for it: the observation
|
|
81
|
+
* preserves the original PID-to-start-time pairing, so a recycled PID would
|
|
82
|
+
* still match and take a signal meant for the process that exited.
|
|
83
|
+
*
|
|
84
|
+
* @param identity - PID plus start identity to signal.
|
|
85
|
+
* @param signal - termination signal to deliver.
|
|
86
|
+
*/
|
|
19
87
|
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void;
|
|
20
88
|
}
|
|
21
89
|
/** Testable boundary around filesystem, process-table, and signal syscalls. */
|
|
22
90
|
export interface ProcessInspectorInternals {
|
|
23
91
|
readFile(path: string): string;
|
|
24
92
|
readDir(path: string): string[];
|
|
93
|
+
readLink(path: string): string;
|
|
94
|
+
stat(path: string): FileStatus;
|
|
25
95
|
open(path: string): number;
|
|
26
96
|
read(fd: number, buffer: Buffer, length: number, position: number): number;
|
|
27
97
|
close(fd: number): void;
|
|
@@ -34,6 +104,7 @@ interface ProcStat {
|
|
|
34
104
|
pgrp: number;
|
|
35
105
|
session: number;
|
|
36
106
|
state: string;
|
|
107
|
+
ttyDevice: number;
|
|
37
108
|
tpgid: number;
|
|
38
109
|
started: string;
|
|
39
110
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module dsh-subprocess-local/windows-inspector
|
|
10
10
|
*/
|
|
11
11
|
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess';
|
|
12
|
-
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts';
|
|
12
|
+
import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts';
|
|
13
13
|
/** One Toolhelp32 process-table row. */
|
|
14
14
|
export interface ProcessEntry {
|
|
15
15
|
pid: number;
|
|
@@ -52,10 +52,9 @@ export declare class WindowsProcessInspector implements ProcessInspector {
|
|
|
52
52
|
private readonly internals;
|
|
53
53
|
constructor(internals?: WindowsProcessInspectorInternals);
|
|
54
54
|
foregroundPgid(shellPid: number): number;
|
|
55
|
-
isStdinWaiting(_pgid: number): boolean;
|
|
56
|
-
processTree(rootPid: number): ProcessIdentity[];
|
|
57
|
-
processSession(_sessionId: number): ProcessIdentity[];
|
|
55
|
+
isStdinWaiting(_pgid: number, _shellPid: number): boolean;
|
|
58
56
|
isAlive(identity: ProcessIdentity): boolean;
|
|
57
|
+
snapshot(): ProcessSnapshot;
|
|
59
58
|
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void;
|
|
60
59
|
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void;
|
|
61
60
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-subprocess-local",
|
|
3
3
|
"description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2-alpha.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -33,21 +33,21 @@
|
|
|
33
33
|
],
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@deepseek-ai/dsh-
|
|
37
|
-
"@deepseek-ai/dsh-
|
|
38
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
39
|
-
"@deepseek-ai/dsh-
|
|
36
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
|
|
37
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
39
|
+
"@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"koffi": "^3.1.0",
|
|
43
43
|
"node-pty": "1.2.0-beta.15"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@deepseek-ai/dsh-invariants": "^0.1.
|
|
47
|
-
"@deepseek-ai/dsh-loader-smoke": "^0.1.
|
|
48
|
-
"@deepseek-ai/dsh-subprocess": "^0.1.
|
|
49
|
-
"@deepseek-ai/dsh-timeout": "^0.1.
|
|
50
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
46
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
|
|
47
|
+
"@deepseek-ai/dsh-loader-smoke": "^0.1.2-alpha.2",
|
|
48
|
+
"@deepseek-ai/dsh-subprocess": "^0.1.2-alpha.2",
|
|
49
|
+
"@deepseek-ai/dsh-timeout": "^0.1.2-alpha.2",
|
|
50
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"postinstall": "node scripts/ensure-spawn-helper.mjs"
|