mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(runtime): collapse speculative portability layers
Remove the one-consumer bounded-read primitive and shared terminal lifecycle controller, make terminal cleanup one awaited provider operation, and reuse one Code Runtime contract suite. Keep only reproduced cancellation and policy fixes; defer unproven replacement, prompt-attribution, and streaming-frame concerns to scoped markers.
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 .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md
|
||||
2026-06-17-filesystem-capability-seam.md: 2fb360e5ad3c972f4a8de50c602a79af158dfc22
|
||||
2026-06-17-filesystem-capability-seam.zh.md: 1f70113b1583d890047ee4a17161f76b6390d8e8
|
||||
2026-06-17-filesystem-capability-seam.md: 7436d2a9402c76f17c7d6571eb489a86e550ebfb
|
||||
2026-06-17-filesystem-capability-seam.zh.md: fdeafba387b562352b377321327c526a67669ef6
|
||||
|
||||
@@ -64,7 +64,7 @@ The interface covers these semantic operations:
|
||||
- Resolve a model/plugin-supplied path into a backend-defined target.
|
||||
- Convert a resolved target to the canonical process path or `file:` URI for the same execution world, and test containment without parsing its opaque key.
|
||||
- Stat target metadata without reading file contents.
|
||||
- Read complete or streamed UTF-8 text, including one stable-handle byte-bounded whole-file operation.
|
||||
- Read complete or streamed UTF-8 text; consumers apply their own view and retention limits.
|
||||
- Create or replace a UTF-8 text file.
|
||||
- Edit an existing UTF-8 text file by literal replacement.
|
||||
|
||||
@@ -88,7 +88,7 @@ Resolved targets must expose at least three concepts:
|
||||
|
||||
Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
|
||||
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files, and `readTextBounded` holds one backend-owned stable handle while rejecting a complete file above its byte ceiling. Line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`). The provider owns regular-file checks, UTF-8 decoding, binary/NUL rejection, and the bounded read's replacement/growth race; it does not know about line windows or views.
|
||||
The provider hands back decoded text: `readText` returns a whole regular text file and `streamText` streams the same text semantics for large files or consumer-owned retention limits. Line windowing, byte ceilings, numbered-line rendering, and total-line accounting live in consumers such as `dsh-tool-fs` and `dsh-lsp-local`. The provider owns regular-file checks, UTF-8 decoding, and binary/NUL rejection; it does not know about line windows, protocol limits, or views.
|
||||
|
||||
Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
- 将模型/插件提供的路径解析为后端定义的目标。
|
||||
- 将解析后的目标转换为同一执行环境的规范进程路径或 `file:` URI,并在不解析其不透明键的情况下检查包含关系。
|
||||
- 获取目标元数据而不读取文件内容。
|
||||
- 读取完整或流式 UTF-8 文本,其中包括一项持有稳定句柄、以字节为上限的全文件读取操作。
|
||||
- 读取完整或流式 UTF-8 文本;消费方执行各自的视图与保留上限。
|
||||
- 创建或替换一个 UTF-8 文本文件。
|
||||
- 通过字面替换编辑一个已有的 UTF-8 文本文件。
|
||||
|
||||
@@ -88,7 +88,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local`
|
||||
|
||||
读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。
|
||||
|
||||
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义,`readTextBounded` 则持有一个归后端所有的稳定句柄,并在完整文件超过字节上限时拒绝。行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中。提供方负责普通文件检查、UTF-8 解码、二进制/NUL 拒绝,以及有界读取期间的路径替换/增长竞态;它不知道行窗口或视图。
|
||||
提供方返回已解码的文本:`readText` 返回整个普通文本文件,`streamText` 为大文件或消费方自有的保留上限流式传输相同的文本语义。行窗口化、字节上限、带行号渲染和总行数统计归 `dsh-tool-fs`、`dsh-lsp-local` 等消费方所有。提供方负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口、协议上限或视图。
|
||||
|
||||
观测状态记录不在 `ctx.fs` 上:成功读取后,执行器发出 `fs/observed`,`dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md
|
||||
2026-07-15-lsp-capability-seam.md: 233a7321e1b4fded955144ed84fbc34df49e8964
|
||||
2026-07-15-lsp-capability-seam.zh.md: 547249fc22844878954fe7120dd8b5c87aabfc1a
|
||||
2026-07-15-lsp-capability-seam.md: 63cec0a4e349ffc5be27a84e955b15e9168f898b
|
||||
2026-07-15-lsp-capability-seam.zh.md: cdb1289f6956d8a4e46ed92847ddd8a58e588afa
|
||||
|
||||
@@ -112,13 +112,13 @@ Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutd
|
||||
|
||||
## Workspace, filesystem, and document synchronization
|
||||
|
||||
`dsh-lsp-local` canonicalizes and reads through `ctx.fs` in the language server's execution world. It requires the workspace target to be a directory, rejects out-of-workspace sources through provider-owned containment, and uses `readTextBounded` so regular-file validation, UTF-8 decoding, the byte ceiling, and path replacement/growth safety stay one filesystem operation. It fuses caller cancellation with provider disposal across each filesystem operation, tracks workspace lookups before they enter a queue, and awaits those lookups during disposal. It does not emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
|
||||
`dsh-lsp-local` canonicalizes and reads through `ctx.fs` in the language server's execution world. It requires the workspace target to be a directory, rejects out-of-workspace sources through provider-owned containment, consumes `streamText`, and enforces `maxDocumentBytes` as chunks arrive; the provider retains regular-file validation and UTF-8 decoding while the protocol consumer owns its document limit. It fuses caller cancellation with provider disposal across each filesystem operation, tracks workspace lookups before they enter a queue, and awaits those lookups during disposal. It does not emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
|
||||
|
||||
The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers.
|
||||
|
||||
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
|
||||
|
||||
1. Resolve and contain the source through `ctx.fs`, then read its current bounded text through the same provider.
|
||||
1. Resolve and contain the source through `ctx.fs`, then stream its current text through the same provider while enforcing the document byte limit.
|
||||
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it.
|
||||
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
|
||||
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
|
||||
@@ -157,7 +157,7 @@ The provider trusts its configured server. Its filesystem visibility and process
|
||||
|
||||
**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
|
||||
|
||||
**Read through the model-facing `read` tool.** Rejected because tool output is windowed, numbered, transcript-visible, and observed. The provider reads bounded full text directly through the same `ctx.fs` execution world used by its subprocess.
|
||||
**Read through the model-facing `read` tool.** Rejected because tool output is windowed, numbered, transcript-visible, and observed. The provider consumes streamed full text directly through the same `ctx.fs` execution world used by its subprocess.
|
||||
|
||||
**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine.
|
||||
|
||||
@@ -195,4 +195,4 @@ Extension ownership is exclusive within one runtime. Two providers cannot both c
|
||||
|
||||
UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use.
|
||||
|
||||
The paired filesystem/subprocess providers align the query snapshot with the server index but do not make a trusted language server safe. Canonical containment rejects query sources outside the workspace; the server itself receives the execution world's configured authority and may read other paths or use caches.
|
||||
The paired filesystem/subprocess providers align the query snapshot with the server index but do not make a trusted language server safe. Canonical containment rejects query sources outside the workspace at resolution time, but stream opening does not add stable-handle identity across a concurrent path replacement; the server itself receives the execution world's configured authority and may read other paths or use caches.
|
||||
|
||||
@@ -112,13 +112,13 @@ interface LspToolInput {
|
||||
|
||||
## 工作区、文件系统与文档同步
|
||||
|
||||
`dsh-lsp-local` 在语言服务器的执行环境中通过 `ctx.fs` 规范化并读取文件。它要求工作区目标是目录,使用提供方自有的 containment 拒绝工作区外的源文件,并通过 `readTextBounded` 把普通文件校验、UTF-8 解码、字节上限和路径替换/增长安全性保留在同一项文件系统操作中。它会针对每项文件系统操作合并调用方取消与提供方资源释放,跟踪尚未进入队列的工作区查找,并在资源释放期间等待这些查找结算。它不发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
|
||||
`dsh-lsp-local` 在语言服务器的执行环境中通过 `ctx.fs` 规范化并读取文件。它要求工作区目标是目录,使用提供方自有的 containment 拒绝工作区外的源文件,消费 `streamText`,并在分片到达时执行 `maxDocumentBytes` 上限;普通文件校验和 UTF-8 解码仍由提供方负责,文档上限则由协议消费方负责。它会针对每项文件系统操作合并调用方取消与提供方资源释放,跟踪尚未进入队列的工作区查找,并在资源释放期间等待这些查找结算。它不发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
|
||||
|
||||
`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。
|
||||
|
||||
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
|
||||
|
||||
1. 通过 `ctx.fs` 解析源文件并检查其位于工作区内,再通过同一提供方对当前文本进行有界读取。
|
||||
1. 通过 `ctx.fs` 解析源文件并检查其位于工作区内,再通过同一提供方流式读取当前文本,同时执行文档字节上限。
|
||||
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。
|
||||
3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。
|
||||
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
|
||||
@@ -157,7 +157,7 @@ interface LspToolInput {
|
||||
|
||||
**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
|
||||
|
||||
**通过面向模型的 `read` 工具读取。**拒绝,因为工具输出带窗口与行号,会进入 transcript 且已被观察。提供方直接通过子进程所用的同一 `ctx.fs` 执行环境读取有界的完整文本。
|
||||
**通过面向模型的 `read` 工具读取。**拒绝,因为工具输出带窗口与行号,会进入 transcript 且已被观察。提供方直接通过子进程所用的同一 `ctx.fs` 执行环境消费流式传输的完整文本。
|
||||
|
||||
**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。
|
||||
|
||||
@@ -195,4 +195,4 @@ interface LspToolInput {
|
||||
|
||||
UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。
|
||||
|
||||
配对的文件系统/子进程提供方会对齐查询快照与服务器索引,但不会因此使受信任的语言服务器变得安全。规范 containment 会拒绝工作区外的查询源;服务器本身获得执行环境所配置的权限,仍可读取其他路径或使用缓存。
|
||||
配对的文件系统/子进程提供方会对齐查询快照与服务器索引,但不会因此使受信任的语言服务器变得安全。规范 containment 会在解析时拒绝工作区外的查询源,但打开流不会在路径并发替换期间额外保证稳定句柄身份;服务器本身获得执行环境所配置的权限,仍可读取其他路径或使用缓存。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md
|
||||
2026-07-28-portable-execution-world-consumers.md: e943c54a3f3a4c93a95db999d4c60790457ba644
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 7755791d5763436866763e8d4ea9e0fab8203bb9
|
||||
2026-07-28-portable-execution-world-consumers.md: d68fc92308079aa68a8f4e1accadde0addb0e5aa
|
||||
2026-07-28-portable-execution-world-consumers.zh.md: 5341cf1643cabb1043b977e18179dc857e37e121
|
||||
|
||||
@@ -14,16 +14,16 @@ Ordinary pipes do not cover one requirement. A persistent terminal needs PTY all
|
||||
|
||||
`ctx.fs` and `ctx.subprocess` together define one execution world. Providers mounted together must describe the same path namespace, executables, processes, and terminal sessions; higher capabilities consume those two interfaces rather than name the provider.
|
||||
|
||||
The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, containment, and a bounded stable-handle text read. The existing text and mutation operations remain filesystem-owned.
|
||||
The filesystem interface owns the path facts that another capability needs without exposing its opaque target identity: a canonical process path, canonical `file:` URI, and containment. Existing whole and streaming text operations remain filesystem-owned; protocol consumers enforce their own retention limits while consuming the stream.
|
||||
|
||||
The subprocess interface owns the process coordinates and primitives: canonical cwd, private runtime storage, executable lookup, ordinary raw or collected process spawning, and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns byte I/O, foreground groups, signalling, TERM-to-KILL session cleanup, and a quiescence wait. The interface package also exports a provider-neutral lifecycle controller that joins top-level settlement, lifetime cancellation, retryable provider cleanup, and bounded quiescence observation; each implementation supplies only its session-cleanup transaction. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
|
||||
The subprocess interface owns the process coordinates and primitives: canonical cwd, private runtime storage, executable lookup, ordinary raw or collected process spawning, and `spawnTerminal()`. The terminal operation is one deep primitive whose handle owns text I/O, foreground groups, signalling, and one awaited TERM-to-KILL operation that settles in-flight handle calls and reaches whole-session quiescence. Its signal cancels allocation only; the published handle owns its lifetime. Prompt detection, idle inference, scrollback, sandbox policy, and owner lifecycle remain in the PTY consumer.
|
||||
|
||||
Generic consumers use that execution world:
|
||||
|
||||
- `dsh-bash-local` continues to map Bash semantics onto ordinary `ctx.subprocess.spawn()`.
|
||||
- `dsh-lsp-local` reads and contains source through `ctx.fs`, resolves and launches language servers through `ctx.subprocess`, and carries provider-owned file URIs through initialization and result rendering. One provider-lifetime signal aborts filesystem and protocol work during disposal, including workspace lookup before queue ownership; its JSON-RPC, pooling, synchronization, and normalization stay unchanged.
|
||||
- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. The allocation signal is detached before publication, while readiness initialization retains setup cancellation. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation until asynchronous foreground signalling settles, so the signal cannot target a successor; if a signaled send returns `inferred_idle` before its prompt arrives, that marker remains attributed to the prior send instead of settling a successor after its echo. A timed-out asynchronous write, or a signal failure during that write, retains the reservation until the provider settles the write. Completion of a stale inspection resumes polling for the current send. Close rejects new public signals after shutdown begins and drains public signals already in flight before returning.
|
||||
- `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them. Preparation carries one lifecycle signal through filesystem resolution, materialization, and executable lookup so disposal can abort a stalled provider. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before serialization, and raw subprocess pipes carry newline-delimited UTF-8 JSON without a redundant base64 representation. The launcher publishes an accepted terminal frame before reaping its controller so a descendant that inherits controller pipes cannot suppress completion; the host still awaits process-group quiescence.
|
||||
- `dsh-pty-local` maps persistent-shell semantics onto `ctx.subprocess.spawnTerminal()`. The local `node-pty` and process-inspection implementation moves into `dsh-subprocess-local`; another subprocess provider supplies the same primitive. Prompt and silence evidence collected during asynchronous pre-write inspection is discarded when the provider write begins. Cancellation retains the send reservation while an in-flight write settles and then signals the foreground group, so late bytes or the signal cannot target a successor; a rejected write sends no signal. The absolute deadline remains armed throughout cancellation. A signal failure becomes terminal transport failure. Completion of a stale inspection resumes polling for the current send. Close rejects new public signals and delegates complete-session quiescence to the handle's awaited termination operation.
|
||||
- `dsh-code-runtime-subprocess` materializes a dependency-free runner through `ctx.fs` and launches it through `ctx.subprocess`, preserving the Code Runtime binding and output contract across local or remote worlds. The fixed runner is adapter-owned infrastructure below `ctx.subprocess.runtimeRoot`, so its write carries an explicit `danger-full-access` policy instead of inheriting the model-facing filesystem mode. It shares host-side worker mechanics through the non-plugin `dsh-code-runtime-worker/runtime-host` subpath instead of copying them. Preparation carries one lifecycle signal through filesystem resolution, materialization, and executable lookup so disposal can abort a stalled provider. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before forwarding, and raw subprocess pipes carry newline-delimited UTF-8 JSON without a redundant base64 representation. The launcher publishes an accepted terminal frame before reaping its controller so a descendant that inherits controller pipes cannot suppress completion; the host still awaits process-group quiescence.
|
||||
|
||||
`dsh-code-runtime-worker` remains a separate implementation. It is the smaller in-process backend and works in single-file distributions that cannot assume an installed Node executable. Remote filesystem/process compositions select `dsh-code-runtime-subprocess`; they do not need a provider-specific Code Runtime package.
|
||||
|
||||
@@ -35,6 +35,10 @@ Generic consumers use that execution world:
|
||||
|
||||
**Move PTY readiness and session policy into the subprocess service.** Rejected because those are persistent-terminal consumer semantics, not OS process mechanics. A subprocess provider owns what only its substrate can do; `dsh-pty-local` owns what a Harness terminal means.
|
||||
|
||||
**Expose separate terminal termination and quiescence operations plus a shared lifecycle controller.** Rejected because every terminal consumer needs the same single cleanup outcome. Separate operations export provider bookkeeping, bounded-observer, and retry semantics without a production consumer; one awaited provider operation is a deeper interface.
|
||||
|
||||
**Add a stable bounded-read primitive to the filesystem seam.** Rejected because only LSP needs a complete-document byte ceiling, which it can enforce while consuming the existing text stream. A second primitive forces every provider to implement stable-handle and no-follow mechanics, including a remote helper protocol, without an observed concurrent-replacement defect.
|
||||
|
||||
**Delete the worker-thread Code Runtime.** Rejected because portability does not erase its current deployment need. The subprocess backend requires a Node executable and filesystem materialization; the worker backend has neither requirement and remains the supported single-process path.
|
||||
|
||||
**Run the whole harness inside the remote environment.** Rejected as a different deployment model. Making execution capabilities portable does not move model calls, session state, plugin state, or the agent loop.
|
||||
|
||||
@@ -14,16 +14,16 @@ Status: implemented
|
||||
|
||||
`ctx.fs` 与 `ctx.subprocess` 共同定义一个执行世界。共同挂载的提供方必须描述相同的路径命名空间、可执行文件、进程和终端会话;上层能力消费这两个接口,而不引用具体提供方。
|
||||
|
||||
文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI、包含关系,以及通过稳定句柄执行的有界文本读取。现有文本与变更操作仍归文件系统负责。
|
||||
文件系统接口负责其他能力需要的路径事实,同时不公开其不透明目标身份:规范化进程路径、规范化 `file:` URI 和包含关系。现有完整文本与流式文本操作仍归文件系统负责;协议消费方在消费流时执行各自的保留上限。
|
||||
|
||||
进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责字节 I/O、前台进程组管理、信号发送、TERM→KILL 会话清理以及等待完全停稳。接口包还导出一个提供方无关的生命周期控制器,用于组合顶层结算、生命周期取消、可重试的提供方清理与有界的完全停稳观测;每个实现只需提供自身的会话清理事务。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
|
||||
进程管理接口负责进程运行坐标与原语:规范化 cwd、私有运行时存储、可执行文件查找、以原始或收集模式 spawn 普通进程,以及 `spawnTerminal()`。终端操作是一项深层原语,其句柄负责文本 I/O、前台进程组、信号发送,以及一项须等待的 TERM→KILL 操作;该操作会结算所有在途句柄调用,并使整个会话完全停稳。其信号只取消分配;句柄一经发布,便负责自身生命周期。提示符检测、空闲推断、scrollback、沙箱策略和所有者生命周期仍由 PTY 消费方负责。
|
||||
|
||||
通用消费方使用该执行世界:
|
||||
|
||||
- `dsh-bash-local` 继续把 Bash 语义映射到普通的 `ctx.subprocess.spawn()`。
|
||||
- `dsh-lsp-local` 通过 `ctx.fs` 读取源文件并验证包含关系,通过 `ctx.subprocess` 解析和启动语言服务器,并让由提供方负责的文件 URI 贯穿初始化与结果渲染。一个提供方生命周期信号会在资源释放期间中止文件系统与协议操作,包括取得队列所有权之前的工作区查找;其 JSON-RPC、池化、同步和规范化保持不变。
|
||||
- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。分配信号会在发布前解除关联,而就绪初始化仍保留设置阶段的取消。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会保留发送预留,直至异步前台信号发送结算,从而使该信号无法把后续发送作为目标;如果收到信号的 send 在其提示符到达前返回 `inferred_idle`,随后到达的标记仍归属于先前的 send,而不会在后续 send 回显后使其完成。异步写入超时,或该写入期间发生信号发送失败,都会保留预留,直至提供方将写入结算。陈旧检查完成后,会针对当前发送恢复轮询。关闭操作开始后会拒绝新的公开信号,并在返回前等待所有已在途的公开信号结算。
|
||||
- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在序列化前执行相同的上限检查;原始子进程管道承载以换行符分隔的 UTF-8 JSON,无需冗余的 base64 表示;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。
|
||||
- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。提供方开始写入时,系统会丢弃异步写入前检查期间收集的提示符与静默证据。取消会在在途写入结算期间保留发送预留,随后向前台进程组发送信号,因此延迟字节和该信号都无法落到后续发送;写入被拒绝时不会发送信号。绝对截止时间会在整个取消期间保持启用。信号发送失败会成为终结性传输失败。陈旧检查完成后,会针对当前发送恢复轮询。关闭操作会拒绝新的公开信号,并把完整会话的完全停稳委托给句柄上须等待的终止操作。
|
||||
- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。固定 runner 是位于 `ctx.subprocess.runtimeRoot` 下的适配器自有基础设施,因此其写入携带显式 `danger-full-access` 策略,而不继承面向模型的文件系统模式。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在转发前执行相同的上限检查;原始子进程管道承载以换行符分隔的 UTF-8 JSON,无需冗余的 base64 表示;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。
|
||||
|
||||
`dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。
|
||||
|
||||
@@ -35,6 +35,10 @@ Status: implemented
|
||||
|
||||
**把 PTY 就绪判断与会话策略移入进程管理服务。** 不予采纳,因为这些属于持久终端消费方的语义,而非 OS 进程机制。进程管理提供方负责只有其执行基底才能完成的操作;`dsh-pty-local` 负责 Harness 终端的语义。
|
||||
|
||||
**分别公开终端终止与完全停稳操作,并提供共享生命周期控制器。** 不予采纳,因为每个终端消费方都需要相同的单一清理结果。拆分操作会把提供方簿记、有界观察者和重试语义暴露出来,却没有生产消费方;由提供方提供一个须等待的操作,接口更深。
|
||||
|
||||
**在文件系统 seam 中新增稳定的有界读取原语。** 不予采纳,因为只有 LSP 需要完整文档字节上限,而它可以在消费现有文本流时执行该上限。第二项原语会迫使每个提供方实现稳定句柄和不跟随符号链接的机制,远程提供方甚至需要辅助协议,却没有已观察到的并发替换缺陷。
|
||||
|
||||
**删除 worker 线程代码运行时。** 不予采纳,因为可移植性不会消除其当前部署需求。进程管理后端需要 Node 可执行文件和文件系统物化,而 worker 后端两者都不需要,并且仍是受支持的单进程路径。
|
||||
|
||||
**在远程环境中运行整个 harness。** 不予采纳,因为这是另一种部署模型。让执行能力可移植,并不意味着移动模型调用、会话状态、插件状态或 agent loop(智能体循环)。
|
||||
|
||||
@@ -447,7 +447,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts)
|
||||
Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-fs-sandbox`
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/filesystem.md
|
||||
filesystem.md: 50b936bdef7535769f19d6cc0a75eb11eea9a869
|
||||
filesystem.zh.md: 61a885203b0718b34b0fd936c318b03c65ce4770
|
||||
filesystem.md: addded9f673ed435e95109d4fb772967514c0b87
|
||||
filesystem.zh.md: 3ef16ede1e137d2a1310861941ac3244ca105650
|
||||
|
||||
@@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'>
|
||||
type FsVersion = Branded<'FsVersion'>
|
||||
```
|
||||
|
||||
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. Protocol consumers use `readTextBounded(target, maxBytes)` when size validation and the complete UTF-8 read must remain one backend-owned stable operation; composing `stat` with `readText` would admit growth and replacement races.
|
||||
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. A protocol consumer that needs a byte ceiling applies it while consuming `streamText`, so the filesystem seam needs no consumer-specific bounded-read primitive.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -258,4 +258,4 @@ type FsErrorCode =
|
||||
|
||||
## The service and the plugin
|
||||
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `readTextBounded`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam).
|
||||
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam).
|
||||
|
||||
@@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'>
|
||||
type FsVersion = Branded<'FsVersion'>
|
||||
```
|
||||
|
||||
`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。当大小校验与完整 UTF-8 读取必须保持为一个由后端负责的稳定操作时,协议消费方使用 `readTextBounded(target, maxBytes)`;组合 `stat` 与 `readText` 会容许文件增长与替换竞态。
|
||||
`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。需要字节上限的协议消费方在消费 `streamText` 时执行该上限,因此文件系统 seam 无需消费方专用的有界读取原语。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -258,4 +258,4 @@ type FsErrorCode =
|
||||
|
||||
## 服务与插件
|
||||
|
||||
`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`readTextBounded`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:对写入/编辑意图 waterfall 作出决策(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }`,或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的 wiring 目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。
|
||||
`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:对写入/编辑意图 waterfall 作出决策(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }`,或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。生成的 wiring 目录在 [services.md](../cordis-catalog/services.md#ctxfs--filesystem-abstract-seam) 中展示确切的 `ctx.fs` 签名。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/core-data-structures/subprocess.md
|
||||
subprocess.md: eae497530ff54a7a259c23a2d0f665f3313488f5
|
||||
subprocess.zh.md: c3504ef7cd42448a15341f7f92d0f309a6216de1
|
||||
subprocess.md: 89c75aea3393a000612e3b8f87d263c7b4fa7c60
|
||||
subprocess.zh.md: 83fb44f4750ba31db1f4336c0c293eb9e236be22
|
||||
|
||||
@@ -240,9 +240,9 @@ interface SubprocessOutcome {
|
||||
|
||||
## Terminal-process primitive
|
||||
|
||||
`spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns raw UTF-8 byte transport, foreground-process-group inspection and signalling, TERM-to-KILL cleanup, and whole-session quiescence. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
|
||||
`spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches whole-session quiescence. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
|
||||
|
||||
The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, `terminate`, and `waitForExit`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](../cordis-catalog/services.md#ctxsubprocess--subprocessservice-abstract-seam).
|
||||
The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, and awaited `terminate`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](../cordis-catalog/services.md#ctxsubprocess--subprocessservice-abstract-seam).
|
||||
|
||||
## Service behavior
|
||||
|
||||
|
||||
@@ -240,9 +240,9 @@ interface SubprocessOutcome {
|
||||
|
||||
## 终端进程原语
|
||||
|
||||
`spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责原始 UTF-8 字节传输、前台进程组检查与信号发送、TERM→KILL 清理,以及整个会话的完全停稳。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
|
||||
`spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使整个会话完全停稳。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
|
||||
|
||||
终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground`、`terminate` 和 `waitForExit`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](../cordis-catalog/services.md#ctxsubprocess--subprocessservice-abstract-seam)中。
|
||||
终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](../cordis-catalog/services.md#ctxsubprocess--subprocessservice-abstract-seam)中。
|
||||
|
||||
## 服务行为
|
||||
|
||||
|
||||
@@ -223,5 +223,4 @@ export class RuntimeOutputLedger {
|
||||
}
|
||||
|
||||
export { decodeWorkerJson, encodeWorkerJson, snapshotCodeJsonValue } from './worker-json.ts'
|
||||
export { jsonValueBytesUpTo } from './output-json.ts'
|
||||
export type { WorkerJsonWire } from './worker-json.ts'
|
||||
|
||||
838
packages/code-runtime/code-runtime/tests/contract.ts
Normal file
838
packages/code-runtime/code-runtime/tests/contract.ts
Normal file
@@ -0,0 +1,838 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
CodeBindingFunction,
|
||||
CodeBindingNamespace,
|
||||
CodeRunResult,
|
||||
CodeRuntime,
|
||||
} from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
interface WorkerCodeRuntimeContractConfig {
|
||||
computeMs?: number
|
||||
maxWallMs?: number
|
||||
maxOutputBytes?: number
|
||||
maxOldGenerationSizeMb?: number
|
||||
}
|
||||
|
||||
interface WorkerCodeRuntimeContractHarness {
|
||||
runtime: CodeRuntime
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
type WorkerCodeRuntimeContractSetup = (
|
||||
config?: WorkerCodeRuntimeContractConfig,
|
||||
) => Promise<WorkerCodeRuntimeContractHarness>
|
||||
|
||||
/** Convenience: one namespace `tools` with the given functions. */
|
||||
export function workerRuntimeTools(
|
||||
functions: Record<string, (args: unknown) => Promise<unknown>>,
|
||||
): CodeBindingNamespace[] {
|
||||
return [{
|
||||
global: 'tools',
|
||||
functions: functions as Record<string, CodeBindingFunction>,
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}]
|
||||
}
|
||||
|
||||
/** Run behavior shared by the direct and subprocess-hosted worker runtimes. */
|
||||
export function runWorkerCodeRuntimeContract(
|
||||
label: string,
|
||||
setup: WorkerCodeRuntimeContractSetup,
|
||||
): void {
|
||||
describe(`${label} — programs and bindings (real workers)`, () => {
|
||||
it('registers with the seam descriptors', async () => {
|
||||
const { runtime } = await setup()
|
||||
expect(runtime.language).toBe('typescript')
|
||||
expect(runtime.isolation).toBe('worker-thread')
|
||||
})
|
||||
|
||||
it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
interface Point { x: number; y: number }
|
||||
const p: Point = { x: 1, y: 2 } as Point;
|
||||
console.log('point', p);
|
||||
process.stdout.write('raw-out\\n');
|
||||
console.warn('careful');
|
||||
return p.x + p.y;
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(3)
|
||||
expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
|
||||
})
|
||||
|
||||
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
|
||||
const { runtime } = await setup()
|
||||
const calls: unknown[] = []
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const first = await tools.echo({ n: 1 });
|
||||
let caught = {};
|
||||
try { await tools.fail({}) } catch (error) { caught = { isTyped: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }
|
||||
let caughtRaw = {};
|
||||
try { await tools.failRaw({}) } catch (error) { caughtRaw = { name: error.name, toolName: error.toolName, message: error.message } }
|
||||
return { first, caught, caughtRaw };
|
||||
`,
|
||||
bindings: workerRuntimeTools({
|
||||
echo: async (args) => { calls.push(args); return { echoed: args } },
|
||||
fail: async () => { throw new Error('nope') },
|
||||
// A non-Error throw: the host renders it, the program still catches.
|
||||
failRaw: async () => { throw 'raw-nope' },
|
||||
}),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual({
|
||||
first: { echoed: { n: 1 } },
|
||||
caught: { isTyped: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
|
||||
caughtRaw: { name: 'ToolCallError', toolName: 'failRaw', message: 'raw-nope' },
|
||||
})
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
it('materializes a typed rejection from a generic namespace descriptor', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
try { await helpers.fail({}) } catch (error) {
|
||||
return {
|
||||
isTyped: error instanceof HelperCallError,
|
||||
name: error.name,
|
||||
helperName: error.helperName,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
`,
|
||||
bindings: [{
|
||||
global: 'helpers',
|
||||
functions: { fail: async () => { throw new Error('nope') } },
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
})
|
||||
expect(result.value).toEqual({
|
||||
isTyped: true,
|
||||
name: 'HelperCallError',
|
||||
helperName: 'fail',
|
||||
message: 'nope',
|
||||
})
|
||||
})
|
||||
|
||||
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
let value = 'leaf';
|
||||
for (let depth = 0; depth < 3_000; depth++) value = [value];
|
||||
return await tools.echo(value);
|
||||
`,
|
||||
bindings: workerRuntimeTools({ echo: async args => args }),
|
||||
})
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
let cursor = result.value
|
||||
for (let depth = 0; depth < 3_000; depth++) {
|
||||
expect(Array.isArray(cursor)).toBe(true)
|
||||
cursor = Array.isArray(cursor) ? cursor[0] : undefined
|
||||
}
|
||||
expect(cursor).toBe('leaf')
|
||||
}, 15_000)
|
||||
|
||||
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.error?.message).toMatch(/enum|strip/i)
|
||||
})
|
||||
|
||||
it('reports a runtime throw as an exception with the message', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.error?.message).toContain('kaboom')
|
||||
})
|
||||
|
||||
it('gives the program an EMPTY environment', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
|
||||
expect(result.value).toBe('{}')
|
||||
})
|
||||
|
||||
it('rejects a non-lossless completion instead of replacing it with rendered text', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'invalid-output', message: 'program completion must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('completes a program that returns nothing with no value at all', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'const x = 1', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect('value' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps logs streamed before a failure', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'console.log("before"); throw new Error("after-log")',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('exception')
|
||||
expect(result.logs).toContain('before')
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${label} — budgets and containment (real workers)`, () => {
|
||||
it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
|
||||
const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
// The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
|
||||
// then spin. Host-side pending-call bookkeeping would pause a naive
|
||||
// budget here; measured busy time cannot be fooled.
|
||||
program: 'void tools.slow({}); for (;;) {}',
|
||||
bindings: workerRuntimeTools({ slow: () => new Promise(() => {}) }),
|
||||
})
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
expect(result.error?.message).toContain('compute budget')
|
||||
}, 15_000)
|
||||
|
||||
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
|
||||
// Keep the binding delay above the compute allowance while leaving enough
|
||||
// headroom for worker bootstrap on loaded CI hosts.
|
||||
const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
|
||||
const result = await runtime.run({
|
||||
program: 'return await tools.slow({})',
|
||||
bindings: workerRuntimeTools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('slow-done')
|
||||
}, 15_000)
|
||||
|
||||
it('ends an idle-forever run at the wall-clock ceiling', async () => {
|
||||
const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
|
||||
const result = await runtime.run({
|
||||
program: 'await tools.never({}); return 1',
|
||||
bindings: workerRuntimeTools({ never: () => new Promise(() => {}) }),
|
||||
})
|
||||
expect(result.error?.kind).toBe('timeout')
|
||||
expect(result.error?.message).toContain('wall-clock ceiling')
|
||||
}, 15_000)
|
||||
|
||||
it('reports an abort mid-run and stops the worker', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => { controller.abort('user-cancel') }, 150)
|
||||
const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
|
||||
}, 15_000)
|
||||
|
||||
it('reports a pre-aborted signal without spawning', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort('too-late')
|
||||
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
|
||||
})
|
||||
|
||||
it('applies the outer-output cap to failures before worker startup', async () => {
|
||||
const capped = await setup({ maxOutputBytes: 64 })
|
||||
const controller = new AbortController()
|
||||
controller.abort('A'.repeat(1_000))
|
||||
const aborted = await capped.runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
|
||||
expect(aborted).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } })
|
||||
|
||||
const minimal = await setup({ maxOutputBytes: 4 })
|
||||
const invalid = await minimal.runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
|
||||
expect(invalid.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(invalid.logs), 'utf8') + Buffer.byteLength(JSON.stringify(invalid.error?.message), 'utf8')).toBeLessThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('drops a binding resolution that lands after the run settled', async () => {
|
||||
const { runtime } = await setup()
|
||||
const controller = new AbortController()
|
||||
let replyDelivered!: Promise<void>
|
||||
const result = await runtime.run({
|
||||
program: 'void tools.late({}); for (;;) {}',
|
||||
bindings: workerRuntimeTools({
|
||||
// Anchored on invocation: abort 100ms after the call reaches the
|
||||
// host, resolve 400ms after — by then the run has settled, so the
|
||||
// resolution's reply hits the post-settlement drop.
|
||||
late: () => new Promise((resolve) => {
|
||||
setTimeout(() => { controller.abort('cancel-now') }, 100)
|
||||
replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
|
||||
}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
|
||||
// Let the late resolution actually fire so its reply executes instead of
|
||||
// being cancelled with the test.
|
||||
await replyDelivered
|
||||
}, 15_000)
|
||||
|
||||
it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
|
||||
const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
|
||||
const result = await runtime.run({
|
||||
program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error?.kind).toBe('worker-exit')
|
||||
// And the host is fine: run something else.
|
||||
const after = await runtime.run({ program: 'return "alive"', bindings: [] })
|
||||
expect(after.value).toBe('alive')
|
||||
}, 30_000)
|
||||
|
||||
it('reports a worker that exits before publishing a completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'process.exit(7)', bindings: [] })
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'worker-exit', message: 'worker exited with code 7 before completing' },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails runaway log output explicitly while retaining a bounded prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 300 })
|
||||
const result = await runtime.run({
|
||||
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 300 bytes' })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.logs.length).toBeGreaterThan(0)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(300)
|
||||
})
|
||||
|
||||
it('retains a fitting prefix when one oversized log is the first output', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||
const result = await runtime.run({
|
||||
program: 'console.log(`start-${`😀"\\\\\\n`.repeat(100)}`); return null',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
|
||||
expect(result.logs).toHaveLength(1)
|
||||
expect(result.logs[0]?.startsWith('start-')).toBe(true)
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(96)
|
||||
})
|
||||
|
||||
it('fails an oversized return value without substituting a string', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('uses UTF-8 serialized bytes at the exact completion boundary', async () => {
|
||||
const exact = await setup({ maxOutputBytes: 7 })
|
||||
const exactResult = await exact.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
// [] costs two bytes and JSON serialization of "€" costs five.
|
||||
expect(exactResult).toEqual({ logs: [], value: '€' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 6 })
|
||||
const overResult = await over.runtime.run({ program: 'return "€"', bindings: [] })
|
||||
expect(overResult.error?.kind).toBe('output-limit')
|
||||
})
|
||||
|
||||
it('accounts logs and completion in one exact combined ledger', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], value: 'xy' })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); return "xy"', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('does not send a giant Error stack across the worker port', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: 'throw new Error("x".repeat(1_000_000))',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a program that awaits its write callback, capturing the chunk', async () => {
|
||||
// Node's write(chunk[, encoding][, callback]) contract: dropping the
|
||||
// callback would leave this promise pending until the wall ceiling and
|
||||
// misreport a completed program as a timeout.
|
||||
const { runtime } = await setup({ maxWallMs: 2_000 })
|
||||
const result = await runtime.run({
|
||||
program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs).toContain('flushed')
|
||||
})
|
||||
|
||||
it('returns a large JSON container exactly when the outer cap permits it', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toEqual(new Array(50_000).fill(7))
|
||||
})
|
||||
|
||||
it('returns an exact completion at the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
// [] costs two bytes and the JSON string contributes two quotes, leaving
|
||||
// exactly this many payload bytes under the 67_108_864-byte default.
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_860)', bindings: [] })
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.logs).toEqual([])
|
||||
expect(result.value).toHaveLength(67_108_860)
|
||||
}, 60_000)
|
||||
|
||||
it('fails one byte over the default 64 MiB combined boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({ program: 'return "x".repeat(67_108_861)', bindings: [] })
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' })
|
||||
}, 60_000)
|
||||
|
||||
it('drains pipe output queued before terminal worker teardown completes', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200_000 })
|
||||
const payload = `late-pipe-${'x'.repeat(100_000)}`
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
|
||||
write('late-pipe-' + 'x'.repeat(100_000));
|
||||
parentPort.postMessage({ type: 'done', value: ['done'] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('done')
|
||||
expect(result.logs.join('') === payload).toBe(true)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
describe(`${label} — hostile programs (real workers)`, () => {
|
||||
it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
|
||||
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
|
||||
parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
|
||||
parentPort.postMessage({ type: 'junk' });
|
||||
return await tools.real({});
|
||||
`,
|
||||
bindings: workerRuntimeTools({ real: async () => 'still-works' }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('still-works')
|
||||
})
|
||||
|
||||
it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
for (const junk of [
|
||||
null, 42, 'junk', [],
|
||||
{ type: 'nope' },
|
||||
{ type: 'call' },
|
||||
{ type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
|
||||
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
|
||||
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
|
||||
{ type: 'log' },
|
||||
{ type: 'log', text: null },
|
||||
{ type: 'log', text: 7 },
|
||||
{ type: 'log', text: {} },
|
||||
{ type: 'done', error: 5 },
|
||||
{ type: 'done', error: { kind: 'exception', message: 5 } },
|
||||
{ type: 'done', error: { kind: 'invented', message: 'bad kind' } },
|
||||
]) parentPort.postMessage(junk);
|
||||
return await tools.real({});
|
||||
`,
|
||||
bindings: workerRuntimeTools({ real: async () => 'still-works' }),
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe('still-works')
|
||||
expect(result.logs).toEqual([])
|
||||
})
|
||||
|
||||
it('fails forged log floods and forged done values through the same outer cap', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 200 })
|
||||
const result = await runtime.run({
|
||||
// Forged messages bypass worker-side capture and completion checks;
|
||||
// the outer ledger must still contain them.
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.value).toBeUndefined()
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 200 bytes' })
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('re-caps an oversized forged done value at the host boundary', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a malformed forged done carrying both value and error', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: 'lied', error: { kind: 'exception', message: 'fake failure' } });
|
||||
return 'honest';
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'exception', message: 'fake failure' } })
|
||||
})
|
||||
|
||||
it('contains a deeply nested forged completion without overflowing the host meter', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const value = [];
|
||||
for (let depth = 0; depth < 3_000; depth++) value.push({ kind: 'array', length: 1 });
|
||||
value.push(null);
|
||||
setTimeout(() => { parentPort.postMessage({ type: 'done', value }) }, 25);
|
||||
// Prevent bootstrap's normal undefined completion from racing the forged terminal.
|
||||
await new Promise(() => {});
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
let value = result.value
|
||||
let depth = 0
|
||||
while (Array.isArray(value)) {
|
||||
expect(value).toHaveLength(1)
|
||||
value = value[0]
|
||||
depth += 1
|
||||
}
|
||||
expect(depth).toBe(3_000)
|
||||
expect(value).toBeNull()
|
||||
}, 15_000)
|
||||
|
||||
it('turns forged over-limit error text into output-limit at the host', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', error: { kind: 'exception', message: '€'.repeat(1000) } });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 64 bytes' })
|
||||
})
|
||||
|
||||
it('answers a binding whose resolution is not lossless JSON with a typed failure reply', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: workerRuntimeTools({ bad: async () => (() => 1) }),
|
||||
})
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('rejects lossy binding arguments in the worker before invoking the host binding', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const decorated = [1]; Object.defineProperty(decorated, 'extra', { value: true });
|
||||
const values = [new Date(), decorated, () => 1];
|
||||
const failures = [];
|
||||
for (const value of values) {
|
||||
try { await tools.never(value) } catch (error) {
|
||||
failures.push({ typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
`,
|
||||
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(result.value).toEqual(new Array(3).fill({
|
||||
typed: true,
|
||||
name: 'ToolCallError',
|
||||
toolName: 'never',
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const forgeObject = `
|
||||
const prototype = Object.create(null);
|
||||
const SpoofedObject = function Object() {};
|
||||
SpoofedObject.prototype = prototype;
|
||||
Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
|
||||
const forged = Object.assign(Object.create(prototype), { value: 1 });
|
||||
Function.prototype.toString = () => 'function Object() { [native code] }';
|
||||
`
|
||||
const argument = await runtime.run({
|
||||
program: `${forgeObject}
|
||||
try { await tools.never(forged) } catch (error) {
|
||||
return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
|
||||
}
|
||||
`,
|
||||
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(argument.value).toEqual({
|
||||
typed: true,
|
||||
name: 'ToolCallError',
|
||||
toolName: 'never',
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
})
|
||||
|
||||
const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
|
||||
expect(completion).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves binding and completion JSON after model code mutates boundary globals', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const arrayPrototype = Array.prototype;
|
||||
const objectPrototype = Object.prototype;
|
||||
const setPrototype = Set.prototype;
|
||||
const stringPrototype = String.prototype;
|
||||
Array.isArray = () => false;
|
||||
arrayPrototype.at = arrayPrototype.includes = arrayPrototype.pop = arrayPrototype.push = () => { throw new Error('mutated array method') };
|
||||
Object.defineProperty = Object.getOwnPropertyDescriptor = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') };
|
||||
Object.hasOwn = () => false;
|
||||
Object.is = () => true;
|
||||
objectPrototype.propertyIsEnumerable = () => false;
|
||||
Number.isFinite = Number.isSafeInteger = () => false;
|
||||
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') };
|
||||
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') };
|
||||
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') };
|
||||
Buffer.byteLength = () => 0;
|
||||
Function.prototype.toString = () => 'mutated';
|
||||
objectPrototype.get = () => undefined;
|
||||
objectPrototype.constructor = arrayPrototype.constructor = null;
|
||||
globalThis.Array = globalThis.Buffer = globalThis.Error = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Reflect = globalThis.Set = globalThis.String = undefined;
|
||||
const echoed = await tools.echo({ request: ['€', 1] });
|
||||
let failure;
|
||||
try { await tools.fail({}) } catch (error) {
|
||||
failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
|
||||
}
|
||||
return { echoed, failure, completion: { ok: true, amount: 42 } };
|
||||
`,
|
||||
bindings: workerRuntimeTools({ echo: async args => args, fail: async () => { throw new Error('nope') } }),
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
value: {
|
||||
echoed: { request: ['€', 1] },
|
||||
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'nope' },
|
||||
completion: { ok: true, amount: 42 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects forged lossy binding arguments again at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
const forged = (id, args) => new Promise((resolve) => {
|
||||
const receive = (message) => {
|
||||
if (message?.type !== 'reply' || message.id !== id) return;
|
||||
parentPort.off('message', receive);
|
||||
resolve(message);
|
||||
};
|
||||
parentPort.on('message', receive);
|
||||
parentPort.postMessage({ type: 'call', id, global: 'tools', name: 'never', args });
|
||||
});
|
||||
const sparse = []; sparse.length = 1;
|
||||
const cycle = {}; cycle.self = cycle;
|
||||
return await Promise.all([
|
||||
forged(8001, new Date()),
|
||||
forged(8002, -0),
|
||||
forged(8003, sparse),
|
||||
forged(8004, cycle),
|
||||
]);
|
||||
`,
|
||||
bindings: workerRuntimeTools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(result.value).toEqual([8001, 8002, 8003, 8004].map(id => ({
|
||||
type: 'reply',
|
||||
id,
|
||||
ok: false,
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
})))
|
||||
})
|
||||
|
||||
it('contains throwing getters while snapshotting binding resolutions', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'try { await tools.bad({}) } catch (error) { return { name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
bindings: workerRuntimeTools({ bad: async () => Object.defineProperty({}, 'bad', { enumerable: true, get() { throw new Error('getter exploded') } }) }),
|
||||
})
|
||||
expect(result.value).toEqual({ name: 'ToolCallError', toolName: 'bad', message: 'binding resolution must be lossless JSON' })
|
||||
})
|
||||
|
||||
it('revalidates a forged lossy completion at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: -0 });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } })
|
||||
})
|
||||
|
||||
it('honors a forged worker-side output-limit signal', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'output-limit' });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({ logs: [], error: { kind: 'output-limit', message: 'outer output exceeded 67108864 bytes' } })
|
||||
})
|
||||
|
||||
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
|
||||
// Computed keys: a literal `'__proto__': …` entry would SET the record's
|
||||
// prototype instead of declaring a binding of that name.
|
||||
bindings: workerRuntimeTools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
|
||||
})
|
||||
expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
|
||||
})
|
||||
})
|
||||
|
||||
describe(`${label} — seam misuse and lifecycle`, () => {
|
||||
it('rejects invalid and duplicate binding globals loudly', async () => {
|
||||
const { runtime } = await setup()
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
}
|
||||
await expect(runtime.run({
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
|
||||
})).rejects.toThrow(/duplicate binding global/)
|
||||
|
||||
await expect(runtime.run({
|
||||
program: 'return typeof ToolCallError',
|
||||
bindings: [{ global: 'ToolCallError', functions: {} }],
|
||||
})).resolves.toMatchObject({ value: 'object' })
|
||||
})
|
||||
|
||||
it('rejects malformed or colliding binding error-class declarations', async () => {
|
||||
const { runtime } = await setup()
|
||||
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
|
||||
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
|
||||
global,
|
||||
functions: {},
|
||||
errorClass: { name, memberNameProperty },
|
||||
})
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
namespace('helpers', 'CallError'),
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
await expect(setup({ computeMs: -1 })).rejects.toThrow(/positive number/)
|
||||
})
|
||||
|
||||
it('rejects a maxWallMs above Node\'s maximum timer delay', async () => {
|
||||
// setTimeout clamps a delay past 2^31-1 ms to 1 ms, so the positivity check
|
||||
// alone would accept a 25-day ceiling that expires on the first tick.
|
||||
await expect(setup({ maxWallMs: 2_147_483_648 }))
|
||||
.rejects.toThrow(/maxWallMs must be at most 2147483647/)
|
||||
// The boundary itself is usable.
|
||||
await expect(setup({ maxWallMs: 2_147_483_647 })).resolves.toBeTruthy()
|
||||
})
|
||||
|
||||
it('requires maxOutputBytes to fit the smallest counted outer payloads', async () => {
|
||||
await expect(setup({ maxOutputBytes: 3 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
await expect(setup({ maxOutputBytes: 4.5 })).rejects.toThrow(/safe integer of at least 4/)
|
||||
})
|
||||
|
||||
it('keeps runs isolated: no state survives from one run to the next', async () => {
|
||||
const { runtime } = await setup()
|
||||
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
|
||||
const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
|
||||
expect(second.value).toBe('undefined')
|
||||
})
|
||||
|
||||
it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
|
||||
const { runtime, dispose } = await setup()
|
||||
const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
|
||||
// Give the worker a moment to actually start spinning.
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
await dispose()
|
||||
const result = await inflight
|
||||
expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
|
||||
}, 15_000)
|
||||
})
|
||||
}
|
||||
@@ -117,12 +117,6 @@ class RecordingFileSystem extends FileSystem {
|
||||
return this.entries.get(target.targetKey)?.content ?? ''
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
const text = await this.readText(target, signal)
|
||||
if (Buffer.byteLength(text) > maxBytes) throw new Error('too large')
|
||||
return text
|
||||
}
|
||||
|
||||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
|
||||
@@ -336,10 +336,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>',
|
||||
jsDoc: '/**\n * Read the whole regular text file as a single decoded string.\n * @param target - the resolved target to read.\n * @param signal - aborts the read.\n * @returns the full decoded UTF-8 content.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string>',
|
||||
jsDoc: '/**\n * Read one regular UTF-8 text file through a backend-owned stable handle,\n * rejecting before more than `maxBytes` are retained. The size check and\n * bytes read are one operation: a caller must not emulate this with\n * {@link stat} followed by {@link readText}, which admits growth and path\n * replacement races between the two calls.\n * @param target - the resolved target to read.\n * @param maxBytes - positive safe-integer byte ceiling.\n * @param signal - aborts the open/read operation.\n * @returns the complete decoded text when it fits.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>',
|
||||
jsDoc: '/**\n * Stream the whole regular text file as decoded text chunks (same text\n * semantics as {@link readText}, for large files). The backend owns\n * cross-chunk UTF-8 decoding and binary rejection so the policy layer never\n * touches raw bytes.\n * @param target - the resolved target to read.\n * @param signal - aborts the stream, including between chunks.\n * @returns the chunk iterable, decoded and validated like {@link readText}.\n */',
|
||||
@@ -1000,7 +996,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>',
|
||||
jsDoc: '/**\n * Allocate a real terminal and start one owned process session. This is the\n * only non-pipe process primitive: implementations own terminal byte I/O,\n * foreground groups, signals, and complete session-tree cleanup.\n * @param spec - fully specified argv, cwd, environment, dimensions, grace, and cancellation.\n * @returns the live terminal handle after allocation succeeds.\n */',
|
||||
jsDoc: '/**\n * Allocate a real terminal and start one owned process session. This is the\n * only non-pipe process primitive: implementations own terminal byte I/O,\n * foreground groups, signals, and complete session-tree cleanup.\n * @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\n * @returns the live terminal handle after allocation succeeds.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2909,7 +2905,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubprocessTerminalHandle',
|
||||
declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise<SubprocessOutcome>;\n write(data: Uint8Array): Promise<void>;\n inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;\n signalForeground(signal: SubprocessTerminalSignal): Promise<number>;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise<boolean>;\n}',
|
||||
declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise<SubprocessOutcome>;\n write(data: string): Promise<void>;\n inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;\n signalForeground(signal: SubprocessTerminalSignal): Promise<number>;\n terminate(): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubprocessTerminalSignal',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
|
||||
README.md: f5cf2441adcd62e55c6169ceb766f88382e314f5
|
||||
README.zh.md: 40f5a83626780bae8f335c80662721bae6cc0b0b
|
||||
README.md: fe0e5e9dec07fad745d6bea28da9009e517c7d85
|
||||
README.zh.md: 195f3963328035e9c6c382dd924cd04ee9e8c642
|
||||
|
||||
@@ -17,7 +17,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`.
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `readTextBounded` / `streamText`** — UTF-8 only. `readText` reads the whole file; `readTextBounded` opens one no-follow, nonblocking handle, verifies it is regular, and retains at most `maxBytes + 1` bytes so growth cannot bypass the cap; `streamText` decodes chunks so a huge file need not be held whole in memory. All reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing; protocol consumers such as the LSP host use the stable bounded operation.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
@@ -17,7 +17,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。
|
||||
- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。
|
||||
- **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。
|
||||
- **`readText` / `readTextBounded` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`readTextBounded` 打开一个不跟随符号链接的非阻塞句柄,确认其为普通文件,并最多保留 `maxBytes + 1` 字节,使文件增长无法绕过上限;`streamText` 按分片解码,因此超大文件无需整体保存在内存中。三者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑;LSP 主机等协议消费方使用稳定的有界操作。
|
||||
- **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。
|
||||
- **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。
|
||||
- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。
|
||||
- **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants, createReadStream } from 'node:fs'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
@@ -377,69 +377,6 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
|
||||
* @param signal - aborts between handle operations.
|
||||
* @returns the complete decoded text when it fits.
|
||||
*/
|
||||
export async function readWholeTextBounded(
|
||||
target: LocalTarget,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded read maxBytes must be a positive safe integer')
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
let handle: Awaited<ReturnType<typeof open>>
|
||||
try {
|
||||
handle = await open(
|
||||
target.targetKey,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (isENOENT(error)) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
||||
if (isPermissionError(error)) throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
||||
throw new FsError(`cannot read "${target.displayPath}" safely: ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
|
||||
}
|
||||
try {
|
||||
throwIfAborted(signal, 'read')
|
||||
const info = await handle.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
if (info.size > maxBytes) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
for (;;) {
|
||||
throwIfAborted(signal, 'read')
|
||||
// Allocate in fixed internal chunks so a permissive deployment cap does
|
||||
// not reserve that entire cap for a small file. Once exactly at the
|
||||
// bound, one final byte detects concurrent growth without retaining it.
|
||||
const remaining = total === maxBytes ? 1 : Math.min(64 * 1024, maxBytes - total)
|
||||
const chunk = Buffer.allocUnsafe(remaining)
|
||||
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
if (total > maxBytes) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
}
|
||||
throwIfAborted(signal, 'read')
|
||||
const bytes = chunks.length === 1 ? chunks[0] as Buffer : Buffer.concat(chunks, total)
|
||||
if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
return decodeUtf8(bytes, 'read', target.displayPath)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
readWholeTextBounded,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
@@ -126,10 +125,6 @@ export class LocalFileSystem extends FileSystem {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeTextBounded({ displayPath: target.displayPath, targetKey: target.targetKey }, maxBytes, signal)
|
||||
}
|
||||
|
||||
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
@@ -218,15 +218,6 @@ describe('readText / streamText', () => {
|
||||
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('reads complete text through the stable byte bound', async () => {
|
||||
await writeFile(join(dir, 'bounded.txt'), '€abc')
|
||||
const target = await fs.resolve('bounded.txt')
|
||||
expect(await fs.readTextBounded(target, 6)).toBe('€abc')
|
||||
await expect(fs.readTextBounded(target, 5)).rejects.toThrow('exceeds the 5-byte limit')
|
||||
await expect(fs.readTextBounded(target, 0)).rejects.toThrow('positive safe integer')
|
||||
await expect(fs.readTextBounded(target, 6, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('streams the same text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
const target = await fs.resolve('a.txt')
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
readWholeTextBounded,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
@@ -317,66 +316,6 @@ describe('readWholeText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeTextBounded', () => {
|
||||
it('rejects non-files, binary text, and initial oversize without a full read', async () => {
|
||||
await expect(readWholeTextBounded(localTarget(dir), 10)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
await writeFile(join(dir, 'large'), '12345')
|
||||
await expect(readWholeTextBounded(localTarget(join(dir, 'large')), 4)).rejects.toThrow('exceeds the 4-byte limit')
|
||||
await writeFile(join(dir, 'binary'), Buffer.from([0x61, 0x00, 0x62]))
|
||||
await expect(readWholeTextBounded(localTarget(join(dir, 'binary')), 3)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('detects growth past the bound on the same open handle', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
|
||||
const bytes = position === 0 ? Buffer.from('abc') : Buffer.from('d')
|
||||
bytes.copy(buffer, offset, 0, Math.min(length, bytes.length))
|
||||
return { bytesRead: Math.min(length, bytes.length), buffer }
|
||||
})
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
open: async () => ({
|
||||
stat: async () => ({ isFile: () => true, size: 3 }),
|
||||
read,
|
||||
close,
|
||||
}),
|
||||
}
|
||||
})
|
||||
try {
|
||||
const isolated = await import('../src/fsio.ts')
|
||||
await expect(isolated.readWholeTextBounded(localTarget('/virtual/growing'), 3))
|
||||
.rejects.toThrow('grew past the 3-byte limit')
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('translates permission and generic open failures', async () => {
|
||||
const failure: { current: Error & { code?: string } } = { current: Object.assign(new Error('denied'), { code: 'EACCES' }) }
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, open: async () => { throw failure.current } }
|
||||
})
|
||||
try {
|
||||
const isolated = await import('../src/fsio.ts')
|
||||
await expect(isolated.readWholeTextBounded(localTarget('/virtual/denied'), 3))
|
||||
.rejects.toMatchObject({ code: 'FS_PERMISSION_DENIED' })
|
||||
failure.current = new Error('open broke')
|
||||
await expect(isolated.readWholeTextBounded(localTarget('/virtual/broken'), 3))
|
||||
.rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/fs/README.md
|
||||
README.md: 67079f795c705ab4c9cfa476e0458be04a48c6c8
|
||||
README.zh.md: d812a94fab9f4b7e9d15ff78bd1fea3bcc00c0d9
|
||||
README.md: bf1dd1c1eb65146258cd64e450749845522e7057
|
||||
README.zh.md: f3fcc0c3794b972233dc418e93bdd80b1cc8570a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read bounded or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
|
||||
|
||||
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
@@ -17,7 +17,7 @@ A future sandboxed, virtual, or remote backend implements this interface and the
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements twelve primitives.
|
||||
A backend subclasses `FileSystem` and implements eleven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
@@ -28,8 +28,7 @@ A backend subclasses `FileSystem` and implements twelve primitives.
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `readTextBounded(target, maxBytes, signal?)` | Read one complete regular UTF-8 file through a backend-owned stable operation, rejecting before retaining more than `maxBytes`. Consumers must not emulate this with `stat` then `readText`, which admits growth and replacement races. |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. |
|
||||
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
|
||||
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
|
||||
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
|
||||
@@ -61,6 +60,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
|
||||
- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
|
||||
- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md).
|
||||
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
|
||||
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、有界或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
|
||||
**文件系统提供方 seam**:抽象 `FileSystem` 服务(`ctx.fs`),定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。
|
||||
|
||||
本包是四层文件系统栈中的提供方 seam 层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## 服务 API(`ctx.fs`)
|
||||
|
||||
后端继承 `FileSystem` 并实现十二个原语。
|
||||
后端继承 `FileSystem` 并实现十一个原语。
|
||||
|
||||
| 成员 | 语义 |
|
||||
|---|---|
|
||||
@@ -28,8 +28,7 @@
|
||||
| `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 |
|
||||
| `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 |
|
||||
| `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 |
|
||||
| `readTextBounded(target, maxBytes, signal?)` | 通过后端自有的稳定操作读取一个完整的普通 UTF-8 文件,在保留超过 `maxBytes` 前拒绝。消费方不得以先 `stat` 再 `readText` 模拟此操作,因为那会容许文件增长与替换竞态。 |
|
||||
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责)。 |
|
||||
| `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 |
|
||||
| `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 |
|
||||
| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 |
|
||||
| `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 |
|
||||
@@ -61,6 +60,6 @@
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **契约只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。
|
||||
- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
|
||||
- **只有十一个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。
|
||||
- **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。
|
||||
- **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Filesystem provider seam for one execution world. Backends own stable target
|
||||
* identity, process paths and file URIs, containment, stable bounded text
|
||||
* reads, decoding, binary rejection, and atomic mutations. Read windows and
|
||||
* identity, process paths and file URIs, containment, text reads, decoding,
|
||||
* binary rejection, and atomic mutations. Read windows and
|
||||
* observed-state policy stay in consumer and policy plugins; `editText`
|
||||
* remains here so version check, literal match, and rewrite share one critical
|
||||
* section.
|
||||
@@ -172,19 +172,6 @@ export abstract class FileSystem extends Service {
|
||||
*/
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
* Read one regular UTF-8 text file through a backend-owned stable handle,
|
||||
* rejecting before more than `maxBytes` are retained. The size check and
|
||||
* bytes read are one operation: a caller must not emulate this with
|
||||
* {@link stat} followed by {@link readText}, which admits growth and path
|
||||
* replacement races between the two calls.
|
||||
* @param target - the resolved target to read.
|
||||
* @param maxBytes - positive safe-integer byte ceiling.
|
||||
* @param signal - aborts the open/read operation.
|
||||
* @returns the complete decoded text when it fits.
|
||||
*/
|
||||
abstract readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
* Stream the whole regular text file as decoded text chunks (same text
|
||||
* semantics as {@link readText}, for large files). The backend owns
|
||||
|
||||
@@ -46,11 +46,6 @@ class FakeFileSystem extends FileSystem {
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
return content
|
||||
}
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number): Promise<string> {
|
||||
const content = await this.readText(target)
|
||||
if (Buffer.byteLength(content) > maxBytes) throw new FsError('too large', 'FS_IO_ERROR')
|
||||
return content
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = await this.readText(target)
|
||||
return (async function* () { yield content })()
|
||||
|
||||
@@ -67,11 +67,6 @@ class FakeFs extends FileSystem {
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number): Promise<string> {
|
||||
const content = await this.readText(target)
|
||||
if (Buffer.byteLength(content) > maxBytes) throw new Error('too large')
|
||||
return content
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/lsp/lsp-local/README.md
|
||||
README.md: c96c4febc9d047b41789f2b7a73e3eaf4d35012b
|
||||
README.zh.md: 5bc2c3c8bb7a89afb673797f5a3b25bb9fc06748
|
||||
README.md: ad8f4bc2318a58202f9596a18d402d2c6d45dae1
|
||||
README.zh.md: ff2686856a9e518a7fa846edf700da1b32e8a114
|
||||
|
||||
@@ -10,11 +10,11 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
|
||||
|
||||
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: resolve and boundedly read the source through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server.
|
||||
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
|
||||
- Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process.
|
||||
- Uses `ctx.fs` canonical containment, file URIs, and stable bounded reads, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
- Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -42,7 +42,7 @@ Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: {
|
||||
|
||||
## Security boundary
|
||||
|
||||
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, no-follow/stable bounded reads, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
|
||||
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, regular-file streaming, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Containment is evaluated before the stream opens and does not promise stable-handle identity across concurrent path replacement. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
|
||||
|
||||
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
|
||||
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
|
||||
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析源文件并进行有界读取、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
|
||||
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `ctx.fs` 解析并流式读取源文件,同时执行字节上限;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,以及位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
|
||||
- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方资源释放会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找结算,再排空所有队列并等待所有服务器结算。
|
||||
- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
|
||||
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程与协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID 命名空间不得监控 harness 进程。
|
||||
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与稳定有界读取,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
|
||||
- 使用 `ctx.fs` 提供的规范 containment、文件 URI 与流式文本校验,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -42,7 +42,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
|
||||
|
||||
## 安全边界
|
||||
|
||||
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、不跟随符号链接的稳定有界读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效。
|
||||
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、普通文件流式读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。系统在打开流之前检查 containment,但不保证路径并发替换期间的稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与子进程提供方;分裂执行环境的组合无效。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Filesystem-seam source access for the generic stdio LSP provider. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { throwIfAborted } from './abort.ts'
|
||||
|
||||
@@ -58,9 +59,9 @@ export async function canonicalizeWorkspace(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, contain, and atomically read one bounded query source through
|
||||
* `ctx.fs`. The provider's bounded read owns stable-handle and no-follow
|
||||
* mechanics; this layer owns only LSP-facing validation and messages.
|
||||
* Resolve, contain, and read one byte-bounded query source through `ctx.fs`.
|
||||
* This layer owns the LSP-specific complete-document cap while the filesystem
|
||||
* provider owns streaming, regular-file checks, and UTF-8 validation.
|
||||
* @param fs - filesystem provider sharing the server's execution world.
|
||||
* @param filePath - absolute source path or path relative to `workspace`.
|
||||
* @param workspace - already-canonical workspace.
|
||||
@@ -90,17 +91,28 @@ export async function readHostSource(
|
||||
if (!fs.contains(workspace.target, target)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
let text: string
|
||||
const chunks: string[] = []
|
||||
let bytes = 0
|
||||
try {
|
||||
text = await fs.readTextBounded(target, maxDocumentBytes, signal)
|
||||
// XXX(lsp-source-replacement): Revisit stable-handle identity only if a real query observes
|
||||
// replacement between canonical containment and the provider opening this stream.
|
||||
const stream = await fs.streamText(target, signal)
|
||||
for await (const chunk of stream) {
|
||||
throwIfAborted(signal)
|
||||
bytes += Buffer.byteLength(chunk)
|
||||
if (bytes > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
throwIfAborted(signal)
|
||||
throw new Error(`source "${filePath}" could not be opened safely: ${messageOf(error)}`, { cause: error })
|
||||
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
return {
|
||||
fileUrl: fs.fileUrl(target),
|
||||
text,
|
||||
text: chunks.join(''),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,12 @@ describe('readHostSource', () => {
|
||||
await expect(readSource('big.ts', 10)).rejects.toThrow(/10-byte limit/)
|
||||
})
|
||||
|
||||
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
|
||||
await writeFile(join(ws, 'multibyte.ts'), '€abc')
|
||||
await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
|
||||
await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a non-UTF-8 source', async () => {
|
||||
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
|
||||
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
|
||||
|
||||
@@ -328,14 +328,17 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await expect(disposing).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('aborts a queued source read when the provider is disposed', async () => {
|
||||
it('aborts a queued source stream when the provider is disposed', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const fs = ctx.fs
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
vi.spyOn(fs, 'readTextBounded').mockImplementation(async (_target, _maxBytes, signal) => {
|
||||
vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
|
||||
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
|
||||
started.resolve(signal)
|
||||
return await rejectWhenAborted(signal)
|
||||
return (async function* () {
|
||||
await rejectWhenAborted(signal)
|
||||
yield ''
|
||||
})()
|
||||
})
|
||||
|
||||
const pending = ctx.lsp.query(query('goToDefinition'))
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
|
||||
export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
|
||||
@@ -144,52 +146,31 @@ export function renderUri(uri: string, workspaceUri: string): string {
|
||||
return uri
|
||||
}
|
||||
if (workspace.protocol !== 'file:') return uri
|
||||
const targetSegments = decodeFileSegments(target)
|
||||
const workspaceSegments = decodeFileSegments(workspace)
|
||||
if (targetSegments === undefined || workspaceSegments === undefined) return uri
|
||||
const sameAuthority = target.hostname === workspace.hostname
|
||||
const windowsWorld = isWindowsFileWorld(workspace, workspaceSegments)
|
||||
if (windowsWorld && [...targetSegments, ...workspaceSegments].some(segment => segment.includes('\\'))) return uri
|
||||
const inside = sameAuthority
|
||||
&& targetSegments.length >= workspaceSegments.length
|
||||
&& workspaceSegments.every((segment, index) => samePathSegment(segment, targetSegments[index] as string, windowsWorld))
|
||||
if (inside) {
|
||||
const relative = targetSegments.slice(workspaceSegments.length)
|
||||
return relative.length === 0 ? '.' : relative.join('/')
|
||||
}
|
||||
return absoluteUriPath(target, targetSegments, windowsWorld)
|
||||
const drivePath = /^\/[a-z](?::|%3A)/iu
|
||||
const windowsWorld = workspace.hostname.length > 0 || drivePath.test(workspace.pathname)
|
||||
const targetWindowsWorld = windowsWorld && (target.hostname.length > 0 || drivePath.test(target.pathname))
|
||||
const workspacePath = filePath(workspace, windowsWorld)
|
||||
const targetPath = filePath(target, targetWindowsWorld)
|
||||
if (workspacePath === undefined || targetPath === undefined) return uri
|
||||
if (windowsWorld !== targetWindowsWorld) return targetPath
|
||||
const path = windowsWorld ? win32 : posix
|
||||
const relative = path.relative(workspacePath, targetPath)
|
||||
const outside = relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
|
||||
const rendered = relative === '' ? '.' : outside ? targetPath : relative
|
||||
return windowsWorld ? rendered.replaceAll('\\', '/') : rendered
|
||||
}
|
||||
|
||||
/** Whether a canonical file URI names a drive path or UNC path in a Windows execution world. */
|
||||
function isWindowsFileWorld(url: URL, segments: readonly string[]): boolean {
|
||||
return url.hostname.length > 0 || /^[A-Za-z]:$/.test(segments[0] ?? '')
|
||||
}
|
||||
|
||||
/** Decode URI path segments while rejecting encoded POSIX separators and NUL. */
|
||||
function decodeFileSegments(url: URL): string[] | undefined {
|
||||
/** Decode a file URL for its execution world while containing malformed URL failures. */
|
||||
function filePath(url: URL, windows: boolean): string | undefined {
|
||||
try {
|
||||
const decoded = url.pathname.split('/').map(segment => decodeURIComponent(segment))
|
||||
if (decoded.some(segment => /[/\0]/u.test(segment))) return undefined
|
||||
while (decoded.at(-1) === '') decoded.pop()
|
||||
decoded.shift()
|
||||
return decoded
|
||||
const path = fileURLToPath(url, { windows })
|
||||
return path.includes('\0') ? undefined : path
|
||||
} catch {
|
||||
// `fileURLToPath` rejects malformed escapes, authorities, and encoded path separators.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Windows execution-world path segments are case-insensitive even on a non-Windows harness host. */
|
||||
function samePathSegment(left: string, right: string, windowsWorld: boolean): boolean {
|
||||
return windowsWorld ? left.toUpperCase() === right.toUpperCase() : left === right
|
||||
}
|
||||
|
||||
/** Render an external file URL according to the execution-world style implied by its workspace URI. */
|
||||
function absoluteUriPath(target: URL, segments: readonly string[], windowsWorld: boolean): string {
|
||||
if (target.hostname.length > 0) return `//${target.hostname}/${segments.join('/')}`
|
||||
if (windowsWorld && /^[A-Za-z]:$/.test(segments[0] ?? '')) return segments.join('/')
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* UI presentation for a pending `lsp` call. Uses a generic search card; the title carries the
|
||||
* operation and one-based cursor, and `locations` focuses the queried line. The shared location
|
||||
|
||||
@@ -103,6 +103,7 @@ describe('renderUri', () => {
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS_URI)).toBe('file:///bad%2Fpath')
|
||||
expect(renderUri('file:///bad%00path', WS_URI)).toBe('file:///bad%00path')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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/pty/pty-local/README.md
|
||||
README.md: bccef05ca73ec8d826621f814f232206fa9fea4b
|
||||
README.zh.md: 27d4d1193047eb1bb3bf2289697be3c0cc0253aa
|
||||
README.md: c4af7bc8293689d64c58eebab2606d4f9b52fd2f
|
||||
README.zh.md: 21075ccbf53d173a54220b58b384f01cfe50ced1
|
||||
|
||||
@@ -8,9 +8,9 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s
|
||||
|
||||
The plugin injects `pty`, `sandbox`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following a delayed earlier prompt therefore cannot settle the current send. After a signaled send returns `inferred_idle` without a prompt marker, the next marker remains attributed to that prior send and cannot settle a successor even when it follows the successor's echoed input. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The terminal-allocation signal is detached when allocation returns, while readiness initialization keeps the setup signal, so later cancellation cannot terminate a published persistent session. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. The canceled send retains its slot until foreground signalling settles, so a successor cannot become that signal's target. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. A send that times out during an asynchronous provider write, or whose cancellation signal fails while that write remains pending, reports its result but retains the slot until the write settles, so late bytes cannot interleave with a successor. Close rejects new public signals after shutdown begins, drains public signals already in flight, starts provider-owned TERM-to-KILL whole-session cleanup, and awaits quiescence after the terminal outcome. A cleanup failure does not cache a permanently rejected close; a later close retries the provider operation.
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
该插件注入 `pty`、`sandbox`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最近一个自有标记之后的可打印尾部与受控 `PS1` 完全相等时,系统才会把标记视为就绪;即使 OSC 标记和提示符被拆到多个数据回调中也是如此。因此,如果回显的输入或输出跟在延迟到达的先前提示符之后,该提示符无法使当前 send 完成。在一次经过信号处理的 send 未出现提示符标记却返回 `inferred_idle` 后,下一个标记仍归属于该先前 send;即使该标记出现在后续 send 的输入回显之后,也不能使后续 send 完成。系统会在写入边界丢弃提供方写入前收集的提示符与静默证据,包括写入前的前台检查尚未完成时收集的证据。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。终端分配返回时,分配信号会解除关联;就绪初始化则保留设置阶段信号,因此后续取消无法终止已发布的持久会话。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最近一个自有标记之后的可打印尾部与受控 `PS1` 完全相等时,系统才会把标记视为就绪;即使 OSC 标记和提示符被拆到多个数据回调中也是如此。因此,如果回显的输入或输出跟在先前提示符之后,该提示符无法使当前 send 完成。系统会在写入边界丢弃提供方写入前收集的提示符与静默证据,包括写入前的前台检查尚未完成时收集的证据。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方信号会转发给终端分配和就绪初始化;句柄一经发布,便负责自身生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。被取消的发送会保留其槽位,直至前台信号发送结算,因此后续发送不会成为该信号的目标。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。发送在提供方异步写入期间超时,或在该写入仍未完成时其取消信号发送失败,都会报告各自结果,但继续占用该槽位,直至写入结算,从而避免延迟到达的字节与后续发送交错。关闭操作会在关闭开始后拒绝新的公开信号请求,等待已经在途的公开信号请求全部结算,再启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。
|
||||
取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待写入结算;写入被拒绝时不会发送信号。被取消的发送会保留其槽位,直至写入和前台信号发送都结算,因此后续发送既不会收到延迟字节,也不会成为该信号的目标。取消等待期间,绝对截止时间仍保持启用。信号发送失败属于终结性传输失败,并会使当前发送被拒绝。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待句柄执行由提供方负责的完整会话终止,之后才将当前发送以 `session_exit` 结算。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -101,30 +101,15 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
let terminalSignal: AbortSignal | undefined
|
||||
let detachSetupSignal: (() => void) | undefined
|
||||
if (spec.signal !== undefined) {
|
||||
const source = spec.signal
|
||||
const controller = new AbortController()
|
||||
const onAbort = (): void => { controller.abort(source.reason) }
|
||||
source.addEventListener('abort', onAbort, { once: true })
|
||||
detachSetupSignal = () => { source.removeEventListener('abort', onAbort) }
|
||||
terminalSignal = controller.signal
|
||||
}
|
||||
let terminal: SubprocessTerminalHandle
|
||||
try {
|
||||
terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: terminalSignal,
|
||||
})
|
||||
} finally {
|
||||
detachSetupSignal?.()
|
||||
}
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: spec.signal,
|
||||
})
|
||||
const session = this.createSession(terminal, this.config)
|
||||
try {
|
||||
await session.initialize(spec.signal)
|
||||
|
||||
@@ -155,7 +155,7 @@ class LocalSendOperation implements PtySendOperation {
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
private readonly decoder = new TextDecoder()
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly outputEnded = Promise.withResolvers<void>()
|
||||
@@ -165,16 +165,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private readonly terminalOperations = new Set<Promise<unknown>>()
|
||||
private signaledOperation: LocalSendOperation | undefined
|
||||
private interrupting: LocalSendOperation | undefined
|
||||
private writing: LocalSendOperation | undefined
|
||||
private activeWrite: { operation: LocalSendOperation; settled: Promise<boolean> } | undefined
|
||||
private pollingReady: LocalSendOperation | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private promptTail = ''
|
||||
private delayedSignaledPrompt = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
@@ -239,23 +236,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
this.activeDeadlineTimer = setTimeout(() => {
|
||||
if (this.active === operation) this.settleActive('timeout', this.writing === operation)
|
||||
if (this.active === operation) {
|
||||
this.settleActive('timeout', this.activeWrite?.operation === operation)
|
||||
}
|
||||
}, this.config.timeoutMs)
|
||||
this.ownTerminalOperation(this.beginSend(operation, request))
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Retain one contained provider operation until its asynchronous work finishes. */
|
||||
private ownTerminalOperation(operation: Promise<void>): void {
|
||||
void this.trackTerminalOperation(operation)
|
||||
}
|
||||
|
||||
private trackTerminalOperation<T>(operation: Promise<T>): Promise<T> {
|
||||
const tracked = operation.finally(() => { this.terminalOperations.delete(tracked) })
|
||||
this.terminalOperations.add(tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||
try {
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
@@ -264,13 +252,20 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0 && !operation.cancelRequested) {
|
||||
this.resetReadinessEvidence()
|
||||
this.writing = operation
|
||||
const write = this.terminal.write(input)
|
||||
const activeWrite = {
|
||||
operation,
|
||||
settled: write.then(() => true, () => false),
|
||||
}
|
||||
this.activeWrite = activeWrite
|
||||
try {
|
||||
await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
await write
|
||||
} finally {
|
||||
this.writing = undefined
|
||||
this.activeWrite = undefined
|
||||
}
|
||||
}
|
||||
// Cancellation owns post-write signalling and reservation release.
|
||||
if (operation.cancelRequested) return
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
return
|
||||
@@ -282,7 +277,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.schedulePoll(operation)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) {
|
||||
if (this.active === operation && !this.closing) {
|
||||
if (operation.settled) this.clearActive()
|
||||
else this.failActive(error)
|
||||
}
|
||||
@@ -323,8 +318,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
async signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.active !== undefined) this.signaledOperation = this.active
|
||||
const targetPgid = await this.trackTerminalOperation(this.terminal.signalForeground(signal))
|
||||
const targetPgid = await this.terminal.signalForeground(signal)
|
||||
return { delivered: true, targetPgid }
|
||||
}
|
||||
|
||||
@@ -345,23 +339,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
|
||||
try {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY emitted invalid UTF-8', { cause: error }))
|
||||
}
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
}
|
||||
|
||||
private readonly onTerminalEnd = (): void => {
|
||||
try {
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY ended with invalid UTF-8', { cause: error }))
|
||||
} finally {
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
|
||||
private readonly onTerminalError = (error: Error): void => {
|
||||
@@ -372,9 +357,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private onData(data: string): void {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt && this.delayedSignaledPrompt) {
|
||||
this.delayedSignaledPrompt = false
|
||||
} else if (sanitized.prompt) {
|
||||
if (sanitized.prompt) {
|
||||
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
|
||||
// before attributing a signal-delayed prompt to a later send.
|
||||
// Bash can print PROMPT_COMMAND before the kernel publishes its return
|
||||
// to the foreground process group. Retain the marker; polling below is
|
||||
// the authority that accepts it only after bash owns the foreground.
|
||||
@@ -402,7 +387,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.transportFailure ??= failure
|
||||
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
this.failActive(failure)
|
||||
this.terminal.terminate()
|
||||
void this.terminal.terminate().catch(() => {})
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
@@ -417,7 +402,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = setTimeout(() => {
|
||||
this.activeTimer = undefined
|
||||
this.ownTerminalOperation(this.pollReadiness(operation))
|
||||
void this.pollReadiness(operation)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
@@ -457,7 +442,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.settleActive('inferred_idle')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
if (this.active === operation && !this.closing) this.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
const active = this.active
|
||||
@@ -470,13 +455,6 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
// A signaled command can return by silence before bash emits its prompt.
|
||||
// Reserve that marker so it cannot become successor readiness after echo.
|
||||
const signaled = this.signaledOperation === operation
|
||||
if (signaled) this.signaledOperation = undefined
|
||||
if (waitReason === 'inferred_idle' && !this.promptSeen && signaled) {
|
||||
this.delayedSignaledPrompt = true
|
||||
}
|
||||
const scrollbackTruncated = this.scrollback.snapshot().truncated
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
@@ -489,10 +467,14 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.stopReadinessPolling()
|
||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||
this.activeDeadlineTimer = undefined
|
||||
}
|
||||
|
||||
private stopReadinessPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.pollingReady = undefined
|
||||
}
|
||||
|
||||
@@ -502,43 +484,38 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
if (this.signaledOperation === operation) this.signaledOperation = undefined
|
||||
this.writing = undefined
|
||||
this.pollingReady = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown, retainOwnership = false): void {
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
if (retainOwnership) {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
} else {
|
||||
this.clearActive()
|
||||
}
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
this.signaledOperation = operation
|
||||
this.interrupting = operation
|
||||
this.stopPolling()
|
||||
this.ownTerminalOperation(this.interruptOnce(operation))
|
||||
this.stopReadinessPolling()
|
||||
void this.interruptOnce(operation)
|
||||
}
|
||||
|
||||
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
|
||||
try {
|
||||
const activeWrite = this.activeWrite
|
||||
if (activeWrite?.operation === operation && !await activeWrite.settled) return
|
||||
await this.terminal.signalForeground('SIGINT')
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error, this.writing === operation)
|
||||
if (this.active === operation && !this.closing) this.onTransportFailure(error)
|
||||
return
|
||||
} finally {
|
||||
if (this.interrupting === operation) this.interrupting = undefined
|
||||
}
|
||||
if (this.active === operation && !operation.settled && !this.closing && this.writing !== operation) {
|
||||
if (this.active === operation && operation.settled) {
|
||||
this.clearActive()
|
||||
} else if (this.active === operation && !this.closing) {
|
||||
this.pollingReady = operation
|
||||
this.schedulePoll(operation, 0)
|
||||
}
|
||||
@@ -549,20 +526,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
this.terminal.terminate()
|
||||
const quiescent = await this.terminal.waitForExit()
|
||||
if (!quiescent) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
|
||||
try {
|
||||
await this.terminal.terminate()
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
|
||||
}
|
||||
// Quiescence is the active send's terminal outcome. Detach its abort
|
||||
// listener before snapshotting provider operations so no late interrupt
|
||||
// can enter the owned set after the drain starts.
|
||||
// Quiescence is the active send's terminal outcome.
|
||||
this.settleActive('session_exit')
|
||||
await Promise.all(this.terminalOperations)
|
||||
// Whole-session cleanup can fail before the top-level process exits. Wait
|
||||
// for it first so that failure is reported instead of blocking forever on
|
||||
// `done`; successful quiescence guarantees `done` can now settle status and
|
||||
// drain the terminal output.
|
||||
await this.completion
|
||||
this.terminal.output.off('data', this.onTerminalData)
|
||||
this.terminal.output.off('end', this.onTerminalEnd)
|
||||
|
||||
@@ -61,8 +61,7 @@ function terminalHandle(): SubprocessTerminalHandle {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate: () => { output.end() },
|
||||
waitForExit: async () => true,
|
||||
terminate: async () => { output.end() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +186,7 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('forwards setup cancellation only while terminal allocation is unpublished', async () => {
|
||||
it('forwards terminal allocation cancellation directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
@@ -204,10 +203,9 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
() => stubLocalSession(),
|
||||
)
|
||||
await published.spawn(spec(agent(ctx), publishedController.signal))
|
||||
expect(publishedSignal).toBeDefined()
|
||||
expect(publishedSignal).not.toBe(publishedController.signal)
|
||||
expect(publishedSignal).toBe(publishedController.signal)
|
||||
publishedController.abort(new Error('originating turn ended'))
|
||||
expect(publishedSignal?.aborted).toBe(false)
|
||||
expect(publishedSignal?.aborted).toBe(true)
|
||||
|
||||
const pendingController = new AbortController()
|
||||
const seen = Promise.withResolvers<AbortSignal>()
|
||||
@@ -245,11 +243,10 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate() {
|
||||
async terminate() {
|
||||
output.end()
|
||||
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
|
||||
},
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
|
||||
const backend = new LocalPtyBackend(
|
||||
|
||||
@@ -50,8 +50,8 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
quiescent = true
|
||||
waitError: Error | undefined
|
||||
terminateError: Error | undefined
|
||||
private cleanup: Promise<void> | undefined
|
||||
|
||||
constructor(public inspector = new FakeInspector()) {}
|
||||
|
||||
@@ -80,9 +80,9 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
})
|
||||
}
|
||||
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
async write(data: string): Promise<void> {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(Buffer.from(data).toString('utf8'))
|
||||
this.writes.push(data)
|
||||
}
|
||||
|
||||
async inspectForeground() {
|
||||
@@ -102,16 +102,20 @@ class FakeTerminal implements SubprocessTerminalHandle {
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.terminateOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch(() => { this.cleanup = undefined })
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private async terminateOnce(): Promise<void> {
|
||||
if (this.terminateError !== undefined) throw this.terminateError
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push('SIGTERM')
|
||||
if (this.autoExitOnKill) this.emitExit(0, 15)
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
if (this.waitError !== undefined) throw this.waitError
|
||||
return this.quiescent
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
@@ -370,6 +374,100 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
|
||||
})
|
||||
|
||||
it('signals only after an in-flight provider write lands', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'must be interrupted', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.groups).toEqual([])
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await operation.done
|
||||
})
|
||||
|
||||
it('does not signal when a cancelled provider write rejects', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'rejected write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
|
||||
const rejected = expect(operation.done).rejects.toThrow('write failed after cancellation')
|
||||
writeGate.reject(new Error('write failed after cancellation'))
|
||||
await rejected
|
||||
expect(inspector.groups).toEqual([])
|
||||
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('releases a timed-out cancellation after the provider write and signal settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
const operation = session.startSend({ text: 'slow cancelled write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect((await operation.done).waitReason).toBe('timeout')
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('retains the absolute timeout after cancellation while output stays active', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'ignore-sigint-and-write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(operation.cancel()).toBe(true)
|
||||
for (let elapsed = 20; elapsed <= 100; elapsed += 20) {
|
||||
terminal.emitData('.')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
}
|
||||
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'timeout' })
|
||||
})
|
||||
|
||||
it('does not resume cancellation polling after the terminal exits during signalling', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -437,21 +535,24 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await writeGate.promise }
|
||||
terminal.signalForeground = async () => { throw new Error('interrupt failed') }
|
||||
let signalCalls = 0
|
||||
terminal.signalForeground = async () => {
|
||||
signalCalls += 1
|
||||
throw new Error('interrupt failed')
|
||||
}
|
||||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const rejected = expect(operation.done).rejects.toThrow('interrupt failed')
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await rejected
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(signalCalls).toBe(0)
|
||||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||||
|
||||
writeGate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const next = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||||
await expect(operation.done).rejects.toThrow('interrupt failed')
|
||||
expect(signalCalls).toBe(1)
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
@@ -591,35 +692,6 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read' })
|
||||
})
|
||||
|
||||
it('does not attribute a post-echo prompt from an inferred prior send to its successor', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config({ idleSilenceMs: 50, timeoutMs: 200 }))
|
||||
await initialize(session, terminal)
|
||||
|
||||
const interrupted = session.startSend({ text: 'sleep', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await session.signal('SIGINT')
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect((await interrupted.done).waitReason).toBe('inferred_idle')
|
||||
|
||||
const successor = session.startSend({ text: "printf 'PID=%s\\n' \"$!\"", submit: true })
|
||||
let settled = false
|
||||
void successor.done.then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
terminal.emitData('printf \'PID=%s\\n\' "$!"\r\n\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
terminal.emitData('PID=123\r\n\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
const result = await successor.done
|
||||
expect(result.waitReason).toBe('stdin_read')
|
||||
expect(result.viewport).toContain('PID=123')
|
||||
})
|
||||
|
||||
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -683,6 +755,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
it('contains terminal transport failures and preserves the first failure', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.terminateError = new Error('cleanup after transport failure')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
terminal.output.emit('data', 'plain text')
|
||||
@@ -691,6 +764,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
.onTransportFailure(new Error('later failure'))
|
||||
await expect(operation.done).rejects.toThrow('output transport failed')
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
terminal.terminateError = undefined
|
||||
await expect(session.close('transport')).rejects.toThrow('output transport failed')
|
||||
|
||||
const rejectedTerminal = new FakeTerminal()
|
||||
@@ -700,19 +774,21 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 in a data chunk and at stream end', async () => {
|
||||
it('replaces invalid UTF-8 terminal output', async () => {
|
||||
const chunkTerminal = new FakeTerminal()
|
||||
const chunkSession = new LocalPtySession(chunkTerminal, config())
|
||||
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
|
||||
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
|
||||
await expect(chunkOperation.done).rejects.toThrow('PTY emitted invalid UTF-8')
|
||||
expect(chunkOperation.readOutput()).toEqual({ delta: '<27>', truncated: false })
|
||||
chunkTerminal.emitExit()
|
||||
await chunkOperation.done
|
||||
|
||||
const endTerminal = new FakeTerminal()
|
||||
const endSession = new LocalPtySession(endTerminal, config())
|
||||
const endOperation = endSession.startSend({ text: '', submit: false })
|
||||
endTerminal.emitBytes(Uint8Array.from([0xe2]))
|
||||
endTerminal.emitExit()
|
||||
await expect(endOperation.done).rejects.toThrow('PTY ended with invalid UTF-8')
|
||||
expect((await endOperation.done).viewport).toBe('<EFBFBD>')
|
||||
})
|
||||
|
||||
it('contains readiness inspection failure and a stale inspection result', async () => {
|
||||
@@ -952,43 +1028,32 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||||
})
|
||||
|
||||
it('drains an in-flight public signal and rejects signals after close starts', async () => {
|
||||
it('closes idempotently and rejects new signals', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
const signal = Promise.withResolvers<number>()
|
||||
terminal.signalForeground = async () => await signal.promise
|
||||
|
||||
const signaling = session.signal('SIGINT')
|
||||
const closing = session.close('public signal')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
|
||||
|
||||
signal.resolve(456)
|
||||
await expect(signaling).resolves.toEqual({ delivered: true, targetPgid: 456 })
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('closes idempotently, contains signal races, and reports survivors', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.quiescent = false
|
||||
terminal.throwKill = true
|
||||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
|
||||
const closing = session.close('test')
|
||||
expect(session.close('other')).toBe(closing)
|
||||
await expect(closing).rejects.toThrow('did not reach quiescence')
|
||||
await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
|
||||
})
|
||||
|
||||
it('reports cleanup failure without waiting for top-level exit', async () => {
|
||||
it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.autoExitOnKill = false
|
||||
terminal.waitError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
terminal.terminateError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
|
||||
await expect(session.close('survivor')).rejects.toThrow('surviving pids: 456')
|
||||
await expect(session.close('survivor')).rejects.toMatchObject({
|
||||
message: 'PTY cleanup failed (survivor)',
|
||||
cause: terminal.terminateError,
|
||||
})
|
||||
expect(terminal.kills).toEqual([])
|
||||
|
||||
terminal.terminateError = undefined
|
||||
terminal.autoExitOnKill = true
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
@@ -1010,73 +1075,25 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await closing
|
||||
})
|
||||
|
||||
it('does not finish close while a pre-write terminal operation is pending', async () => {
|
||||
it('settles a closing send when provider termination cancels inspection', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await inspection.promise
|
||||
const operation = session.startSend({ text: 'must not run', submit: true })
|
||||
const closing = session.close('pending inspection')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
const terminate = terminal.terminate.bind(terminal)
|
||||
terminal.terminate = async () => {
|
||||
inspection.reject(new Error('terminal terminated'))
|
||||
await terminate()
|
||||
}
|
||||
const operation = session.startSend({ text: 'pending inspection', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
|
||||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
await session.close('pending inspection')
|
||||
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
expect(terminal.writes).toEqual([])
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
it('does not finish close while a terminal write is pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const write = Promise.withResolvers<undefined>()
|
||||
terminal.write = async () => { await write.promise }
|
||||
const operation = session.startSend({ text: 'pending write', submit: true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const closing = session.close('pending write')
|
||||
let closed = false
|
||||
void closing.then(() => { closed = true })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(closed).toBe(false)
|
||||
|
||||
write.resolve(undefined)
|
||||
await closing
|
||||
expect(closed).toBe(true)
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
it('detaches active cancellation before draining terminal operations', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||||
terminal.inspectForeground = async () => await inspection.promise
|
||||
const signalForeground = vi.spyOn(terminal, 'signalForeground')
|
||||
const controller = new AbortController()
|
||||
const operation = session.startSend({ text: 'pending inspection', submit: true, signal: controller.signal })
|
||||
const closing = session.close('pending cancellation')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
controller.abort('late cancellation')
|
||||
expect(signalForeground).not.toHaveBeenCalled()
|
||||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await closing
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -92,12 +92,6 @@ class TestFileSystem extends FileSystem {
|
||||
return text
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
const text = await this.readText(target, signal)
|
||||
if (Buffer.byteLength(text) > maxBytes) throw new Error('too large')
|
||||
return text
|
||||
}
|
||||
|
||||
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
throw new Error('not needed in skill tests')
|
||||
}
|
||||
|
||||
@@ -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: 9f30fa6dc676e7b82f87b75b78b7d3143f204c94
|
||||
README.zh.md: 0b6813ec7d754f42e8bf4c65a1dee33d77bf3787
|
||||
README.md: 31c5539750c4af2b1c4169ac1dce4c91da587af7
|
||||
README.zh.md: 74aed52084c0db3ed9bfe4fcb991f781976fb7b5
|
||||
|
||||
@@ -11,7 +11,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
|
||||
- **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 before any process-cleanup failure is reported, 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 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. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
||||
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation 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. 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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **凭据清除 + 显式合并**:以 `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 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid/启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
|
||||
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,并公开一项须等待的终止操作,该操作会在终止顶层 shell 前后清理后代。每次前台检查都会保留有根进程树中的精确身份;Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid/启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。
|
||||
- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -59,12 +59,7 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
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().then(() => { this.terminals.delete(terminal) }))
|
||||
pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
|
||||
}
|
||||
this.live.clear()
|
||||
const outcomes = [
|
||||
@@ -136,11 +131,6 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
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',
|
||||
@@ -151,10 +141,10 @@ export class LocalSubprocessService extends SubprocessService {
|
||||
}
|
||||
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)
|
||||
const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs)
|
||||
this.terminals.add(handle)
|
||||
const release = async (): Promise<void> => {
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
this.terminals.delete(handle)
|
||||
}
|
||||
void handle.done.then(release, release).catch(() => {})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Buffer } from 'node:buffer'
|
||||
import { constants } from 'node:os'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
@@ -34,7 +33,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
private readonly outcome = Promise.withResolvers<SubprocessOutcome>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
private readonly exitDisposable: IDisposable
|
||||
private readonly lifecycle: SubprocessTerminalLifecycle
|
||||
private cleanup: Promise<void> | undefined
|
||||
private exited = false
|
||||
private trackedDescendants: ProcessIdentity[] = []
|
||||
|
||||
@@ -42,13 +41,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
* @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
|
||||
@@ -61,26 +58,15 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
|
||||
signal: signalName(exitSignal),
|
||||
})
|
||||
this.terminate()
|
||||
})
|
||||
this.lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: this.done,
|
||||
cleanup: () => this.closeOnce(),
|
||||
signal,
|
||||
void this.terminate().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// 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> {
|
||||
async write(data: string): 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)
|
||||
this.terminal.write(data)
|
||||
}
|
||||
|
||||
// Local inspection is synchronous; the seam returns a promise for remote transports.
|
||||
@@ -107,12 +93,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.lifecycle.terminate()
|
||||
}
|
||||
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await this.lifecycle.waitForExit(signal)
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.closeOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch(() => { this.cleanup = undefined })
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('validates terminal spawn specs before allocating a PTY', async () => {
|
||||
it('validates terminal allocation inputs before allocating a PTY', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const base: SubprocessTerminalSpawnSpec = {
|
||||
@@ -89,9 +89,6 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
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()
|
||||
})
|
||||
@@ -99,8 +96,7 @@ describe('LocalSubprocessService', () => {
|
||||
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 terminate = vi.fn(async () => {})
|
||||
const terminal: SubprocessTerminalHandle = {
|
||||
pid: 1,
|
||||
output: new PassThrough(),
|
||||
@@ -109,13 +105,11 @@ describe('LocalSubprocessService', () => {
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate,
|
||||
waitForExit,
|
||||
}
|
||||
const terminals = (ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
await fiber.dispose()
|
||||
expect(terminate).toHaveBeenCalledOnce()
|
||||
expect(waitForExit).toHaveBeenCalledOnce()
|
||||
expect(terminals.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -124,8 +118,8 @@ describe('LocalSubprocessService', () => {
|
||||
const fiber = await ctx.plugin(LocalSubprocessService)
|
||||
const service = ctx.subprocess
|
||||
const runtimeRoot = service.runtimeRoot
|
||||
const firstFailure = new Error('first retryable cleanup failure')
|
||||
const secondFailure = new Error('second retryable cleanup failure')
|
||||
const firstFailure = new Error('first cleanup failure')
|
||||
const secondFailure = new Error('second cleanup failure')
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const failedTerminal: SubprocessTerminalHandle = {
|
||||
@@ -135,22 +129,19 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw firstFailure }),
|
||||
terminate: vi.fn(async () => { throw firstFailure }),
|
||||
}
|
||||
const secondFailedTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw secondFailure }),
|
||||
terminate: vi.fn(async () => { throw secondFailure }),
|
||||
}
|
||||
let finishCleanup!: () => void
|
||||
const cleanup = new Promise<boolean>((resolve) => {
|
||||
finishCleanup = () => { resolve(true) }
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve
|
||||
})
|
||||
const drainingTerminal: SubprocessTerminalHandle = {
|
||||
...failedTerminal,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(() => cleanup),
|
||||
terminate: vi.fn(() => cleanup),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(failedTerminal)
|
||||
@@ -187,8 +178,7 @@ describe('LocalSubprocessService', () => {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => undefined,
|
||||
signalForeground: async () => 1,
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw failure }),
|
||||
terminate: vi.fn(async () => { throw failure }),
|
||||
}
|
||||
const terminals = (service as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals
|
||||
terminals.add(terminal)
|
||||
@@ -247,7 +237,7 @@ describe('LocalSubprocessService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('contains a terminal release failure after top-level exit', async () => {
|
||||
it('retains a terminal whose automatic cleanup fails', async () => {
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
const terminal = {
|
||||
pid: 123,
|
||||
@@ -264,6 +254,8 @@ describe('LocalSubprocessService', () => {
|
||||
try {
|
||||
const { default: IsolatedLocalSubprocessService } = await import('../src/index.ts')
|
||||
const ctx = new Context()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const fiber = await ctx.plugin(IsolatedLocalSubprocessService)
|
||||
const alive = new Set([124])
|
||||
;(ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessService>).terminalInspector = {
|
||||
@@ -281,10 +273,9 @@ describe('LocalSubprocessService', () => {
|
||||
exitListener?.({ exitCode: 0 })
|
||||
await handle.done
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
alive.clear()
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
expect((ctx.subprocess as unknown as { terminals: Set<SubprocessTerminalHandle> }).terminals.size).toBe(1)
|
||||
await fiber.dispose()
|
||||
expect(disposalErrors).toHaveLength(1)
|
||||
} finally {
|
||||
vi.doUnmock('node-pty')
|
||||
vi.resetModules()
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('LocalTerminalHandle', () => {
|
||||
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
|
||||
pty.emitData('hello €')
|
||||
await handle.write(Buffer.from('input\r'))
|
||||
await handle.write('input\r')
|
||||
expect(pty.writes).toEqual(['input\r'])
|
||||
expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true })
|
||||
expect(await handle.signalForeground('SIGINT')).toBe(456)
|
||||
@@ -98,16 +98,14 @@ describe('LocalTerminalHandle', () => {
|
||||
pty.emitExit(7, 9)
|
||||
pty.emitExit(0)
|
||||
expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €')
|
||||
})
|
||||
|
||||
it('rejects invalid input and unsafe foreground signals', async () => {
|
||||
it('rejects unsafe foreground signals and writes after exit', 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
|
||||
@@ -116,8 +114,8 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
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')
|
||||
await handle.terminate()
|
||||
await expect(handle.write('late')).rejects.toThrow('has exited')
|
||||
})
|
||||
|
||||
it('keeps the shell alive until forced descendants leave', async () => {
|
||||
@@ -129,15 +127,14 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
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)
|
||||
await quiescent
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
@@ -149,17 +146,16 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const waiting = handle.waitForExit()
|
||||
pty.emitExit()
|
||||
const waiting = handle.terminate()
|
||||
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)
|
||||
await waiting
|
||||
})
|
||||
|
||||
it('cleans a same-session descendant after the top-level shell exits naturally', async () => {
|
||||
@@ -172,7 +168,7 @@ describe('LocalTerminalHandle', () => {
|
||||
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -188,7 +184,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = []
|
||||
pty.emitExit()
|
||||
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM']])
|
||||
})
|
||||
|
||||
@@ -209,8 +205,7 @@ describe('LocalTerminalHandle', () => {
|
||||
return []
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
@@ -225,14 +220,13 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
await handle.waitForExit()
|
||||
await handle.terminate()
|
||||
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('keeps a failed post-shell sweep retryable until its survivor leaves', async () => {
|
||||
it('retries failed cleanup after a surviving descendant leaves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
@@ -244,14 +238,15 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
|
||||
handle.terminate()
|
||||
const failed = expect(handle.waitForExit()).rejects.toThrow('surviving pids: 124')
|
||||
const first = handle.terminate()
|
||||
const failed = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
|
||||
inspector.alive.delete(late.pid)
|
||||
handle.terminate()
|
||||
expect(await handle.waitForExit()).toBe(true)
|
||||
const retry = handle.terminate()
|
||||
expect(retry).not.toBe(first)
|
||||
await retry
|
||||
expect(inspector.processes).toEqual([[late.pid, 'SIGTERM'], [late.pid, 'SIGKILL']])
|
||||
})
|
||||
|
||||
@@ -268,82 +263,35 @@ describe('LocalTerminalHandle', () => {
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
handle.terminate()
|
||||
const quiescent = handle.waitForExit()
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(await quiescent).toBe(true)
|
||||
await quiescent
|
||||
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 () => {
|
||||
it('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')
|
||||
const failed = expect(handle.terminate()).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)
|
||||
await handle.terminate()
|
||||
})
|
||||
|
||||
it('contains process races and reacts to lifetime cancellation', async () => {
|
||||
it('contains process races while reporting surviving descendants', 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'])
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
|
||||
await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: d03824da33bb44b2525b1343a27557ed15823418
|
||||
README.zh.md: 34cc75c3cfc8148754343e92b0c204760b1fb543
|
||||
README.md: 2fca5cdd08f3cbd36d8ef492226c91681b5568d0
|
||||
README.zh.md: 5830d0fc394160bc557e24646cc76d4bcfead13c
|
||||
|
||||
@@ -11,8 +11,7 @@ The subprocess seam (`ctx.subprocess`) is the process half of one execution worl
|
||||
- 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).
|
||||
- `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.
|
||||
- `SubprocessTerminalLifecycle` composes a handle's top-level `done` promise with its provider-owned session cleanup. It binds lifetime cancellation, shares one active cleanup attempt, permits a failed attempt to retry, normalizes cleanup rejections, and bounds quiescence observation without knowing the provider's process mechanics.
|
||||
- `spawnTerminal(spec)` is the only non-pipe primitive. Its handle owns a real PTY, UTF-8 text I/O, foreground-process-group inspection/signalling, and one awaited `terminate()` operation that reaches whole-session quiescence and settles in-flight handle calls. The spec signal cancels allocation only; the published handle owns its lifetime. The output stream ends after queued output when the top-level process exits, and a live transport failure rejects `done`. These operations remain one substrate primitive because ordinary pipes cannot allocate a controlling terminal or clean 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.
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
- 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、拆卸阶梯与原因分类归调用方所有)。
|
||||
- `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、有效 UTF-8 字节 I/O、前台进程组检查/信号发送、TERM→KILL 全会话清理,以及等待完全停稳。顶层进程退出后,输出流会在排完队列中的输出后结束;存活期间的传输故障会拒绝 `done`。这些操作仍属于一项基底原语,因为普通管道无法分配控制终端,也无法证明并清理完整的终端会话;就绪检测、scrollback 与所有者策略仍归 PTY 消费方所有。
|
||||
- `SubprocessTerminalLifecycle` 把句柄的顶层 `done` promise 与由提供方负责的会话清理组合起来。它绑定生命周期取消,共享同一个进行中的清理尝试,允许失败的尝试重试,规范化清理拒绝,并在不了解提供方进程机制的情况下对完全停稳观测施加上限。
|
||||
- `spawnTerminal(spec)` 是唯一的非管道原语。其句柄负责真实 PTY、UTF-8 文本 I/O、前台进程组检查/信号发送,以及一项须等待的 `terminate()` 操作;该操作会使整个会话完全停稳,并让所有在途句柄调用结算。spec 信号只取消分配;句柄一经发布,便负责自身生命周期。顶层进程退出后,输出流会在排完队列中的输出后结束;存活期间的传输故障会拒绝 `done`。这些操作仍属于一项基底原语,因为普通管道无法分配控制终端,也无法清理完整的终端会话;就绪检测、scrollback 与所有者策略仍归 PTY 消费方所有。
|
||||
- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并。本地普通 spawn 与终端 spawn 都应用这一定义;自行拥有 spawn 的 SDK 管理传输层可以直接导入它。
|
||||
- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
export { SubprocessTerminalLifecycle } from './terminal-lifecycle.ts'
|
||||
export type { SubprocessTerminalLifecycleOptions } from './terminal-lifecycle.ts'
|
||||
export type {
|
||||
CollectedOutput,
|
||||
DshEnvironment,
|
||||
@@ -95,10 +93,11 @@ 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.
|
||||
* - {@link spawnTerminal} owns terminal allocation, text transport,
|
||||
* foreground groups, signalling, and whole-session quiescence behind one
|
||||
* awaited termination method; readiness and persistent-shell policy stay
|
||||
* in the PTY consumer. Its output stream ends after queued terminal output
|
||||
* when the top-level process exits.
|
||||
*/
|
||||
export abstract class SubprocessService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
@@ -138,7 +137,7 @@ export abstract class SubprocessService extends Service {
|
||||
* 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.
|
||||
* @param spec - fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.
|
||||
* @returns the live terminal handle after allocation succeeds.
|
||||
*/
|
||||
abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/** Provider-neutral lifecycle transaction for terminal-process handles. */
|
||||
|
||||
/** Inputs owned by one terminal-process lifecycle controller. */
|
||||
export interface SubprocessTerminalLifecycleOptions {
|
||||
/** Settlement of the top-level terminal process or its live transport. */
|
||||
readonly done: Promise<unknown>
|
||||
/** Provider-owned cleanup that reaches whole-session quiescence. */
|
||||
readonly cleanup: () => Promise<void>
|
||||
/** Optional cancellation for the complete terminal lifetime. */
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
function normalizeCleanupError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates terminal cleanup without knowing how a provider allocates or
|
||||
* terminates its process session. One active cleanup attempt is shared by all
|
||||
* callers; a rejected attempt may be retried, and successful cleanup removes
|
||||
* the lifetime abort listener.
|
||||
*/
|
||||
export class SubprocessTerminalLifecycle {
|
||||
private cleanupAttempt: Promise<void> | undefined
|
||||
private removeLifetimeAbort: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* @param options - top-level settlement, provider cleanup, and lifetime cancellation.
|
||||
*/
|
||||
constructor(private readonly options: SubprocessTerminalLifecycleOptions) {
|
||||
const onDone = (): void => { this.terminate() }
|
||||
void options.done.then(onDone, onDone)
|
||||
|
||||
if (options.signal !== undefined) {
|
||||
const onAbort = (): void => { this.terminate() }
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.removeLifetimeAbort = () => { options.signal?.removeEventListener('abort', onAbort) }
|
||||
if (options.signal.aborted) this.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin an idempotent provider cleanup attempt. */
|
||||
terminate(): void {
|
||||
void this.startCleanup().catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for top-level settlement and successful whole-session cleanup.
|
||||
* @param signal - optional bound for this observation only.
|
||||
* @returns true after quiescence, false when the observer signal aborts first.
|
||||
*/
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
const quiescence = this.cleanupAttempt ?? this.options.done.then(
|
||||
() => this.startCleanup(),
|
||||
() => this.startCleanup(),
|
||||
)
|
||||
if (signal === undefined) {
|
||||
await quiescence
|
||||
return true
|
||||
}
|
||||
if (signal.aborted) return false
|
||||
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
let settled = false
|
||||
const finish = (complete: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
complete()
|
||||
}
|
||||
const onAbort = (): void => { finish(() => { resolve(false) }) }
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) onAbort()
|
||||
void quiescence.then(
|
||||
() => { finish(() => { resolve(true) }) },
|
||||
(error: unknown) => { finish(() => { reject(normalizeCleanupError(error)) }) },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private startCleanup(): Promise<void> {
|
||||
if (this.cleanupAttempt !== undefined) return this.cleanupAttempt
|
||||
|
||||
const outcome = Promise.withResolvers<void>()
|
||||
this.cleanupAttempt = outcome.promise.catch((error: unknown) => {
|
||||
this.cleanupAttempt = undefined
|
||||
throw normalizeCleanupError(error)
|
||||
})
|
||||
void this.cleanupAttempt.then(
|
||||
() => {
|
||||
this.removeLifetimeAbort?.()
|
||||
this.removeLifetimeAbort = undefined
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
try {
|
||||
void this.options.cleanup().then(outcome.resolve, outcome.reject)
|
||||
} catch (error: unknown) {
|
||||
outcome.reject(error)
|
||||
}
|
||||
return this.cleanupAttempt
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export interface SubprocessTerminalSpawnSpec {
|
||||
cols: number
|
||||
/** TERM-to-KILL cleanup grace for the complete terminal session. */
|
||||
graceMs: number
|
||||
/** Cancellation of setup or the live terminal session. */
|
||||
/** Cancellation of terminal allocation; a published handle owns its later lifetime. */
|
||||
signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
@@ -236,10 +236,10 @@ export interface SubprocessTerminalHandle {
|
||||
/** 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 text to the terminal input.
|
||||
* @param data - text to deliver without implicit newline conversion.
|
||||
*/
|
||||
write(data: Uint8Array): Promise<void>
|
||||
write(data: string): Promise<void>
|
||||
/**
|
||||
* Inspect the current foreground process group.
|
||||
* @returns its id and input-wait fact, or undefined when no foreground group can be resolved.
|
||||
@@ -251,12 +251,9 @@ export interface SubprocessTerminalHandle {
|
||||
* @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.
|
||||
* Idempotently terminate the complete terminal session and await whole-session quiescence.
|
||||
* After settlement, no write, inspection, or signal call remains in flight.
|
||||
*/
|
||||
waitForExit(signal?: AbortSignal): Promise<boolean>
|
||||
terminate(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ class StubSubprocessService extends SubprocessService {
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }),
|
||||
signalForeground: async () => 1,
|
||||
terminate: () => {},
|
||||
waitForExit: async () => true,
|
||||
terminate: async () => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
describe('SubprocessTerminalLifecycle', () => {
|
||||
it('waits for top-level settlement and the provider cleanup transaction', async () => {
|
||||
const done = Promise.withResolvers<undefined>()
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanup = vi.fn(() => cleanupGate.promise)
|
||||
const lifecycle = new SubprocessTerminalLifecycle({ done: done.promise, cleanup })
|
||||
|
||||
const waiting = lifecycle.waitForExit()
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
done.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
|
||||
|
||||
const observed = vi.fn()
|
||||
void waiting.then(observed)
|
||||
await Promise.resolve()
|
||||
expect(observed).not.toHaveBeenCalled()
|
||||
|
||||
cleanupGate.resolve(undefined)
|
||||
await expect(waiting).resolves.toBe(true)
|
||||
lifecycle.terminate()
|
||||
await expect(lifecycle.waitForExit()).resolves.toBe(true)
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('normalizes cleanup failures, permits retries, and retains lifetime cancellation until success', async () => {
|
||||
const done = Promise.withResolvers<undefined>()
|
||||
const lifetime = new AbortController()
|
||||
const removeListener = vi.spyOn(lifetime.signal, 'removeEventListener')
|
||||
const synchronousFailure = new Error('first cleanup failed')
|
||||
let attempt = 0
|
||||
const cleanup = vi.fn((): Promise<void> => {
|
||||
attempt += 1
|
||||
if (attempt === 1) throw synchronousFailure
|
||||
if (attempt === 2) {
|
||||
return Promise.resolve().then(() => {
|
||||
const nonErrorRejection: unknown = 'cleanup transport gone'
|
||||
throw nonErrorRejection
|
||||
})
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
const lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: done.promise,
|
||||
cleanup,
|
||||
signal: lifetime.signal,
|
||||
})
|
||||
|
||||
lifecycle.terminate()
|
||||
await expect(lifecycle.waitForExit()).rejects.toBe(synchronousFailure)
|
||||
lifecycle.terminate()
|
||||
await expect(lifecycle.waitForExit()).rejects.toThrow('cleanup transport gone')
|
||||
|
||||
lifetime.abort()
|
||||
await expect(lifecycle.waitForExit()).resolves.toBe(true)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function))
|
||||
|
||||
done.reject(new Error('top-level transport failed'))
|
||||
await Promise.resolve()
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('starts cleanup for a pre-aborted lifetime and bounds a wait that is already aborted', async () => {
|
||||
const cleanupGate = Promise.withResolvers<undefined>()
|
||||
const cleanup = vi.fn(() => cleanupGate.promise)
|
||||
const lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: new Promise(() => {}),
|
||||
cleanup,
|
||||
signal: AbortSignal.abort(new Error('lifetime cancelled')),
|
||||
})
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
await expect(lifecycle.waitForExit(AbortSignal.abort())).resolves.toBe(false)
|
||||
cleanupGate.resolve(undefined)
|
||||
await expect(lifecycle.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('contains cleanup settlement after an observer aborts between signal checks', async () => {
|
||||
const firstCleanup = Promise.withResolvers<undefined>()
|
||||
const cleanup = vi.fn()
|
||||
.mockImplementationOnce(() => firstCleanup.promise)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const lifecycle = new SubprocessTerminalLifecycle({ done: Promise.resolve(), cleanup })
|
||||
const observer = new AbortController().signal
|
||||
vi.spyOn(observer, 'aborted', 'get')
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true)
|
||||
|
||||
await expect(lifecycle.waitForExit(observer)).resolves.toBe(false)
|
||||
firstCleanup.reject(new Error('late cleanup failure'))
|
||||
await vi.waitFor(() => { expect(cleanup).toHaveBeenCalledOnce() })
|
||||
await Promise.resolve()
|
||||
|
||||
lifecycle.terminate()
|
||||
await expect(lifecycle.waitForExit()).resolves.toBe(true)
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports bounded cleanup success and failure', async () => {
|
||||
const successful = new SubprocessTerminalLifecycle({
|
||||
done: Promise.resolve(),
|
||||
cleanup: async () => {},
|
||||
})
|
||||
await expect(successful.waitForExit(new AbortController().signal)).resolves.toBe(true)
|
||||
|
||||
const failure = new Error('quiescence failed')
|
||||
const failed = new SubprocessTerminalLifecycle({
|
||||
done: Promise.resolve(),
|
||||
cleanup: () => Promise.reject(failure),
|
||||
})
|
||||
await expect(failed.waitForExit(new AbortController().signal)).rejects.toBe(failure)
|
||||
|
||||
const failedDone = Promise.withResolvers<undefined>()
|
||||
const afterTransportFailure = new SubprocessTerminalLifecycle({
|
||||
done: failedDone.promise,
|
||||
cleanup: async () => {},
|
||||
})
|
||||
const waiting = afterTransportFailure.waitForExit()
|
||||
failedDone.reject(new Error('transport failed'))
|
||||
await expect(waiting).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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/code-runtime/code-runtime-subprocess/README.md
|
||||
README.md: 38ee201a1754c6f50b7fae60b77af7dae734c8f9
|
||||
README.zh.md: 6fec39de550b7e3f57cd613dbe23c9687cd840ac
|
||||
README.md: eff5a6e648eb13c3411c19bca4d05f0898d3ad05
|
||||
README.zh.md: 16cf0e0ea6d487e255fc520a8fa1f41f97ee5d30
|
||||
|
||||
Reference in New Issue
Block a user