From 32bce339da84917b696f1387d8e7e8417c73c27d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:40:02 +0800 Subject: [PATCH] fix(runtime): close lifecycle teardown races --- ...rtable-execution-world-consumers.i18n.yaml | 4 +- ...7-28-portable-execution-world-consumers.md | 6 +-- ...8-portable-execution-world-consumers.zh.md | 6 +-- packages/pty/pty-local/README.i18n.yaml | 4 +- packages/pty/pty-local/README.md | 4 +- packages/pty/pty-local/README.zh.md | 4 +- packages/pty/pty-local/src/index.ts | 33 ++++++++++---- packages/pty/pty-local/src/session.ts | 12 +++-- packages/pty/pty-local/tests/index.spec.ts | 45 +++++++++++++++++++ packages/pty/pty-local/tests/local.spec.ts | 43 +++++++++++++++++- packages/pty/pty-local/tests/session.spec.ts | 27 +++++++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- .../subprocess-local/src/process-inspector.ts | 14 ++++++ .../subprocess-local/src/terminal.ts | 9 +++- .../subprocess-local/tests/local.spec.ts | 2 + .../tests/process-inspector.spec.ts | 8 ++++ .../subprocess-local/tests/terminal.spec.ts | 32 +++++++++++++ packages/typert/README.i18n.yaml | 4 +- 20 files changed, 233 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml index f72f7c1d89..f97b09b940 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.i18n.yaml @@ -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: 145b23317552a51f608404a3308011fb8540319f -2026-07-28-portable-execution-world-consumers.zh.md: 50e27ccce19516a02e4e5024efb8303231a2236e +2026-07-28-portable-execution-world-consumers.md: 3d1928be882fb3fb621e733649146445c69e8a5b +2026-07-28-portable-execution-world-consumers.zh.md: f385e502d5d737fe0d5ae6e28ac7a3a097d85a6b diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md index 145b233175..3d1928be88 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md @@ -22,8 +22,8 @@ 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 sends provider-owned file URIs. Its JSON-RPC, pooling, synchronization, cancellation, 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. A timed-out asynchronous write retains the send reservation until the provider settles it, and completion of a stale inspection resumes polling for the current send. -- `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. The heap-bounded worker rejects oversized binding frames before transfer, each outer hop enforces the same bound before serialization, and 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. The allocation signal is detached before publication, while readiness initialization retains setup cancellation. A timed-out asynchronous write, or a cancellation whose foreground signal fails during that write, retains the send reservation until the provider settles it; completion of a stale inspection resumes polling for the current send. +- `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 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. @@ -45,4 +45,4 @@ A remote execution provider implements only its shared sandbox owner plus filesy The fundamental interfaces are wider, and a filesystem/subprocess pair must agree on one execution world. The added operations are limited to facts and lifecycle mechanics that current generic consumers require; model schemas, protocol framing, readiness policy, and presentation do not leak into the providers. -The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal still waits for exact PID-identity-fenced descendants and the top-level terminal process to reach quiescence. +The local implementation absorbs `node-pty` and platform process inspection because it owns local terminal mechanics. This moves code without weakening terminal teardown: disposal waits for exact PID-identity-fenced descendants retained during foreground inspection, Linux session members that survive top-level exit, and the top-level terminal process to reach quiescence. macOS cannot enumerate a POSIX session after its leader exits, so a child that reparents between inspection snapshots remains an explicit local-provider limitation rather than a reason to move process mechanics back into the PTY consumer. diff --git a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md index 50e27ccce1..f385e502d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.zh.md @@ -22,8 +22,8 @@ Status: implemented - `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`;其他进程管理提供方则提供相同原语。异步写入超时后仍保留发送预留,直至提供方将写入结算;陈旧检查完成后,会针对当前发送恢复轮询。 -- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在序列化前执行相同的上限检查;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。 +- `dsh-pty-local` 把持久 shell 语义映射到 `ctx.subprocess.spawnTerminal()`。本地 `node-pty` 与进程检查实现移入 `dsh-subprocess-local`;其他进程管理提供方则提供相同原语。分配信号会在发布前解除关联,而就绪初始化仍保留设置阶段的取消。异步写入超时,或在该写入期间取消时前台信号发送失败,都会保留发送预留,直至提供方将写入结算;陈旧检查完成后,会针对当前发送恢复轮询。 +- `dsh-code-runtime-subprocess` 通过 `ctx.fs` 物化无依赖 runner,并通过 `ctx.subprocess` 启动它,从而在本地或远程执行世界中保留代码运行时的绑定与输出契约。它通过非插件子路径 `dsh-code-runtime-worker/runtime-host` 共享宿主侧 worker 机制,而不是复制这些机制。准备阶段让同一个生命周期信号贯穿文件系统解析、物化和可执行文件查找,使资源释放能够中止停滞的提供方操作。受堆上限约束的 worker 会在传输前拒绝过大的绑定帧;每个外层转发环节都会在序列化前执行相同的上限检查;launcher 会在回收 controller 前发布已接纳的终态帧,使继承 controller 管道的后代进程无法阻止完成;宿主仍会等待进程组完全停稳。 `dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。 @@ -45,4 +45,4 @@ Status: implemented 基础接口更宽,一对文件系统/进程管理提供方必须在同一个执行世界上保持一致。新增操作仅限当前通用消费方所需的事实与生命周期机制;模型 schema、协议分帧、就绪策略和呈现不会渗入提供方。 -本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)仍会等待受精确 PID 身份校验保护的后代进程和顶层终端进程完全停稳。 +本地实现承接 `node-pty` 和平台进程检查,因为它负责本地终端机制。这种代码迁移不会削弱终端拆卸:dispose(资源释放)会等待前台检查期间保留下来且受精确 PID 身份围栏保护的后代进程、在顶层进程退出后仍存活的 Linux 会话成员,以及顶层终端进程完全停稳。macOS 无法在 POSIX 会话 leader 退出后枚举该会话,因此在两次检查快照之间重新设定父进程的子进程仍是明确的本地提供方限制,而不是把进程机制移回 PTY 消费方的理由。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index e29a581f57..05e9b904f9 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.i18n.yaml @@ -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: 2c4ee44e8489cc6b53179769301e125dcca0ac8e -README.zh.md: 947bab00b9d6aa5fb7b321fbee84f4b2e420133c +README.md: 7d0cd2c26c6c58a64ae9d5fe5598c8aa6ff62915 +README.zh.md: 0b8216a9f599c224bd2888eb428213c9d725a338 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 2c4ee44e84..7d0cd2c26c 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -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. 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. 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 a delayed earlier prompt therefore cannot settle the current send. 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. -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. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. A send that times out during an asynchronous provider write reports the timeout but retains the session's send slot until that write settles, so late bytes cannot interleave with a successor. Close 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. 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 session's send slot until the write settles, so late bytes cannot interleave with a successor. Close 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. ## Model Experience diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 947bab00b9..0b8216a9f5 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -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 完成。如果 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` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 -取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。在提供方异步写入期间超时的发送会报告超时,但会继续占用该会话的发送槽位,直至写入结算,从而避免延迟到达的字节与后续发送交错。关闭操作启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。 +取消发送会先把排队输入标记为已取消,再请求终端句柄向当前前台进程组发送真正的 `SIGINT`;如果异步的写入前检查随后才结算,也无法执行该输入。取消绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。发送在提供方异步写入期间超时,或在该写入仍未完成时其取消信号发送失败,都会报告各自结果,但继续占用会话的发送槽位,直至写入结算,从而避免延迟到达的字节与后续发送交错。关闭操作启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。 ## 模型体验 diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index a8d99cb243..62b5fdb71a 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -101,15 +101,30 @@ 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') - 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, - }) + 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 session = this.createSession(terminal, this.config) try { await session.initialize(spec.signal) diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index f984648f27..9a0beed6ff 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -473,17 +473,23 @@ export class LocalPtySession implements PtyBackendSession { this.active = undefined } - private failActive(error: unknown): void { + private failActive(error: unknown, retainOwnership = false): void { const operation = this.active if (operation === undefined) return - this.clearActive() + if (retainOwnership) { + this.stopPolling() + this.activeAbort?.() + this.activeAbort = undefined + } else { + this.clearActive() + } operation.fail(error) } private interrupt(operation: LocalSendOperation): void { if (this.active !== operation) return void this.terminal.signalForeground('SIGINT').catch((error: unknown) => { - if (this.active === operation) this.failActive(error) + if (this.active === operation) this.failActive(error, this.writing === operation) }) } diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index a8753deaf3..23e6f4bc89 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -187,6 +187,51 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) }) + it('forwards setup cancellation only while terminal allocation is unpublished', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) + + const publishedController = new AbortController() + let publishedSignal: AbortSignal | undefined + const published = new LocalPtyBackend( + ctx, + config(), + async (spawnSpec) => { + publishedSignal = spawnSpec.signal + return terminalHandle() + }, + () => stubLocalSession(), + ) + await published.spawn(spec(agent(ctx), publishedController.signal)) + expect(publishedSignal).toBeDefined() + expect(publishedSignal).not.toBe(publishedController.signal) + publishedController.abort(new Error('originating turn ended')) + expect(publishedSignal?.aborted).toBe(false) + + const pendingController = new AbortController() + const seen = Promise.withResolvers() + const pending = new LocalPtyBackend( + ctx, + config(), + async spawnSpec => await new Promise((_resolve, reject) => { + const setupSignal = spawnSpec.signal as AbortSignal + seen.resolve(setupSignal) + const onAbort = (): void => { + reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason))) + } + setupSignal.addEventListener('abort', onAbort, { once: true }) + }), + () => stubLocalSession(), + ) + const spawning = pending.spawn(spec(agent(ctx), pendingController.signal)) + const pendingSignal = await seen.promise + const reason = new Error('cancel pending allocation') + pendingController.abort(reason) + await expect(spawning).rejects.toBe(reason) + expect(pendingSignal.aborted).toBe(true) + }) + it('composes the default local session around a spawned terminal', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c02a9563ea..df05438864 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -156,6 +156,47 @@ describe('pty-local real shell', () => { expect(() => process.kill(pid, 0)).toThrow() }, 10_000) + it('reaps a disowned same-session descendant after the shell exits naturally', async () => { + const { ctx, root, agent } = await harness('danger-full-access') + const created = await ctx.pty.spawn(agent, { type: 'shell' }) + const pidFile = join(root, 'disowned.pid') + let pid: number | undefined + try { + const background = ctx.pty.startSend(agent, created.sessionId, { + text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`, + submit: true, + }) + await background.done + const pidDeadline = Date.now() + 2_000 + let childPid = 0 + while (childPid === 0 && Date.now() < pidDeadline) { + if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8')) + if (childPid > 0) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(existsSync(pidFile), ctx.pty.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true) + expect(childPid).toBeGreaterThan(0) + pid = childPid + expect(() => process.kill(childPid, 0)).not.toThrow() + await ctx.pty.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done + const deadline = Date.now() + 2_000 + while (ctx.pty.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(ctx.pty.list(agent)[0]?.status.kind).toBe('exited') + await ctx.pty.kill(agent, created.sessionId) + expect(() => process.kill(childPid, 0)).toThrow() + } finally { + if (pid !== undefined) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyReaped) { + // Product cleanup is the expected path; this only contains a failed regression. + } + } + } + }, 10_000) + it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => { const { ctx, agent } = await harness('danger-full-access', { idleSilenceMs: 10_000, diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 6346a6bba2..120b2881e1 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -27,6 +27,7 @@ class FakeInspector implements ProcessInspector { foregroundPgid() { return this.pgid } isStdinWaiting() { return this.waiting } processTree() { return this.members } + processSession() { return [] } isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } signalGroup(pgid: number, signal: PtySignal) { if (this.throwGroup) throw new Error('group failed') @@ -325,6 +326,32 @@ describe('LocalPtySession readiness and output', () => { expect((await next.done).waitReason).toBe('inferred_idle') }) + it('retains send ownership when cancellation signalling fails during an asynchronous write', 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() + terminal.write = async () => { await writeGate.promise } + terminal.signalForeground = async () => { 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 + 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') + }) + it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => { vi.useFakeTimers() const startupTerminal = new FakeTerminal() diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index d931d3574b..72f3c93da9 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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: 6bc1003ae5903bb5640728bc79c3f9042fddbe7a -README.zh.md: 3c9ce73c7fcbeec9b17d73f212ecdcb6842ec133 +README.md: 38b26c33f3854acdb4a2bb48612da73361d727ba +README.zh.md: fc4a5d4961fb7618b9db84b21063f907361a26d8 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 6bc1003ae5..38b26c33f3 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -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, and `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. Linux `/proc`/syscall and macOS `ps` inspectors retain exact pid/start identity so pid reuse cannot redirect cleanup; the higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal bytes, inspects and signals the current foreground process group, and cleans descendants before the top-level shell. 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 @@ -26,7 +26,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix. - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. -- **A daemonized terminal descendant can escape the captured tree** — a child that reparents before teardown is no longer discoverable from the `node-pty` root. The local provider accepts this gap rather than signal the root PID's POSIX session, which can include unrelated launcher processes. +- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 3c9ce73c7f..fc4a5d4961 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -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 的 `/proc`/syscall 检查器与 macOS 的 `ps` 检查器会保留精确的 pid/启动身份,使 PID 复用无法把清理重定向到其他进程;上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端字节,检查当前前台进程组并向其发送信号,并先于顶层 shell 清理后代。每次前台检查都会保留有根进程树中的精确身份;Linux 还会在会话 leader 退出后枚举该 POSIX 会话。因此,先前观察到的 macOS 后代以及任何同会话 Linux 成员在重新设定父进程后仍受身份围栏保护,而 pid/启动身份可防止清理因 PID 复用而跟随到其他进程。上层 PTY 后端负责提示符就绪检测、缓冲和面向模型的操作。 - **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -26,7 +26,7 @@ - **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 使用 `ps` 快照。 -- **守护化的终端后代可能逃离已捕获进程树**:子进程若在拆卸前重新设定父进程,便无法再从 `node-pty` 根发现。本地提供方接受这个缺口,不向根 PID 的 POSIX 会话发送信号,因为其中可能包含无关的启动器进程。 +- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程若在任何前台检查快照产生前重新设定父进程,便无法再从 `node-pty` 根发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与该提供方拥有的终端会话。本地提供方不会增加持续运行的进程表监视器。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f57184f289..48b01e18e3 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -16,6 +16,8 @@ export interface ProcessInspector { isStdinWaiting(pgid: number): boolean /** Return the root and its current transitive descendants, children first. */ processTree(rootPid: number): ProcessIdentity[] + /** Return current members of one POSIX process session when the platform exposes them. */ + processSession(sessionId: number): ProcessIdentity[] /** Return whether the exact identity remains a non-quiescent process. */ isAlive(identity: ProcessIdentity): boolean signalGroup(pgid: number, signal: SubprocessTerminalSignal): void @@ -201,6 +203,7 @@ abstract class PosixProcessInspector implements ProcessInspector { abstract foregroundPgid(shellPid: number): number | undefined abstract isStdinWaiting(pgid: number): boolean abstract processTree(rootPid: number): ProcessIdentity[] + abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { @@ -272,6 +275,13 @@ class LinuxProcessInspector extends PosixProcessInspector { return processTree(entries, rootPid) } + processSession(sessionId: number): ProcessIdentity[] { + return numericEntries(this.internals, '/proc').flatMap((pid) => { + const stat = readLinuxStat(this.internals, pid) + return stat?.session === sessionId ? [{ pid, started: stat.started }] : [] + }) + } + isAlive(identity: ProcessIdentity): boolean { const stat = readLinuxStat(this.internals, identity.pid) return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state) @@ -307,6 +317,10 @@ class MacProcessInspector extends PosixProcessInspector { return processTree(macProcessTable(this.internals), rootPid) } + processSession(_sessionId: number): ProcessIdentity[] { + return [] + } + isAlive(identity: ProcessIdentity): boolean { return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index d4b7bd26d1..8446f11b8f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -36,6 +36,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private exited = false private termination: Promise | undefined private removeAbort: (() => void) | undefined + private trackedDescendants: ProcessIdentity[] = [] /** * @param terminal - allocated node-pty process. @@ -86,6 +87,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // eslint-disable-next-line @typescript-eslint/require-await async inspectForeground(): Promise { + this.descendants() const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { @@ -145,7 +147,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private descendants(): ProcessIdentity[] { - return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid) + this.trackedDescendants = this.survivors(this.unionMembers( + this.trackedDescendants, + this.inspector.processTree(this.pid), + this.inspector.processSession(this.pid), + ).filter(member => member.pid !== this.pid)) + return this.trackedDescendants } private async waitForMembers(members: ProcessIdentity[]): Promise { diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 7ff10a7348..93be25acd0 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -111,6 +111,7 @@ describe('LocalSubprocessService', () => { foregroundPgid: () => undefined, isStdinWaiting: () => false, processTree: () => [], + processSession: () => [], isAlive: () => false, signalGroup: () => {}, signalProcess: () => {}, @@ -175,6 +176,7 @@ describe('LocalSubprocessService', () => { foregroundPgid: () => 123, isStdinWaiting: () => false, processTree: () => [{ pid: 124, started: 'child' }], + processSession: () => [], isAlive: identity => alive.has(identity.pid), signalGroup: () => {}, signalProcess: () => {}, diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index e8ad2fd11e..bcc3ddb091 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -86,6 +86,13 @@ describe('Linux process inspector', () => { { pid: 10, started: '500' }, ]) expect(inspector.processTree(99)).toEqual([]) + expect(inspector.processSession(30)).toEqual([ + { pid: 10, started: '500' }, + { pid: 11, started: '501' }, + { pid: 12, started: '502' }, + { pid: 13, started: '503' }, + ]) + expect(inspector.processSession(99)).toEqual([]) expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true) expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false) inspector.signalGroup(40, 'SIGINT') @@ -197,6 +204,7 @@ describe('macOS process inspector', () => { { pid: 10, started: 'Mon Jul 21 10:00:00 2026' }, ]) expect(inspector.processTree(99)).toEqual([]) + expect(inspector.processSession(10)).toEqual([]) expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true) inspector.signalGroup(55, 'SIGTSTP') inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL') diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 417fd62517..0202a3f33e 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -51,6 +51,7 @@ class FakeInspector implements ProcessInspector { pgid: number | undefined = 456 waiting = false members: ProcessIdentity[] = [] + sessionMembers: ProcessIdentity[] = [] readonly alive = new Set() readonly groups: Array<[number, SubprocessTerminalSignal]> = [] readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = [] @@ -61,6 +62,7 @@ class FakeInspector implements ProcessInspector { foregroundPgid() { return this.pgid } isStdinWaiting() { return this.waiting } processTree() { return this.members } + processSession() { return this.sessionMembers } isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } signalGroup(pgid: number, signal: SubprocessTerminalSignal) { if (this.throwGroup) throw new Error('group failed') @@ -158,6 +160,36 @@ describe('LocalTerminalHandle', () => { expect(await waiting).toBe(true) }) + it('cleans a same-session descendant after the top-level shell exits naturally', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const disowned = { pid: 124, started: 'disowned' } + inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] + inspector.alive.add(124) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + + pty.emitExit() + + expect(await handle.waitForExit()).toBe(true) + expect(inspector.processes).toEqual([[124, 'SIGTERM']]) + }) + + it('retains an inspected descendant after it reparents away from the shell', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const descendant = { pid: 124, started: 'observed' } + inspector.members = [descendant] + inspector.alive.add(descendant.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + + await handle.inspectForeground() + inspector.members = [] + pty.emitExit() + + expect(await handle.waitForExit()).toBe(true) + expect(inspector.processes).toEqual([[124, 'SIGTERM']]) + }) + it('rescans for descendants forked during TERM', async () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index 384c9cdb94..13b8df9f71 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.i18n.yaml @@ -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: db4478fe799dce1bbdb7e3ae3d31947515aae151 -README.zh.md: 238f5f66db58edb99945cfa43299635b4f8f36a8 +README.md: fd8cfccdb333018e551459195564daad157d3867 +README.zh.md: b5b495e22bbf4328d82925cc793588ee45e5c6e4