From 16cac3b5afb7e829a3f02d9445e5991a6c30a5d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:05:46 +0800 Subject: [PATCH] fix(runtime): close remote cleanup and readiness races --- ...rtable-execution-world-consumers.i18n.yaml | 4 +- ...7-28-portable-execution-world-consumers.md | 4 +- ...8-portable-execution-world-consumers.zh.md | 4 +- packages/pty/pty-local/README.i18n.yaml | 4 +- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/README.zh.md | 2 +- packages/pty/pty-local/src/session.ts | 48 ++++++-- packages/pty/pty-local/tests/session.spec.ts | 112 ++++++++++++++++++ packages/typert/README.i18n.yaml | 6 +- 9 files changed, 166 insertions(+), 20 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 8b4288edb4..0eb1611fe1 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: 000fa1b98964eb85550d0c607281937f8d7932c6 -2026-07-28-portable-execution-world-consumers.zh.md: 94e6bfcf94e450fafea2b33e152384da400e4b63 +2026-07-28-portable-execution-world-consumers.md: 5a15c2c0a4e552fadbdad9f7d09e00229afb033b +2026-07-28-portable-execution-world-consumers.zh.md: 5ae7f3b96f4df4c3756d294ba12147a3b1b54d34 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 000fa1b989..5a15c2c0a4 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. -- `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. +- `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 reaps its controller before group termination becomes the fallback. `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. 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 94e6bfcf94..5ae7f3b96f 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 机制,而不是复制这些机制。 +- `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,进程组终止才会成为后备手段。 `dsh-code-runtime-worker` 仍是独立实现。它是较小的进程内后端,可用于无法假定已安装 Node 可执行文件的单文件分发。远程文件系统/进程组合选择 `dsh-code-runtime-subprocess`;它们不需要提供方专用的代码运行时包。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index d8f5c2e2d0..828e767a60 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: 8a0a60139e27d98c4f506245a204723da997cd4a -README.zh.md: 3df845931dac33abecbcd5030a1b204c68acc638 +README.md: 235599737d21289d291ccab8f06fbcca91c83223 +README.zh.md: ca01c9a10430644b0d6ea6c5cfa70472f0447b92 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 8a0a60139e..235599737d 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -10,7 +10,7 @@ The plugin injects `pty`, `sandbox`, `sandboxPolicy`, and `subprocess`, then reg 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 printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; 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. -Send cancellation asks the terminal handle to signal the current foreground process group with a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. 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 asks the terminal handle to signal the current foreground process group with a real `SIGINT`; it 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. ## Model Experience diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 3df845931d..ca01c9a104 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -10,7 +10,7 @@ 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 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/session.ts b/packages/pty/pty-local/src/session.ts index a6abaf7785..5ac4dbca73 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -94,6 +94,10 @@ class LocalSendOperation implements PtySendOperation { return this.promise.promise } + get settled(): boolean { + return this.finished + } + append(text: string): void { if (!this.finished) this.output.append(text) } @@ -155,6 +159,8 @@ export class LocalPtySession implements PtyBackendSession { private activeTimer: NodeJS.Timeout | undefined private activeDeadlineTimer: NodeJS.Timeout | undefined private activeAbort: (() => void) | undefined + private writing: LocalSendOperation | undefined + private pollingReady: LocalSendOperation | undefined private polling = false private promptSeen = false private promptTextSeen = false @@ -224,7 +230,7 @@ export class LocalPtySession implements PtyBackendSession { this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort) } this.activeDeadlineTimer = setTimeout(() => { - if (this.active === operation) this.settleActive('timeout') + if (this.active === operation) this.settleActive('timeout', this.writing === operation) }, this.config.timeoutMs) void this.beginSend(operation, request) return operation @@ -236,12 +242,29 @@ export class LocalPtySession implements PtyBackendSession { if (this.active !== operation || this.closing) return operation.setInitialForeground(foreground) const input = `${request.text}${request.submit ? '\r' : ''}` - if (input.length > 0) await this.terminal.write(Buffer.from(input, 'utf8')) + if (input.length > 0) { + this.writing = operation + try { + await this.terminal.write(Buffer.from(input, 'utf8')) + } finally { + this.writing = undefined + } + } + if (this.active === operation && operation.settled) { + this.clearActive() + return + } // Closing can race the awaited provider write even though static analysis sees only local assignments. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.active === operation && !this.closing) this.schedulePoll(operation, 0) + if (this.active === operation && !this.closing) { + this.pollingReady = operation + this.schedulePoll(operation, 0) + } } catch (error: unknown) { - if (this.active === operation) this.failActive(error) + if (this.active === operation) { + if (operation.settled) this.clearActive() + else this.failActive(error) + } } } @@ -401,15 +424,24 @@ export class LocalPtySession implements PtyBackendSession { if (this.active === operation) this.failActive(error) } finally { this.polling = false - if (this.active === operation) this.schedulePoll(operation) + const active = this.active + // Awaited provider inspection can clear or replace the active send despite static analysis. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (active !== undefined && this.pollingReady === active) this.schedulePoll(active) } } - private settleActive(waitReason: PtyWaitReason): void { + private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void { const operation = this.active if (operation === undefined) return const scrollbackTruncated = this.scrollback.snapshot().truncated - this.clearActive() + if (retainOwnership) { + this.stopPolling() + this.activeAbort?.() + this.activeAbort = undefined + } else { + this.clearActive() + } operation.settle(waitReason, this.statusValue, scrollbackTruncated) } @@ -424,6 +456,8 @@ export class LocalPtySession implements PtyBackendSession { this.stopPolling() this.activeAbort?.() this.activeAbort = undefined + this.writing = undefined + this.pollingReady = undefined this.active = undefined } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 7a775eb9a0..7e6b9407a0 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -268,6 +268,40 @@ describe('LocalPtySession readiness and output', () => { failedInternal.fail(new Error('ignored')) }) + it('retains send ownership after timeout until an asynchronous provider write settles', 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 } + const operation = session.startSend({ text: 'slow write', submit: true }) + await Promise.resolve() + await Promise.resolve() + 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 Promise.resolve() + await Promise.resolve() + const rejectedWrite = Promise.withResolvers() + terminal.write = async () => { await rejectedWrite.promise } + const rejected = session.startSend({ text: 'late rejection', submit: true }) + await vi.advanceTimersByTimeAsync(100) + expect((await rejected.done).waitReason).toBe('timeout') + rejectedWrite.reject(new Error('write failed after timeout')) + 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() @@ -509,6 +543,84 @@ describe('LocalPtySession readiness and output', () => { }).settle('timeout', { kind: 'running' }, false) }) + it('reschedules readiness for a new send after a stale remote inspection releases the poll slot', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + + const old = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let block = true + terminal.inspectForeground = async () => block + ? await inspection.promise + : { processGroupId: 456, inputWaiting: false } + const internals = session as unknown as { + pollReadiness(operation: PtySendOperation): Promise + settleActive(reason: 'timeout'): void + } + const stalePoll = internals.pollReadiness(old) + internals.settleActive('timeout') + await old.done + + block = false + const current = session.startSend({ text: '', submit: false }) + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await Promise.resolve() + await Promise.resolve() + inspection.resolve({ processGroupId: 456, inputWaiting: false }) + await stalePoll + await vi.advanceTimersByTimeAsync(10) + + expect((await current.done).waitReason).toBe('stdin_read') + }) + + it('does not poll a successor before its own pre-write inspection and write complete', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + + const old = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const staleInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + const successorInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let inspectCalls = 0 + terminal.inspectForeground = async () => { + inspectCalls += 1 + return inspectCalls === 1 ? await staleInspection.promise : await successorInspection.promise + } + const internals = session as unknown as { + pollReadiness(operation: PtySendOperation): Promise + settleActive(reason: 'timeout'): void + } + const stalePoll = internals.pollReadiness(old) + internals.settleActive('timeout') + await old.done + + const current = session.startSend({ text: 'successor', submit: true }) + await Promise.resolve() + await Promise.resolve() + staleInspection.resolve({ processGroupId: 456, inputWaiting: false }) + await stalePoll + await vi.advanceTimersByTimeAsync(10) + expect(inspectCalls).toBe(2) + expect(terminal.writes).toEqual([]) + + successorInspection.resolve({ processGroupId: 456, inputWaiting: false }) + await Promise.resolve() + await Promise.resolve() + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect(terminal.writes).toEqual(['successor\r']) + expect((await current.done).waitReason).toBe('stdin_read') + }) + it('contains stale timer, write, inspection, and interrupt continuations', async () => { vi.useFakeTimers() const settle = (operation: PtySendOperation): void => { diff --git a/packages/typert/README.i18n.yaml b/packages/typert/README.i18n.yaml index cf44d9aace..d075078b94 100644 --- a/packages/typert/README.i18n.yaml +++ b/packages/typert/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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/typert/README.md -README.md: ad9f843e48be0e3be85921ed8fd3ca4e2c327160 -README.zh.md: 0d4be016b766178b54f7e269583fa4200e3eb24b +# pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-subprocess/README.md +README.md: 9e0ea9065ea4e42696f6afcd243c385e514f7c2d +README.zh.md: 6946248ec58054fcdd93a4c94affb55f90041301