fix(pty,subprocess,lsp): address review round on cancellation, lookup, and contracts

- pty-local: a pre-write inspection rejection no longer releases a canceled
  send while its foreground SIGINT is in flight; the interrupt path retains
  the slot and its post-signal tail resumes polling. Regression pins the
  failure-shaped cancellation and a close-during-write release.
- pty-local: SEND_ACTIVE names which provider operation is draining; README
  states the never-settling-provider outcome (slot retained, close recovers).
- subprocess-local: resolveExecutable rejects relative paths containing
  separators instead of expanding them per PATH entry with a misleading
  not-found error; seam JSDoc pins the rule for every provider.
- subprocess-local: LocalTerminalHandle documents why the seam's
  in-flight-join promise holds without operation tracking.
- lsp-local: the oversized-source diagnostic reports the observed byte lower
  bound; README documents that processId: null trades away server-side
  orphan cleanup after a hard-killed harness.
- pty/subprocess seams: cross-reference the twin five-member signal unions.
- pty-local: TODO markers for send-state consolidation and initialize-race
  relocation.
This commit is contained in:
Tianyi Cui
2026-08-02 14:11:57 +08:00
parent a662af94df
commit e385c11e8e
21 changed files with 155 additions and 33 deletions

View File

@@ -988,7 +988,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>',
jsDoc: '/**\n * Resolve one configured executable in this provider\'s execution world.\n * Absolute paths are verified; bare names use the provider\'s scrubbed PATH\n * plus explicit environment overrides.\n * @param command - absolute executable path or bare PATH name.\n * @param env - explicit environment entries used for lookup.\n * @param signal - aborts remote or local lookup.\n * @returns a canonical executable path.\n */',
jsDoc: '/**\n * Resolve one configured executable in this provider\'s execution world.\n * Absolute paths are verified; bare names use the provider\'s scrubbed PATH\n * plus explicit environment overrides. Relative paths containing separators\n * are rejected: no current consumer defines which directory they would\n * resolve against, so providers fail loud instead of guessing.\n * @param command - absolute executable path or bare PATH name.\n * @param env - explicit environment entries used for lookup.\n * @param signal - aborts remote or local lookup.\n * @returns a canonical executable path.\n */',
},
{
signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp-local/README.md
README.md: ad8f4bc2318a58202f9596a18d402d2c6d45dae1
README.zh.md: ff2686856a9e518a7fa846edf700da1b32e8a114
README.md: 661c27d3326adfc3408b33550a63fe3ebe183a39
README.zh.md: 83f0eaa1fb4caccc381a1623b791355b2f5c5b65

View File

@@ -57,3 +57,4 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
- **No confinement policy** — this package trusts the configured server and does not sandbox its process; a restricted deployment must supply appropriate process/filesystem providers or a same-world sandbox wrapper.
- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal.
- **A hard-killed harness orphans language servers** — `initialize.processId: null` removes server-side client-PID monitoring, so servers are cleaned only by graceful service disposal; a SIGKILL'd harness leaves them running until they exit on their own.

View File

@@ -2,19 +2,19 @@
[English](README.md) | 中文
`ctx.lsp` 的**通用 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。它通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动,因此服务器与源文件始终位于挂载的同一执行环境。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,preset 应放在 `cordis.yml` overlay 中。
`ctx.lsp` 的**通用 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。它通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动,因此服务器与源文件始终位于挂载的执行世界中。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,预设应放在 `cordis.yml` overlay 中。
Namespace 插件(`name``inject``Config``apply`,无默认导出)。
## 功能
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开** 序列:通过 `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 结果对模型可见,因此查询不满足先读后写策略。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其 dispose资源释放完成,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本、所请求操作再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列串行执行每个源读取打开查询关闭生命周期因此排队调用只会在轮到自身时读取当前源不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。
- 协议 shutdown 失败后,经由进程 seam 终止服务器后代树POSIX 进程组信号Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程协议流;`initialize.processId``null`,因为另一台机器或 PID namespace 不得监 harness 进程。
- 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
@@ -24,13 +24,13 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env匹配 `KEY``PASSWORD``SECRET``TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |
| `maxMessageBytes` | `16000000` | 从服务器接受的单条 framed 消息最大大小。 |
| `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 |
| `maxDocumentBytes` | `4000000` | 该主机可打开的最大源文件。 |
| `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 |
| `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown``exit` 的预算。 |
| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 |
@@ -38,11 +38,11 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 协议行为
初始化会声明 `general.positionEncodings: ['utf-16']``workspace: { workspaceFolders: true, configuration: true }``textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink``targetUri` + `targetSelectionRange` 映射hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会作为结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
初始化会声明 `general.positionEncodings: ['utf-16']``workspace: { workspaceFolders: true, configuration: true }``textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink``targetUri` + `targetSelectionRange` 映射hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会结构化 `LSP_MALFORMED_RESPONSE` 错误的形式失败。
## 安全边界
提供方信任其配置的服务器,不声明任何沙箱限制。它把规范身份、containment、普通文件流式读取、UTF-8 校验与文件 URI 编码委托给 `ctx.fs`;服务器启动前,系统会拒绝缺失、非普通文件、非 UTF-8、过大或规范路径位于工作区外的查询源。系在打开流之前检查 containment但不保证路径并发替换期间稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须为同一执行环境挂载文件系统与进程提供方;分裂执行环境的组合无效。
提供方信任其配置的服务器,不提供任何沙箱隔离。它把规范身份、包含关系、普通文件流式读取、UTF-8 验证和文件 URI 编码委托给 `ctx.fs`并在服务器启动前拒绝缺失、非普通文件、非 UTF-8、过大或规范化后位于 Workspace 外部的查询源。包含关系在打开流之前评估,不承诺在并发路径替换期间保持稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须挂载描述同一执行世界的文件系统与进程管理提供方;分裂世界组合无效。
## 模型体验
@@ -54,6 +54,7 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 已知限制与暂缓事项
- **不提供隔离策略**这个package信任配置的服务器对其进程执行沙箱;受限部署必须提供适当的进程/文件系统提供方,或包装同一执行环境的沙箱。
- **不提供隔离策略**package信任配置的服务器,不对其进程实施沙箱;受限部署必须提供适当的进程/文件系统提供方,或使用同一执行世界的沙箱包装层
- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺。
- **逐服务器Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent 会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到释放。
- **逐服务器Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent(智能体)会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到 dispose。
- **被强制杀死的 harness 会遗留语言服务器**`initialize.processId: null` 取消了服务器侧的客户端 PID 监视,因此服务器只能由服务的优雅 dispose 清理;被 SIGKILL 的 harness 会让它们继续运行,直到自行退出。

View File

@@ -107,7 +107,11 @@ export async function readHostSource(
throwIfAborted(signal)
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
}
if (bytes > maxDocumentBytes) throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit`)
if (bytes > maxDocumentBytes) {
throw new Error(
`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`,
)
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),

View File

@@ -156,10 +156,10 @@ describe('readHostSource', () => {
await expect(readSource('.')).rejects.toThrow(/not a regular file/)
})
it('rejects an oversized source', async () => {
it('rejects an oversized source and reports the observed lower bound', async () => {
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
await expect(readSource('big.ts', 10)).rejects.toMatchObject({
message: 'source "big.ts" exceeds the 10-byte limit',
message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes',
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md
README.md: 92a3d7be68ada6f38ab3c4ca5bdd2622ebefc8ee
README.zh.md: 016043f7842df8bb963cdc5eea015666c9ce6941
README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75
README.zh.md: 353c2a4bdac7e8402fc63071dfb6fb85dcff66d5

View File

@@ -10,7 +10,7 @@ The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the
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. 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`.
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. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. 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

View File

@@ -10,7 +10,7 @@
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`
## 模型体验

View File

@@ -82,6 +82,9 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSp
}).argv
}
// TODO(pty-initialize-race-home): Fold this outer abort race into
// LocalPtySession.initialize when the send-state consolidation lands; the
// session already owns the send lifecycle the race protects.
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await session.initialize(signal)

View File

@@ -162,6 +162,11 @@ export class LocalPtySession implements PtyBackendSession {
private readonly outputEnded = Promise.withResolvers<void>()
private readonly completion: Promise<void>
private statusValue: PtySessionStatus = { kind: 'running' }
// TODO(pty-send-state-consolidation): Fold the per-send fields below
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
// cancellation/readiness interplay now has enough pinned tests to carry
// that refactor safely.
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeDeadlineTimer: NodeJS.Timeout | undefined
@@ -220,7 +225,14 @@ export class LocalPtySession implements PtyBackendSession {
startSend(request: PtySendRequest): PtySendOperation {
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) throw new PtyError('PTY session already has an active send or draining provider operation', 'SEND_ACTIVE')
if (this.active !== undefined) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
: this.interrupting !== undefined
? ' or draining foreground interrupt'
: ''
throw new PtyError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const operation = new LocalSendOperation(
@@ -246,8 +258,22 @@ export class LocalPtySession implements PtyBackendSession {
}
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
let foreground: SubprocessTerminalForeground | undefined
try {
foreground = await this.terminal.inspectForeground()
} catch (error: unknown) {
// A pre-write inspection failure while cancellation owns the slot must not
// release it: interruptOnce's in-flight foreground signal could land on a
// successor's foreground group. The interrupt path's post-signal tail
// resumes polling, whose guarded catch propagates a persistent failure.
// A retained settled operation implies that same in-flight interrupt, so
// this guard admits only an unsettled active send.
if (this.active === operation && !this.closing && this.interrupting !== operation) {
this.failActive(error)
}
return
}
try {
const foreground = await this.terminal.inspectForeground()
if (this.active !== operation || this.closing || this.interrupting === operation) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`

View File

@@ -345,6 +345,47 @@ describe('LocalPtySession readiness and output', () => {
await operation.done
})
it('retains a canceled send when the pre-write inspection rejects while its signal is in flight', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const failed = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await failed.promise
const uncanceled = session.startSend({ text: 'plain failure', submit: true })
failed.reject(new Error('inspect failed before write'))
await expect(uncanceled.done).rejects.toThrow('inspect failed before write')
terminal.inspectForeground = FakeTerminal.prototype.inspectForeground.bind(terminal)
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await inspection.promise
const signalGate = Promise.withResolvers<undefined>()
terminal.signalForeground = async (signal) => {
await signalGate.promise
inspector.signalGroup(456, signal)
return 456
}
const controller = new AbortController()
const operation = session.startSend({ text: 'must stay owned', submit: true, signal: controller.signal })
controller.abort()
inspection.reject(new Error('transient inspection failure'))
await Promise.resolve()
await Promise.resolve()
// The slot stays reserved while the cancellation's foreground signal is in flight.
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
terminal.inspectForeground = async () => ({ processGroupId: 456, inputWaiting: false })
signalGate.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('retains a canceled send until asynchronous foreground signalling settles', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
@@ -1176,6 +1217,24 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
})
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 write = Promise.withResolvers<undefined>()
terminal.write = async () => { await write.promise }
const writeOperation = session.startSend({ text: 'pending write', submit: true })
await Promise.resolve()
await Promise.resolve()
await session.close('pending write')
expect((await writeOperation.done).waitReason).toBe('session_exit')
// The rejection lands after close released the send; it must stay contained.
write.reject(new Error('write rejected during close'))
await Promise.resolve()
await Promise.resolve()
})
it('settles a closing send when provider termination cancels a pending inspection', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config())

View File

@@ -28,7 +28,11 @@ export class PtyBackendCleanupError extends AggregateError {
/** Why one interactive send returned control to its caller. */
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
/** Signals the model-facing PTY surface permits for foreground process groups. */
/**
* Signals the model-facing PTY surface permits for foreground process groups.
* Kept member-identical to `SubprocessTerminalSignal` in
* `@deepseek-ai/dsh-subprocess` without a cross-seam dependency; change both together.
*/
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** Top-level PTY process status, independent of a send's wait reason. */

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
README.md: 85103c2634bd35b188acd71c7f037e3678a2542e
README.zh.md: 505eac4f2650e6723e042cdb1122f9ef504ad7df
README.md: b901a69d6cfd45a084711ba0d32e555c481fd626
README.zh.md: 5723dc99ff955c5f4b07ea96e7d2134100d9fd6a

View File

@@ -10,7 +10,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README
- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory.
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*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.
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative PATH entries resolve from the host process cwd.
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. 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.

View File

@@ -10,7 +10,7 @@
- **按流划分的处置方式**`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符收集模式collect在输出超过上限后于内存中保留尾部错误与结果通常聚集在末尾沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留用于诊断的尾部。某条流大于 spill 上限时,会丢弃已不完整的 spill仅返回带截断标记的尾部spill 文件描述符在结算时封存最终关闭失败时则不公布路径以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需创建、权限为 `0700` 的每进程目录之下。
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*``*PASSWORD*``*SECRET*``*TOKEN*`)和所有环境中已有的 `DSH_*` 名称spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Noteagent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
- **基于偏移量的读取**收集模式的读取器按完整流的字节坐标返回增量服务自身从不持有游标因此消费方自有的游标bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
- **可执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;相对 PATH 条目从宿主进程 cwd 解析。
- **可执行文件查找**`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在接缝处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
- **终端进程所有权**`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
- **先终止再等待退出的 dispose资源释放**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。

View File

@@ -77,6 +77,11 @@ export class LocalSubprocessService extends SubprocessService {
signal?.throwIfAborted()
const environment = childEnv(env)
const absolute = isAbsolute(command)
if (!absolute && (command.includes('/') || (process.platform === 'win32' && command.includes('\\')))) {
throw new Error(
`subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
)
}
const candidates = absolute ? [command] : this.executableCandidates(command, environment)
for (const candidate of candidates) {
signal?.throwIfAborted()

View File

@@ -24,7 +24,14 @@ function signalName(number: number | undefined): NodeJS.Signals | null {
return null
}
/** A local terminal whose process-session ownership stays below the PTY backend. */
/**
* A local terminal whose process-session ownership stays below the PTY backend.
* The seam's terminate() promise — no write, inspection, or signal in flight
* after settlement — holds here without operation tracking only because every
* handle call completes synchronously under the hood (node-pty write, ps-based
* inspection). A first genuinely asynchronous step in any handle call must add
* the tracking a remote provider needs.
*/
export class LocalTerminalHandle implements SubprocessTerminalHandle {
readonly pid: number
readonly output = new PassThrough()

View File

@@ -32,6 +32,10 @@ describe('LocalSubprocessService', () => {
PATH: relative(process.cwd(), dirname(process.execPath)) || '.',
})).toBe(process.execPath)
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('must be non-empty')
await expect(ctx.subprocess.resolveExecutable('./bin/tsserver'))
.rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server'))
.rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('dsh-command-that-does-not-exist', { PATH: '' }))
.rejects.toThrow('was not found on PATH')
await expect(ctx.subprocess.resolveExecutable('/dsh-absolute-command-that-does-not-exist'))
@@ -61,6 +65,8 @@ describe('LocalSubprocessService', () => {
.toEqual(['/explicit/tool.EXE'])
expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
.rejects.toThrow('is a relative path')
} finally {
platform.mockRestore()
await fiber.dispose()

View File

@@ -107,7 +107,9 @@ export abstract class SubprocessService extends Service {
/**
* Resolve one configured executable in this provider's execution world.
* Absolute paths are verified; bare names use the provider's scrubbed PATH
* plus explicit environment overrides.
* plus explicit environment overrides. Relative paths containing separators
* are rejected: no current consumer defines which directory they would
* resolve against, so providers fail loud instead of guessing.
* @param command - absolute executable path or bare PATH name.
* @param env - explicit environment entries used for lookup.
* @param signal - aborts remote or local lookup.

View File

@@ -193,7 +193,11 @@ export interface SubprocessHandle {
waitForExit(signal?: AbortSignal): Promise<boolean>
}
/** Signals supported by the terminal-process primitive. */
/**
* Signals supported by the terminal-process primitive. Kept member-identical
* to `PtySignal` in `@deepseek-ai/dsh-pty` without a cross-seam dependency;
* change both together.
*/
export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** A fully specified terminal-process spawn. */