Skip to content

fix(core): run web terminal PTYs on the bundled ConPTY backend - #11643

Merged
wenshao merged 11 commits into
mainfrom
fix/web-terminal-bundled-conpty
Sep 13, 2026
Merged

wenshao merged 11 commits into
mainfrom
fix/web-terminal-bundled-conpty

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Windows web-terminal and agent-view PTYs use bundled ConPTY to address the inbox backend's natural-exit host leak. Web terminals retry once on the inbox backend if bundled spawn fails, and release the remaining worker resources after exit while retaining scrollback.

The review follow-up keeps terminal output intact. The Windows server answers only primary device-attribute startup probes; the browser owns colors, modes and geometry. Reconnect history is restored without forwarding its automatic replies, while the old visible terminal keeps accepting keyboard input. First-connection output and subsequent live output can still receive replies. This removes the hand-written query filters instead of adding more escape-sequence cases.

Why it's needed

#11352 tracks Windows terminal processes left behind after exit. Selecting bundled ConPTY changes who answers terminal probes, so the backend switch also needs to preserve live answers without injecting historical responses into the shell on reconnect. The original filtering approach lost some color answers and replayed others. #11734 tracks that follow-up; the cross-package replay contract was explicitly approved for this PR.

Reviewer Test Plan

How to verify

  1. On Windows, repeatedly open a web terminal, run a command and exit. Check that conhost/OpenConsole process counts return to baseline. Also check PowerShell startup, cancellation, resize and CJK input. Native Windows acceptance is still required; mocked spawn assertions do not prove process cleanup.
  2. In an active Web Shell terminal, issue background, cursor-color, palette, mode and DCS queries. Live queries should receive the browser's answers. Primary DA should have only one answerer, including when the server responder is unavailable.
  3. Reconnect with those queries in scrollback. Historical queries must not write new replies into the shell. Type during restoration and queue fresh output: the input must arrive once, followed by the live responses after restoration. Interrupt restoration and confirm a stale callback cannot replace the current view.
  4. Update the daemon and Web Shell together. An older client or unmarked old-server snapshot must fail with a reload/restart message, not silently replay replies. Closing a detached terminal must still release it.

Evidence (Before & After)

Before this correction, real package probes at 77257a5 showed OSC 11 losing its only answer and OSC 12/multi-index OSC 4 answering again on replay. The intervening d8975fa restored live color queries; this change preserves that correction and removes replayed replies as well.

After: the actual modified React terminal component, real xterm and FitAddon were mounted in Chromium with controlled surrounding contexts and WebSocket transport. Fresh output produced six color/mode/DCS replies, plus one DA only when browser-owned. On reconnect, 14 native historical reply events produced zero outgoing replies; a real keyboard k arrived once during delayed restoration and the queued live batch produced seven replies once. Canceled restoration and legacy-protocol rejection passed, with no browser errors. This is component-boundary evidence, not a real daemon/PTY or Windows process-count test.

Tested on

OS Status
macOS Focused unit tests and mounted Chromium component checks passed
Windows Not tested on a native host
Linux Not tested locally in this round

Environment (optional)

Node 22.22.0, React 19.2.4, browser xterm 6.0.0, FitAddon 0.11.0, headless xterm 5.5.0 and Chromium 149.0.7827.55. Focused registry, WebSocket-route and terminal-component tests passed, as did ESLint on the six edited TypeScript files. Package typechecks encountered stale project-reference build declarations, including APIs already present in source; they are not reported as passing. No dependency installation or full rebuild was run in this round.

Risk & Scope

  • The Windows backend change covers web terminals and agent view; this round does not expand agent-view behavior. The replay contract applies to Web Shell terminals on every host, not only VS Code. POSIX backend selection remains unchanged.
  • The inbox fallback does not fix the original native leak. A missing headless responder also leaves startup probes dependent on browser attachment. Native Windows cleanup, DLL-failure fallback and full daemon/PTY acceptance remain unverified.
  • Migration: daemon and Web Shell must update together for the replay-marked protocol. Workspace ownership checks, bounded binary output and input/output backpressure are retained. No dependencies are added.
  • Design: English / 简体中文.

Linked Issues

Refs #11352 · Refs #11497 · Refs #11734 · microsoft/node-pty#965. No issue is automatically closed by this description.

中文说明

这个 PR 做了什么

Windows web-terminal 和 agent-view PTY 改用 bundled ConPTY,处理 inbox 后端在自然退出时遗留 host 的问题。web terminal 在 bundled spawn 失败时回退 inbox 重试一次,并在退出后释放剩余 worker 资源,同时保留 scrollback。

本次评审修正保留完整终端输出。Windows 服务端只应答启动时的 primary DA 探测,颜色、模式和几何查询交给浏览器。重连历史恢复时不转发自动应答,旧的可见终端仍接受键盘输入;首次连接输出和随后的实时输出仍能得到应答。删除手写查询过滤器,不再继续枚举转义序列。

为什么需要

#11352 跟踪 Windows 终端退出后残留进程的问题。切换 bundled ConPTY 会改变终端探测的应答方,因此还必须保留实时应答,并防止重连把历史应答重新注入 shell。原有过滤方案会丢失一部分颜色应答,也会重放另一部分。#11734 跟踪该后续问题;这次跨包回放协议已获明确批准,放在本 PR 中修正。

评审测试计划

如何验证

  1. Windows 上反复打开 web terminal、执行命令并退出,检查 conhost/OpenConsole 进程数量是否回到基线。同时检查 PowerShell 启动、取消、resize 和中文输入。仍需 Windows 真机验收;mock spawn 断言不能证明进程已清理。
  2. 在活动的 Web Shell 终端中发出背景色、光标色、调色板、模式和 DCS 查询。实时查询应收到浏览器的答案。primary DA 只能有一个应答方,也需覆盖服务端 responder 不可用的情况。
  3. 携带这些查询的 scrollback 重连。历史查询不能向 shell 写入新的应答。恢复时输入字符并排入新输出:输入应到达一次,恢复后再收到实时应答。中断恢复,确认旧回调不能替换当前视图。
  4. daemon 和 Web Shell 同步升级。旧客户端或旧服务端未标记的快照应明确提示刷新/重启,不能悄悄重放应答。关闭已脱离页面的终端仍应释放会话。

证据(前后对比)

修正前,77257a5 上的真实终端包探测显示 OSC 11 丢失唯一应答,OSC 12 和多下标 OSC 4 在回放时再次应答。期间提交的 d8975fa 已恢复实时颜色查询;本次保留该修复,并消除历史回放应答。

修正后,在 Chromium 中挂载实际修改的 React 终端组件、真实 xterm 和 FitAddon,仅控制外围 context 和 WebSocket 传输。首次输出产生六次颜色/模式/DCS 应答,只有浏览器负责 DA 时才额外产生一次 DA。重连时观察到的 14 次原生历史应答没有任何一次向外转发;延迟恢复期间真实键盘 k 到达一次,排队的实时查询产生七次应答且各一次。中断恢复、旧协议拒绝均通过,浏览器没有错误。这是组件边界验证,不是真实 daemon/PTY 或 Windows 进程计数测试。

测试平台

OS 状态
macOS 定向单测和真实 Chromium 组件检查通过
Windows 未在原生主机测试
Linux 本轮未在本地测试

环境(可选)

Node 22.22.0、React 19.2.4、浏览器 xterm 6.0.0、FitAddon 0.11.0、headless xterm 5.5.0、Chromium 149.0.7827.55。registry、WebSocket 路由和终端组件的定向测试通过,六个改动 TypeScript 文件的 ESLint 通过。包级类型检查遇到过期的 project-reference 构建声明,其中包含源码中已有的 API;不将类型检查报告为通过。本轮没有安装依赖或运行完整构建。

风险与范围

  • Windows 后端变更覆盖 web terminal 和 agent view;本轮不扩展 agent-view 行为。回放协议适用于所有平台的 Web Shell 终端,并非仅限 VS Code。POSIX 后端选择不变。
  • inbox 回退不解决原始原生泄漏。缺少 headless responder 时,启动探测也依赖浏览器连接。Windows 原生清理、DLL 失败回退以及完整 daemon/PTY 验收仍未验证。
  • 迁移:daemon 和 Web Shell 必须同步升级以支持带回放标记的协议。保留工作区归属校验、有界二进制输出以及输入输出背压。不增加依赖。
  • 设计:English / 简体中文

关联 Issue

Refs #11352 · Refs #11497 · Refs #11734 · microsoft/node-pty#965。本描述不自动关闭任何 issue。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

The web terminal spawned its PTYs with node-pty's default inbox ConPTY
backend, where a natural shell exit orphans the conhost.exe --headless
the backend spawned: the native exit watcher erases the pty baton before
delivering onExit, pty_baton has no destructor, and the erased HPCON is
unreachable from JS (microsoft/node-pty#965). Every exited web terminal
leaked ~8 MB for the life of the CLI, and a pin bump does not fix it.

Mirror the #11497 shell path: spawn with useConptyDll on Windows so the
bundled backend releases its host reference right after spawn and the
host exits with its last client. The bundled backend adds one
synchronous throw point — a missing or unloadable conpty.dll — and a
web terminal has no child_process fallback, so a failed bundled spawn
retries once on the inbox backend: the pre-fix leaking behavior beats a
terminal that cannot start at all. A failed spawn produced no child, so
the retry cannot double-spawn. The 4001 non-retryable close path is
unchanged.

Also switch the agent-view PTY host to the bundled backend (same leak,
lower rate), refresh the conpty-host release docs, and add a
VERIFIED_NODE_PTY pin test so a node-pty bump forces re-verification of
the native teardown semantics the release path depends on.

Refs #11352
@yiliang114
yiliang114 force-pushed the fix/web-terminal-bundled-conpty branch from 5cb5f11 to cce1866 Compare September 11, 2026 09:07
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Thanks — re-running the gate at b7934e39, five commits past the last full pass. The interesting part is that the last two commits removed machinery rather than adding it.

Template looks good ✓ (all nine headings present, and the two design docs carry reciprocal links with matching section order in both languages).

Problem: observed and measured, not theoretical. #11352 is open as P1 / type/bug / scope/windows, and @wenshao ran the A/B on a real windows-2022 host driving this tree's own registry: base 78bbd9f5 left 18 orphaned conhost.exe --headless holding 111.5 MB after 18 natural shell exits; head left 0 hosts, 0 MB. The spawn path that measurement covers is byte-identical to the head under review, so that half of the claim carries forward.

Direction: aligned. #11497 (merged) already moved the shell tool onto bundled ConPTY; this brings the last two spawn sites — the web-terminal registry and the agent-view PTY host — onto the same backend, so Windows PTY creation follows one policy instead of two. Upstream CHANGELOG has no direct conhost/ConPTY entry, but Windows terminal-process lifecycle is a recurring fix area there: neutral-to-supportive rather than a precedent.

Size: core paths are touched (packages/core/src/services/**) and the change spans three packages (core, cli, web-shell). Breakdown of the 1151 changed lines: production logic 286 (TerminalPanel.tsx 127, web-terminal-registry.ts 123, conpty-host.ts 18 — comments only, terminal.ts 13, pty-host.ts 5), build script 20 (prepare-package.js), tests 738, docs 107, generated/schema 0. Well under the 500-line escalation threshold and under the 1000-line large-PR advisory. The author is a maintainer (admin), so the two-tier core gate's maintainer exemption applies; noting the numbers anyway since it is a core, cross-package change.

Worth saying out loud because it cuts against the usual direction: core-side production logic shrank since the last pass (web-terminal-registry.ts 185 → 123) while the total grew, because the new lines are the Web Shell client and route half of the replay protocol. The registry got simpler.

Approach: the scope feels right, and this round it got smaller in the place that mattered. My independent baseline before reading the diff was: mirror #11497 at the two remaining spawn sites, reuse loadXtermHeadless rather than adding a loader, keep an inbox retry because bundled ConPTY introduces a synchronous throw, ship the conpty.dll pins, and — for the query problem — make the sink aware of history-vs-live instead of pattern-matching the byte stream. The PR now does exactly that last part: the hand-written TerminalQueryStripper, its TERMINAL_QUERY_SEQUENCE_RE and its MAX_HELD_ESCAPE_CHARS carry are gone from head (I grepped; nothing remains), replaced by an ownership rule — the server answers only primary DA, the browser owns colours, modes and geometry, and reconnect history is written into a terminal that has no input forwarder yet.

That is the right call and it is not just tidier. The filter approach had produced the standing R2-1 Critical and the /verify round's 12 failures, all from one root cause: an unbounded pending carry where a single unterminated OSC withheld every following byte. Deleting the filter deletes the class. Enumerating escape sequences against two different xterm majors was never going to converge.

Two things I checked rather than assumed, since both were raised earlier in this thread:

  • agent-view needs no responder. Its retained ring is read by pty-host-process.ts:886 and supervisor-process.ts:1151 as output.toString() — a plain string for a log view, not a replay into an interactive xterm. So it carries neither the re-answer hazard nor a browser that needs DA suppressed. The five-line backend switch there is the same policy applied consistently; I'd keep it.
  • the replay=1 handshake is proportionate. It does force daemon and Web Shell to move together, but packages/web-shell is served by the daemon so they ship as one artifact; the only realistic mismatch is a stale cached browser page, which gets an explicit "reload this page" and a non-retryable 4002 rather than a terminal with a JSON blob at the top.

What I would push back on is round count, not scope: this is the sixth round. Per AGENTS.md, from here only Critical fixes should land — and I did not find one at this head, so the five Suggestions in my Stage 2 notes belong in a follow-up issue rather than another widening of this diff.

Risk: no elevated risk signals — none of the changed files match the revert-correlated paths (shellExecutionService, shell.ts, sandbox.ts, relaunch.ts, the mcp/acp/lsp families). The one thing that deserves reviewer attention is not a path signal but a coverage one: PR CI skips the Windows test lane by workflow design, so the head's responder + replay protocol has never executed on a real Windows host. Details and the lane that would settle it are in Stage 2.

Moving on to code review. 🔍

中文说明

感谢贡献 —— 在 b7934e39 重跑门禁,距上次完整审查已过去五个提交。值得注意的是:最后两个提交是删掉了机制,而不是增加。

模板完整 ✓(九个标题齐全;两份设计文档带有互链,两种语言的章节顺序一致)。

问题: 已观测且已测量,不是理论性加固。#11352P1 / type/bug / scope/windows 开启;@wenshao 在真实 windows-2022 主机上、驱动本树自己的 registry 做了 A/B:base 78bbd9f5 在 18 次 shell 自然退出后遗留 18 个孤儿 conhost.exe --headless、占用 111.5 MB;head 为 0 个 host、0 MB。那次测量覆盖的 spawn 路径与本次审查的 head 逐字节相同,因此这一半结论可以延续。

方向: 对齐。#11497(已合并)已把 shell 工具切到 bundled ConPTY;本 PR 把最后两个 spawn 点 —— web-terminal registry 与 agent-view PTY host —— 统一到同一后端,使 Windows 上的 PTY 创建只遵循一套策略。上游 CHANGELOG 没有 conhost/ConPTY 的直接条目,但 Windows 终端进程生命周期在该文件里是反复出现的修复领域:属于中性偏支持,而非既有先例。

规模: 触及核心路径(packages/core/src/services/**),且跨三个包(corecliweb-shell)。1151 行改动的构成:生产逻辑 286 行TerminalPanel.tsx 127、web-terminal-registry.ts 123、conpty-host.ts 18 —— 仅注释、terminal.ts 13、pty-host.ts 5)、构建脚本 20 行prepare-package.js)、测试 738 行文档 107 行、生成/schema 0 行。远低于 500 行升级阈值,也低于 1000 行大 PR 建议线。作者是维护者(admin),因此适用两层核心门禁的维护者豁免;但因为属于核心且跨包的改动,仍在此记录数字。

有一点值得明说,因为它与通常的方向相反:核心侧生产逻辑相比上一轮减少了web-terminal-registry.ts 185 → 123),而总量增加,是因为新增行属于回放协议的 Web Shell 客户端与路由那一半。registry 变得更简单了。

方案: 范围合理,而且这一轮在关键处变小了。我在读 diff 之前的独立方案是:在剩下两个 spawn 点复刻 #11497、复用 loadXtermHeadless 而不是新增加载器、保留 inbox 重试(因为 bundled ConPTY 引入了同步抛出)、发布 conpty.dll pin,并且 —— 对查询问题 —— 让接收端区分历史与实时,而不是对字节流做模式匹配。PR 现在恰好就是这么做的:手写的 TerminalQueryStripper、它的 TERMINAL_QUERY_SEQUENCE_REMAX_HELD_ESCAPE_CHARS 暂存都已在 head 上消失(我 grep 过,没有残留),取而代之的是一条归属规则 —— 服务端只应答 primary DA,浏览器负责颜色、模式与几何,重连历史写入一个尚未安装输入转发器的终端。

这是正确的选择,而且不只是更整洁。过滤器方案产生了仍然挂着的 R2-1 Critical,以及 /verify 那轮的 12 个失败 —— 全部来自同一个根因:无界的 pending 暂存,一个未结束的 OSC 就会扣住其后所有字节。删掉过滤器就删掉了这一整类问题。对着两个不同的 xterm 大版本枚举转义序列,本来就不可能收敛。

有两点我是去核对的、而不是想当然的,因为它们在本帖里都被提过:

  • agent-view 不需要 responder。 它保留的 ring 由 pty-host-process.ts:886supervisor-process.ts:1151output.toString() 读取 —— 是给日志视图用的纯字符串,不是回放进入交互式 xterm。所以它既没有重复应答的风险,也没有需要抑制 DA 的浏览器。那里五行的后端切换只是同一策略的一致应用;我会保留。
  • replay=1 握手是适度的。 它确实要求 daemon 与 Web Shell 同步升级,但 packages/web-shell 由 daemon 自身提供服务,两者作为同一产物发布;唯一现实的不匹配是浏览器缓存了旧页面,而它会收到明确的「reload this page」和不可重试的 4002,而不是顶部多出一坨 JSON 的终端。

我要提出异议的是轮次,而不是范围:这已经是第六轮。按 AGENTS.md,从这里开始只应落地 Critical 修复 —— 而我在当前 head 上没有发现 Critical,所以 Stage 2 里的五条 Suggestion 应该开成后续 issue,而不是再次扩大这个 diff。

风险: 无升级风险信号 —— 改动文件都不匹配与 revert 相关的路径(shellExecutionServiceshell.tssandbox.tsrelaunch.ts,以及 mcp/acp/lsp 系列)。真正需要审查者注意的不是路径信号,而是覆盖度:PR CI 按工作流设计跳过 Windows 测试通道,因此 head 上的 responder + 回放协议从未在真实 Windows 主机上执行过。细节以及能定论的通道见 Stage 2。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b7934e39c60c8a3eb473f31a015d907e34cf3163 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Code review at b7934e39

My independent baseline before reading the diff was: mirror #11497 at the two remaining spawn sites, reuse the existing loadXtermHeadless loader instead of adding one, keep an inbox retry because bundled ConPTY introduces a synchronous throw, derive the conpty.dll pins rather than hardcoding a third table, and make the replay sink history-aware instead of pattern-matching escape sequences out of the byte stream. The PR does all of that. The only place it goes past my baseline is the cross-package marker handshake, and it is past it for a reason I could verify: without a marked boundary the client cannot tell "history that must stay silent" from "live output that must be answered", and a global mute flag would drop keystrokes too.

No Critical findings at this head. Below are the things I checked because they are the specific ways this design could have been silently wrong, then what remains open.

The standing R2-1 Critical is gone by construction, not patched

R2-1 and the /verify round's 12 failures (all one root cause) were about the query stripper: an unbounded pending carry where a single unterminated OSC withheld every following byte, plus query families the regex did not match. I grepped head rather than trusting the description — TerminalQueryStripper, TERMINAL_QUERY_SEQUENCE_RE, PARTIAL_ESCAPE_SUFFIX_RE, MAX_HELD_ESCAPE_CHARS and strip( are all absent from web-terminal-registry.ts. The registry no longer rewrites terminal bytes at all. Whatever the earlier rounds concluded about those regexes no longer has a subject.

Three of the previously reported Suggestions also closed, two of them structurally rather than by patch:

  • Responder scrollback: 0 — present at web-terminal-registry.ts:263. That is @wenshao's measured 2.76 MB → 0.05 MB per live session.
  • Responder disposed on the spawn-failure catchqueryReplyDisposable?.dispose(); queryTerminal?.dispose(); now sit in the catch at :440.
  • Responder never followed the client's resize — now moot rather than fixed, and correctly so: the only reply forwarded is the constant \x1b[?1;2c, so the responder's geometry affects nothing it emits. The stale-80-column CPR that reproducing it produced (ESC[6n answered ESC[8;21R) is now left to the browser, which has the real geometry. resize() at :532 touching only the PTY is right, not an omission.
  • The unpinned live-listener half — the property the last round called load-bearing and untested is now pinned, and pinned at the boundary that actually matters. TerminalPanel.test.tsx's "keeps the old terminal interactive until reconnect replay finishes" asserts the restore terminal receives both the history and the live chunk in order, that onData is not attached before the swap, that the old terminal's keystroke still reaches the socket during replay, and that after the swap callback the old terminal is disposed, the forwarder is installed, and a reply through it reaches the socket. Reverting to listener(data) on the server no longer keeps the suite green, because the server now forwards raw bytes by design and the suppression lives in the client.

What I verified against upstream rather than assuming

Four claims in this design are load-bearing and none of them is visible from the diff alone, so I read the xterm.js sources at the pinned versions.

  1. The DA suppression is prefix-exact. term.parser.registerCsiHandler({ final: 'c' }, () => handlesPrimaryDa) looks like it would swallow DA2 and DA3 as well, which would leave them answered by nobody — the server forwards only DA1. It does not. In xterm.js@6.0.0 EscapeSequenceParser.ts, _identifier({final:'c'}) yields 0x63 and dispatch keys on this._collect << 8 | code, so \x1b[>c (0x3e63) and \x1b[=c (0x3d63) miss the handler entirely and fall through to _csiHandlerFb, the native handler. Returning false also bubbles to that fallback, which is precisely the "browser owns DA" case. So "primary DA has exactly one answerer" holds without collateral suppression of the other two families.
  2. The empty-snapshot swap still fires. A brand-new PTY usually has no output yet, and the route sends Buffer.from('') regardless — pinned by terminal.test.ts. If xterm skipped the write callback for an empty string, restoreSnapshot would never swap: output would go to a terminal on a detached DOM host and the user would stare at a blank pane with input still wired to the invisible one. WriteBuffer.ts@5.5.0 pushes the data and the callback unconditionally, and _innerWrite calls cb() for that entry before advancing, so the swap happens. This was the one place the entire first-connection path could have dead-ended silently.
  3. The ordering claim in the design doc is real. "The snapshot write callback installs the new forwarder and swaps the visible host before queued live writes parse" is what makes history silent and live output answered, and it depends on xterm's internals. _innerWrite invokes chunk N's callback before _action on chunk N+1, so it holds.
  4. There is no live-output-before-marker race. terminal.ts attaches the output listener at :309 but sends the snapshot control frame at :339 — a live chunk landing in between would reach the client as an unmarked binary frame and trip its 4002 "restart the daemon" path. It cannot: everything from addOutputListener through sendOutput is synchronous with no await, so no PTY data event can interleave. Attaching before reading the snapshot is also the pre-existing correct order — it is what stops output being lost between the read and the attach.

Consumer check on every added field

Per the house rule that a declared-but-unset option is a dead switch:

  • WebTerminalSnapshot.handlesPrimaryDa — one production read site, terminal.ts:342, and it is genuinely populated (readSnapshot sets it whenever session.queryTerminal exists). Note it stays true after releasePtyResources disposes the responder; harmless, since the shell is gone and nothing queries it, and pinned by a test.
  • AgentViewPtySpawnOptions.useConptyDll — became a required field, so any second construction site would fail to compile. There is one, at pty-host.ts:353, reaching pty.module.spawn(...). Not dead.
  • /terminal's new replay=1 requirement — the route has exactly one client, TerminalPanel.buildWsUrl, updated in the same commit. No integration test, desktop-shell or bridge client connects to it; I grepped. The release-only path is checked before the replay gate, so detaching a terminal still works from an older page.
  • @xterm/headless and loadXtermHeadless are reused from fix(core): host Windows shell PTYs via bundled ConPTY to stop orphaning conhost.exe #11497, not re-added; the loader caches its module promise, so terminals two through eight do not re-import.

Still open — all Suggestion-level, none blocking

This is the sixth round, so per AGENTS.md these belong in a follow-up issue rather than another pass over this diff. All five were either reported before and declined, or are new and small.

  1. The bundled→inbox retry discards firstError silently (web-terminal-registry.ts:381). No log, no session flag. The consequence is operational rather than functional: a Windows user whose conpty.dll fails to load lands back on the leaking backend and [Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 quietly returns for them, with nothing in the daemon's output saying so. One debugLogger.warn would close it. Previously reported as R1-2.
  2. prepare-package.js's nodePtyPins derivation still yields {} silently where the adjacent sharp derivation throws. The new conpty-host.test.ts tripwire is a real improvement — it asserts core's manifest carries exactly six @lydell/node-pty* pins, all at the verified version, and that root matches core — but it guards the derivation's input. Break the .filter(...) predicate itself and the release ships zero of six pins with test:scripts green. Previously reported as R3-3; I confirmed the six pins are present and identical today, so this is drift insurance, not a live defect.
  3. The new client-side protocol notice bypasses i18n. TerminalPanel.tsx:311 writes a hardcoded English 'Terminal protocol changed; restart the daemon and reload this page.', while every sibling notice in the same file goes through t('terminal.notice.*') and the Web Shell ships a complete zh-CN locale (i18n.tsx:5119-5122). A Chinese-locale user gets one English line in an otherwise Chinese terminal.
  4. restoring?.term survives an early-return onclose. The dispose sits after if (ended || NON_RETRYABLE_CLOSE_CODES.has(event.code)) return; in ws.onclose, and the stale write callback's guard disposes only liveInput, not the terminal. Bounded — the effect cleanup disposes it on unmount, and in the common ended case the callback still runs and completes the swap — so this is a short-lived detached instance, not a growing leak.
  5. Description drift, and one assertion I could not verify. The Tested-on table still marks Windows "Not tested on a native host" although this thread contains a maintainer Windows A/B with concrete numbers, and Evidence describes only the Chromium component check. Separately, both design docs state the cross-package correction "was approved on 2026-09-13" — a date after the head commit (2026-09-12T17:59Z) — and I could not find that approval anywhere in the thread. Flagging it because a committed design doc is what a future reader trusts; if the sign-off happened off-thread, naming it would make the doc accurate.

One nit: the catch around queryTerminal.write(data) attributes the throw solely to a mid-stream dispose, but xterm's WriteBuffer.write also throws on DISCARD_WATERMARK overflow. Both are handled correctly by the swallow; the comment just names one of the two reasons, and a swallow-everything catch is exactly where a future reader needs the full list.

Reconnect replay flow
sequenceDiagram
    participant P1 as PowerShell PTY
    participant P2 as WebTerminalRegistry
    participant P3 as headless xterm responder
    participant P4 as terminal WS route
    participant P5 as browser xterm visible
    participant P6 as browser xterm restore host

    Note over P1,P5: live output on Windows bundled ConPTY
    P1->>P2: output chunk, may contain a query
    P2->>P3: write raw bytes
    P3-->>P2: onData reply
    P2->>P1: forward only the primary DA reply
    P2->>P2: buffer raw bytes unchanged
    P2->>P5: listener receives raw bytes
    P5->>P1: browser answers colour, mode and geometry queries

    Note over P4,P6: reconnect
    P4->>P5: control frame snapshot replay true handlesPrimaryDa true
    P4->>P6: binary snapshot frame
    P6->>P6: parse history with no input forwarder, replies go nowhere
    P6-->>P5: write callback swaps host and installs forwarder
    P4->>P5: later binary frames are live output
    P5->>P1: browser answers again
Loading
Files changed (13)
File What changed
packages/core/src/services/web-terminal-registry.ts Spawns with bundled ConPTY on win32 plus one inbox retry; adds a headless responder that forwards only primary DA; keeps the buffered stream byte-exact; disposes the responder on every teardown path
packages/core/src/services/web-terminal-registry.test.ts Nine new cases on the real @xterm/headless: backend choice per platform, the retry, byte preservation across split escapes, DA-only answering, the no-responder fallback, and both cancellation windows during the headless load
packages/core/src/services/conpty-host.ts Comments only — the doc previously claimed web-terminal and agent-view PTYs still use the inbox backend, which this PR makes false
packages/core/src/services/conpty-host.test.ts New file. Pins the six @lydell/node-pty versions core and root declare, and drives the release through each WindowsPtyAgent internal shape
packages/cli/src/agent-view/pty-host.ts Five lines: useConptyDll becomes a required spawn option, set from process.platform at the single construction site
packages/cli/src/agent-view/pty-host.test.ts Asserts the option reaches the underlying spawn
packages/cli/src/serve/routes/terminal.ts Requires replay=1 after the release check, and emits a NUL-prefixed snapshot control frame carrying replay and handlesPrimaryDa immediately before the binary history
packages/cli/src/serve/routes/terminal.test.ts Pins the frame's exact bytes and position on both the fresh and reconnect paths, and that a legacy client is closed before any PTY is created
packages/web-shell/client/components/terminal/TerminalPanel.tsx Sends replay=1; restores a marked snapshot into a second terminal on a detached host with no input forwarder, keeps the old one accepting keystrokes, then swaps on the write callback; rejects an unmarked snapshot from an older daemon
packages/web-shell/client/components/terminal/TerminalPanel.test.tsx Multi-instance terminal mock; pins replay silence, live answering after the swap, typing during replay, interrupted-replay disposal, and old-daemon rejection
scripts/prepare-package.js Derives the six node-pty pins from core's manifest instead of a third hardcoded table
docs/design/web-terminal-replay.md New design: query ownership, the transport marker, browser restoration, acceptance criteria
docs/design/web-terminal-replay.zh-CN.md Chinese counterpart, same five sections and same technical identifiers

Testing

This is an unattended CI run, so per the gate's rules I did not build, run, or execute anything from this PR's tree. All test evidence below is the PR's own CI, read through the API at the reviewed commit — real check names and real conclusions.

41 check-runs on b7934e39: 23 success, 17 skipped, 0 failure, 1 in progress (review-pr, which is bot orchestration on an issue_comment event, not PR CI). All five pull_request-event workflow runs are complete and green. Nothing failed, so there is no failing-job log to quote.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Serve A/B (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
TUI parity snapshots (ink vs opentui) success
OpenTUI no-flicker gate success
Real daemon E2E / Java 11 success
SDK Java (ubuntu 11/17/21, windows 21, macos 21) success
Classify PR · assign · label · Remind on force-push success
Test (windows-latest, Node 22.x) skipped
Test (macos-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped
review-pr in progress

Three honest qualifications on that table, because a wall of green oversells it:

  • The Windows and macOS unit lanes are skipped by workflow design, not by this PR. ci.yml:1653 and :1556 gate both on merge_group || schedule || workflow_dispatch, so they never run on pull_request and will run in the merge queue after approval. The win32 code paths are still covered on Ubuntu — the tests force them with osPlatform.mockReturnValue('win32') — but that is mocked-win32, not a real ConPTY.
  • Integration Tests (CLI, No Sandbox) is skipped while Integration Tests (no-AK, No Sandbox) passed. Integration coverage exists; it is just the AK lane that did not run.
  • The two supporting bot lanes at this head are weaker evidence than they look. Serve A/B diffed base vs head across the endpoint set and found exactly one changed field, activeWorkStaleMs 4 → 5 — timing noise, and good news for the daemon side. Web-shell Visuals reported no screenshot changed, but its own output says it has no scenario that renders TerminalPanel, so that is a coverage gap rather than a clean bill of health for the restore path.

Not verified: the head's responder and replay protocol against a real Windows shell. @wenshao's A/B covers the backend switch at 9e54d5ab — whose spawn path is byte-identical to head, so the conhost-leak claim carries forward — but the responder's DA forwarding and the browser restore have only ever run against mocked win32 on Linux, and the author's Chromium component check, which is the author's own claim and not something I re-ran.

Sandboxed verification would settle part of this: @qwen-code /verify is already running against b7934e39 (its state comment is in this thread) and will A/B the responder and replay logic against the base build with mock-free harnesses — worth reading specifically for whether the previous round's 12 failures are now zero, since the code that caused them no longer exists. It will not settle the Windows half: that container is Linux, so native conhost process accounting at this head still needs the merge-queue Test (windows-latest) lane plus a real process-count run like @wenshao's. That is the single unsubstantiated item standing between this PR and a confident merge, and it is a platform-availability gap, not something more review can close.

Real-scenario tmux testing: not applicable — this is an unattended CI run, and the gate never executes PR-derived code on that path. The live-behaviour signal is the CI evidence above plus the /verify lane.

中文说明

b7934e39 的代码审查

我在读 diff 之前的独立方案是:在剩下两个 spawn 点复刻 #11497、复用已有的 loadXtermHeadless 而不是新增加载器、保留 inbox 重试(因为 bundled ConPTY 引入了同步抛出)、用推导代替硬编码第三份 conpty.dll pin 表,并且让回放的接收端区分历史与实时,而不是从字节流里用模式匹配抠掉转义序列。PR 全部做到了。唯一超出我方案的是跨包的标记握手,而它超出的理由我能验证:没有标记边界,客户端就无法区分「必须保持沉默的历史」和「必须应答的实时输出」,而全局静音标志会连按键一起丢掉。

当前 head 上没有 Critical。下面是我核对过的、这套设计可能悄悄出错的几个具体位置,然后是仍然挂着的问题。

挂着的 R2-1 Critical 是被结构性消除的,不是被补丁修掉的

R2-1 以及 /verify 那轮的 12 个失败(同一个根因)都关于查询过滤器:无界的 pending 暂存 —— 一个未结束的 OSC 就扣住其后所有字节 —— 加上正则没有覆盖的查询族。我是 grep head 而不是采信描述:TerminalQueryStripperTERMINAL_QUERY_SEQUENCE_REPARTIAL_ESCAPE_SUFFIX_REMAX_HELD_ESCAPE_CHARSstrip(web-terminal-registry.ts 里全部不存在。registry 现在完全不再改写终端字节。前几轮关于那些正则的任何结论都已经没有对象了。

三条此前提出的 Suggestion 也关闭了,其中两条是结构性关闭而非打补丁:

  • responder 的 scrollback: 0 —— 已在 web-terminal-registry.ts:263。也就是 @wenshao 实测的每个活跃会话 2.76 MB → 0.05 MB。
  • spawn 失败的 catch 里释放 responder —— :440 的 catch 中现在有 queryReplyDisposable?.dispose(); queryTerminal?.dispose();
  • responder 从不跟随客户端 resize —— 现在是失效而非被修复,而且是正确的失效:唯一被转发的应答是常量 \x1b[?1;2c,所以 responder 的几何尺寸对它发出的内容毫无影响。复现该问题时产生的过期 80 列 CPR(ESC[6n 被答成 ESC[8;21R)现在交给拥有真实几何的浏览器。:532resize() 只动 PTY 是对的,不是遗漏。
  • 未被测试钉住的实时监听那一半 —— 上一轮称为「承重却无测试」的性质现在被钉住了,而且钉在真正要紧的边界上。TerminalPanel.test.tsx 的 "keeps the old terminal interactive until reconnect replay finishes" 断言:恢复终端按序收到历史块和实时块、切换前 onData 挂载、回放期间旧终端的按键仍能到达 socket、切换回调之后旧终端被释放、转发器被安装、经它发出的应答能到达 socket。把服务端改回 listener(data) 不再能保持全绿,因为服务端现在按设计转发原始字节,抑制逻辑住在客户端。

我对着上游源码核实、而不是假设的四点

这套设计有四个承重结论,单看 diff 都看不出来,所以我按 pin 的版本读了 xterm.js 源码。

  1. DA 抑制是前缀精确的。 term.parser.registerCsiHandler({ final: 'c' }, () => handlesPrimaryDa) 看起来会连 DA2、DA3 一起吞掉,而那样它们就没人应答了 —— 服务端只转发 DA1。实际不会。在 xterm.js@6.0.0EscapeSequenceParser.ts 中,_identifier({final:'c'}) 得到 0x63,分发按 this._collect << 8 | code 取键,所以 \x1b[>c0x3e63)和 \x1b[=c0x3d63)完全命不中该 handler,会落到 _csiHandlerFb 即原生 handler。返回 false 同样会冒泡到那个 fallback,而这正是「浏览器拥有 DA」的情形。所以「primary DA 只有一个应答方」成立,且不会连带抑制另外两族。
  2. 空快照的切换仍会触发。 全新 PTY 通常还没有输出,而路由无论如何都会发 Buffer.from('') —— terminal.test.ts 钉住了这一点。如果 xterm 对空字符串跳过 write 回调,restoreSnapshot 就永远不会切换:输出会进入挂在游离 DOM 容器上的终端,用户面对一个空白面板,而输入仍连在那个不可见的终端上。WriteBuffer.ts@5.5.0 无条件压入数据和回调,_innerWrite 在推进前会对该条目调用 cb(),所以切换会发生。这是整条首连路径唯一可能静默死掉的地方。
  3. 设计文档里的顺序声明是真的。「快照 write 回调安装新转发器并切换可见容器,然后排队的实时 write 才会解析」正是让历史沉默、实时应答的机制,而它依赖 xterm 内部行为。_innerWrite 在对第 N+1 块执行 _action 之前先调用第 N 块的回调,所以该声明成立。
  4. 不存在「实时输出先于标记」的竞态。 terminal.ts:309 挂载输出监听,却在 :339 才发快照控制帧 —— 中间若有实时块到达,客户端会收到未标记的二进制帧并触发 4002「restart the daemon」。这不会发生:从 addOutputListenersendOutput 全部同步、没有 await,因此 PTY 数据事件无法插入。先挂监听再读快照也是既有的正确顺序 —— 它正是防止输出在「读取」与「挂载」之间丢失的手段。

每个新增字段的消费方核查

按本仓库「声明却无人赋值的选项就是死开关」这条规则:

  • WebTerminalSnapshot.handlesPrimaryDa —— 只有一个生产读取点 terminal.ts:342,且确实被赋值(只要 session.queryTerminal 存在,readSnapshot 就会设置)。注意它在 releasePtyResources 释放 responder 之后仍为 true;无害,因为 shell 已经没了、不会再有查询,并且有测试钉住。
  • AgentViewPtySpawnOptions.useConptyDll —— 变成了必填字段,所以任何第二个构造点都会编译失败。构造点只有一个,在 pty-host.ts:353,直达 pty.module.spawn(...)。不是死开关。
  • /terminal 新增的 replay=1 要求 —— 该路由只有一个客户端 TerminalPanel.buildWsUrl,在同一提交里更新。没有集成测试、desktop-shell 或 bridge 客户端连它;我 grep 过。release-only 路径在 replay 门禁之前判断,所以旧页面仍能释放终端。
  • @xterm/headlessloadXtermHeadless 是复用 fix(core): host Windows shell PTYs via bundled ConPTY to stop orphaning conhost.exe #11497 的产物,不是新加;加载器缓存 module promise,因此第二到第八个终端不会重复 import。

仍然挂着 —— 全部 Suggestion 级,都不阻塞

这是第六轮,所以按 AGENTS.md 这些应该开成后续 issue,而不是再过一遍这个 diff。五条都是要么此前已报告并被作者婉拒,要么是新增且很小。

  1. bundled→inbox 重试静默丢弃 firstErrorweb-terminal-registry.ts:381)。无日志、无会话标志。后果是运维层面而非功能层面:conpty.dll 加载失败的 Windows 用户会退回泄漏后端,[Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 对他们悄悄复发,而 daemon 输出里没有任何提示。一行 debugLogger.warn 就能关闭。此前作为 R1-2 报告过。
  2. prepare-package.jsnodePtyPins 推导仍然静默产出 {},而紧邻的 sharp 推导会抛。新增的 conpty-host.test.ts tripwire 是实质改进 —— 它断言 core 清单恰好带六个 @lydell/node-pty* pin、全部为已验证版本、且 root 与 core 一致 —— 但它守的是推导的输入。把 .filter(...) 谓词本身改坏,发布产物就会 0/6 个 pin 而 test:scripts 全绿。此前作为 R3-3 报告过;我确认了今天六个 pin 都在且相同,所以这是漂移保险,不是现存缺陷。
  3. 新增的客户端协议提示绕过了 i18n。 TerminalPanel.tsx:311 硬编码英文 'Terminal protocol changed; restart the daemon and reload this page.',而同文件里每一条同类提示都走 t('terminal.notice.*'),且 Web Shell 有完整的 zh-CN 语言包(i18n.tsx:5119-5122)。中文语言环境的用户会在 otherwise 中文的终端里看到一行英文。
  4. restoring?.termonclose 提前返回时不被释放。 那次 dispose 位于 ws.oncloseif (ended || NON_RETRYABLE_CLOSE_CODES.has(event.code)) return; 之后,而过期 write 回调的守卫只释放 liveInput、不释放终端。有界 —— effect cleanup 会在卸载时释放它,且在常见的 ended 情形下回调仍会运行并完成切换 —— 所以这是短命的游离实例,不是增长型泄漏。
  5. 描述漂移,以及一条我无法核实的断言。 Tested-on 表仍标 Windows「Not tested on a native host」,尽管本帖里已有带具体数字的维护者 Windows A/B;Evidence 也只描述了 Chromium 组件检查。另外,两份设计文档都写着跨包修正「was approved on 2026-09-13」—— 一个晚于 head 提交(2026-09-12T17:59Z)的日期 —— 而我在整个 thread 里找不到那次批准。之所以提出来,是因为已提交的设计文档正是未来读者会信任的东西;如果签署发生在线下,写明出处会让文档更准确。

一个小 nit:queryTerminal.write(data) 外那个 catch 把抛出原因只归于流中途 dispose,但 xterm 的 WriteBuffer.writeDISCARD_WATERMARK 溢出时也会抛。两者都被这个吞异常正确处理了;只是注释只列了两个原因中的一个,而「吞掉一切」的 catch 恰恰是未来读者最需要完整清单的地方。

测试

这是无人值守的 CI 运行,因此按门禁规则我没有构建、运行或执行本 PR 树里的任何东西。下面所有测试证据都来自 PR 自己的 CI,通过在受审提交上调用 API 读取 —— 真实的检查名和真实的结论。

b7934e39 上 41 个 check-run:23 success、17 skipped、0 failure,1 个进行中(review-pr,属于 issue_comment 事件上的机器人编排,不是 PR CI)。五个 pull_request 事件的工作流运行全部完成且为绿。没有失败,因此没有失败日志可引。

对那张表有三点诚实的限定,因为一片绿会高估它:

  • Windows 与 macOS 单元测试通道是工作流设计跳过的,不是本 PR 造成的。 ci.yml:1653:1556 都把两者限定在 merge_group || schedule || workflow_dispatch,所以在 pull_request 上永不运行,会在批准后的合并队列里运行。win32 代码路径在 Ubuntu 上仍有覆盖 —— 测试用 osPlatform.mockReturnValue('win32') 强制进入 —— 但那是 mocked-win32,不是真实 ConPTY。
  • Integration Tests (CLI, No Sandbox) 被跳过,而 Integration Tests (no-AK, No Sandbox) 通过。 集成覆盖是存在的,只是 AK 通道没跑。
  • 本 head 上两条辅助机器人通道的证据强度低于表面。 Serve A/B 对 base 与 head 跑了整套端点,只发现一个字段变化,activeWorkStaleMs 4 → 5 —— 计时噪声,对 daemon 侧是好消息。Web-shell Visuals 报告没有截图变化,但它自己的输出说明没有任何场景渲染 TerminalPanel,所以对恢复路径而言这是覆盖缺口,不是合格证明。

未验证:head 上的 responder 与回放协议对着真实 Windows shell 的表现。@wenshao 的 A/B 覆盖的是 9e54d5ab 上的后端切换 —— 其 spawn 路径与 head 逐字节相同,所以 conhost 泄漏这一结论可以延续 —— 但 responder 的 DA 转发和浏览器恢复只在 Linux 上对着 mocked win32 跑过,以及作者自己的 Chromium 组件检查(那是作者的声明,不是我重跑的结果)。

沙箱验证能定论其中一部分:@qwen-code /verify 已在对 b7934e39 运行(本帖里有它的状态评论),会用无 mock 的 harness 把 responder 与回放逻辑对着 base 构建做 A/B —— 特别值得看上一轮那 12 个失败是否已归零,因为导致它们的代码已经不存在了。但它不能定论 Windows 那一半:那个容器是 Linux,所以本 head 上的原生 conhost 进程记账仍需要合并队列的 Test (windows-latest) 通道,加上一次像 @wenshao 那样的真实进程计数运行。这是本 PR 与「有把握地合并」之间唯一未被证实的项目,而它是平台可用性缺口,不是更多审查能关闭的。

真实场景 tmux 测试:不适用 —— 这是无人值守 CI 运行,该路径下门禁从不执行 PR 衍生的代码。实时行为信号来自上面的 CI 证据加上 /verify 通道。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b7934e39c60c8a3eb473f31a015d907e34cf3163 · re-run with @qwen-code /triage

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent-assisted review at cce1866c0b8eb028130b5618923de7c51ce83423 — no confirmed code Criticals in this static review.

Reviewed the full six-file diff against base 518f6795f9eda4b9911c2a1e8ea76ddc787551cb, the complete changed production files, and the added tests. Traced useConptyDll from both spawn sites through the actual PTY loaders; checked the registry's failed-spawn retry, creation bookkeeping, early output/exit, live release and exit-time cleanup. Downstream inspection included the terminal WebSocket handler, server registry ownership/workspace drain, the shell-service precedent, and Agent View's host-process launch/exit cleanup. No daemon route is added or changed here; the terminal handler continues to supply its resolved workspace cwd/environment to the registry.

Suggestion (non-blocking): packages/cli/src/agent-view/pty-host.ts:346-357 has no inbox-backend retry, unlike web-terminal-registry.ts:325-336. A bundled-DLL load failure propagates through runAgentViewPtyHostProcess before it starts the host server (pty-host-process.ts:477-481). Please confirm that failing Agent View startup, rather than using the web terminal's compatibility fallback, is intentional. This is not a claim that the shipped DLL is missing or that normal Windows launches fail.

History / scope: There were no prior formal review Criticals in the fetched history; the triage comment's fallback asymmetry is the suggestion above, not an independently reproduced native failure. Core/cross-package policy: approximately 57 changed production-file lines including comments; this is a small fix, not a 500+ line refactor. The author has repository admin permission, verified via GitHub's collaborator-permission endpoint.

Verification limits: Static source/test inspection only; no PR code, unit tests, builds, Windows PTYs, or native leak measurements were executed. The mock tests verify option propagation and cleanup calls, not native conhost.exe reclamation. The deliberately retained inbox fallback's natural-exit leak is documented, not presented as fixed.

Comment only; no approval implied.

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the leak fix is real and was measured on the target platform by a maintainer, the standing Critical is gone because the code it was about no longer exists, every load-bearing assumption in the new design checks out against upstream xterm sources, and what remains is five Suggestions plus one platform I cannot reach from here.

Going back to my own Stage 2 proposal: the PR does everything I would have done, and the one place it went further — the marked replay boundary across the daemon/Web Shell seam — is further for a reason I could verify rather than take on faith. What I did not expect going in was that this round would be a reduction. Two commits deleted a hand-written escape-sequence filter that three review rounds and a sandboxed verification had all found new ways to fail, and replaced it with an ownership rule that is shorter, has no byte-rewriting to get wrong, and closes the injection on every host rather than only Windows. When a round makes the diff smaller and the standing Critical loses its subject, that is the direction a sixth round should go.

The thing I am most satisfied with is that the four assumptions this design rests on are all real, and none of them is visible from the diff. I checked each against the pinned xterm sources rather than reasoning from the API names: the DA suppression is prefix-exact so DA2/DA3 stay browser-answered; the empty-snapshot write callback does fire, so a fresh terminal with no output yet still swaps into view instead of stranding the user on a blank pane wired to an invisible terminal; the snapshot callback really does run before queued live chunks parse, which is the whole "history silent, live answered" mechanism; and the route's listener-before-marker ordering cannot race because the block is synchronous. Any one of those being wrong would have been a silent, hard-to-diagnose failure in production. They are not wrong.

Equally, the property the last round called load-bearing and untested is now pinned — and pinned at the boundary where it actually lives. The suppression moved from the server's byte stream to the client's forwarder attachment, and TerminalPanel.test.tsx asserts the silence, the swap, the typing-during-replay path and the post-swap answering. Reverting it no longer keeps the suite green.

Two honest reservations, neither of which I think should hold this up:

The head's Windows runtime path has never executed on Windows. The conhost-leak half is solid — @wenshao's A/B measured 18 orphaned hosts and 111.5 MB at base against 0 and 0 at 9e54d5ab, and the spawn path there is byte-identical to head. But the responder's DA forwarding and the browser restore have only run against mocked win32 on Linux, plus the author's own Chromium component check. PR CI skips the Windows lane by design and will pick it up in the merge queue. This is a platform-availability gap, not a review gap, and it is the one thing I would want a human to hold in mind when the queue runs.

This is the sixth round, and the diff is not the only thing that has drifted. The Tested-on table still marks Windows untested although this thread contains real Windows numbers, and both design docs assert a cross-package approval "on 2026-09-13" — a date after the head commit — that I could not find anywhere in the thread. Per the re-run rules, hygiene is not a reason to defer, so I am not deferring on it. But the design doc is what someone reads in six months to understand why the daemon and the client must move together, and an unverifiable sign-off date is a poor thing to leave in it. Please refresh the body and either cite the approval or drop the claim before merging.

Six months from now, would I curse this or thank it? Thank it. The comments explain why where the why is genuinely non-obvious — the baton-erase ordering, the _useConptyDll discriminator, why a failed bundled spawn cannot double-spawn, why the headless load is a second cancellation window — and the pin tripwire fails loudly on a node-pty bump instead of silently degrading the release path. The design doc exists in both languages with matching structure. And the code got shorter in the place that was causing the trouble, which is the rarest shape a sixth round takes.

Verdict: approve. Guardrails cross-checked rather than eyeballed: not a fork PR (isCrossRepository: false), title is fix not refactor, so the approval guardrail does not engage; no Stage 0 escalation — the author is a maintainer and the change is 286 production lines, under both thresholds; PR CI on this head is fully complete and green, PENDING is 0, so the approval is pinned to b7934e39 now rather than deferred to a finalize pass.

One thing a maintainer needs to do that I will not do myself: reviewDecision is still CHANGES_REQUESTED, carried by my own /review at 1bd418b3 and qwen-code-dev-bot's at 77257a5. Both are anchored to the query stripper, which no longer exists at this head — R2-1's witnesses have no subject. My approval here does not clear them, and dismissing another account's review is not mine to do. @yiliang114 please dismiss those two stale change-requests (or ask @wenshao to), otherwise this sits blocked on findings about deleted code.

Filing the five Suggestions from Stage 2 as a follow-up issue is the right home for them under AGENTS.md's five-round rule. If someone picks them up, the two I would prioritise are the silent firstError on the inbox retry — because it makes the original bug reappear invisibly on affected machines — and the untranslated protocol notice.

中文说明

Confidence: 4/5 —— 泄漏修复是真实的,并由维护者在目标平台上实测;挂着的 Critical 之所以消失,是因为它所指向的代码已不存在;新设计的每一个承重假设都对着上游 xterm 源码核实通过;剩下的是五条 Suggestion,加上一个我在这里够不到的平台。

回到我在 Stage 2 写下的自己的方案:PR 做到了我会做的一切,而它唯一走得更远的地方 —— 跨 daemon/Web Shell 接缝的标记回放边界 —— 走远的原因是我能核实的,而不是只能采信。我进来时没料到的是这一轮是一次缩减。两个提交删掉了一个手写转义序列过滤器 —— 三轮审查加一次沙箱验证都在不断找到它新的失败方式 —— 换成一条归属规则:更短、没有会被写错的字节改写、并且在所有平台(而不只是 Windows)关闭了注入。当一轮让 diff 变小、并让挂着的 Critical 失去对象时,第六轮就该往这个方向走。

最让我满意的是,这套设计依赖的四个假设全都是真的,而且从 diff 里一个都看不出来。我按 pin 的 xterm 源码逐条核对,而不是从 API 名字推断:DA 抑制是前缀精确的,所以 DA2/DA3 仍由浏览器应答;空快照的 write 回调确实会触发,所以一个还没有输出的全新终端仍会切换进视野,而不是把用户留在一个空白面板上、输入却连在不可见的终端;快照回调确实先于排队的实时块解析,而这正是「历史沉默、实时应答」的全部机制;路由「先挂监听后发标记」的顺序不会竞态,因为那一段是同步的。这四条里任何一条错了,都会在生产环境里变成一个静默且难以诊断的故障。它们没有错。

同样,上一轮称为承重却无测试的那个性质现在被钉住了 —— 而且钉在它真正所在的边界上。抑制从服务端的字节流移到了客户端的转发器挂载,TerminalPanel.test.tsx 断言了沉默、切换、回放期间输入、以及切换后的应答。把它改回去不再能保持全绿。

两点诚实的保留,我都不认为应当挡住它:

head 的 Windows 运行时路径从未在 Windows 上执行过。 conhost 泄漏那一半是扎实的 —— @wenshao 的 A/B 测得 base 上 18 个孤儿 host、111.5 MB,而 9e54d5ab 上为 0 和 0,且那里的 spawn 路径与 head 逐字节相同。但 responder 的 DA 转发和浏览器恢复只对着 Linux 上 mocked 的 win32 跑过,外加作者自己的 Chromium 组件检查。PR CI 按设计跳过 Windows 通道,会在合并队列里接上。这是平台可用性缺口,不是审查缺口,也是合并队列运行时我希望人类记在心上的唯一一件事。

这是第六轮,而漂移的不只是 diff。 Tested-on 表仍标 Windows 未测,尽管本帖里就有真实的 Windows 数字;两份设计文档都断言跨包修正「was approved on 2026-09-13」—— 一个晚于 head 提交的日期 —— 而我在整个 thread 里找不到。按重跑规则,卫生问题不构成 defer 的理由,所以我没有据此 defer。但设计文档正是六个月后有人用来理解「为什么 daemon 和客户端必须同步升级」的东西,把一个无法核实的签署日期留在里面并不好。请在合并前刷新 PR 正文,并且要么写明那次批准的出处,要么删掉该断言。

六个月后我会骂它还是谢它?谢它。注释在 why 真正不显然的地方解释了 why —— baton 擦除的顺序、_useConptyDll 判别式、为什么失败的 bundled spawn 不会双重 spawn、为什么 headless 加载是第二个取消窗口 —— 而 pin tripwire 会在 node-pty 升级时大声失败,而不是静默把发布路径降级成空操作。设计文档两种语言齐备、结构一致。而代码在出问题的地方变短了,这是第六轮里最罕见的形态。

结论:批准。 门禁是交叉核对过的,不是靠眼估:不是 fork PR(isCrossRepository: false),标题是 fix 而非 refactor,所以批准护栏不触发;没有 Stage 0 升级 —— 作者是维护者,且改动为 286 行生产逻辑,低于两个阈值;本 head 上的 PR CI 已完全结束且为绿,PENDING 为 0,因此批准现在就钉在 b7934e39,而不是推迟到 finalize。

有一件需要维护者做、而我自己不会做的事:reviewDecision 仍是 CHANGES_REQUESTED,由我自己在 1bd418b3/reviewqwen-code-dev-bot77257a5 的评审携带。两者都锚在查询过滤器上,而它在本 head 已不存在 —— R2-1 的证人已无对象。我这里的批准不会清除它们,而驳回另一个账号的评审不归我做。@yiliang114 请 dismiss 那两条过期的 change-request(或请 @wenshao 来做),否则这个 PR 会因为针对已删除代码的发现而一直卡住。

按 AGENTS.md 的五轮规则,把 Stage 2 的五条 Suggestion 开成后续 issue 是它们正确的归宿。如果有人接手,我会优先处理两条:inbox 重试里静默的 firstError —— 因为它会让原始 bug 在受影响的机器上隐形复发 —— 以及未翻译的协议提示。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at b7934e39c60c8a3eb473f31a015d907e34cf3163 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 agent-view PTY host spawn has no inbox fallback for the bundled backend's synchronous throw — already reported (comment 5632316061)
  • R1-2 bundled-to-inbox retry discards firstError with no log or session flag — already reported (comment 5632316061)

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI, and every local build/test run happened on Linux where useConptyDll is inert, so no executed suite exercised this diff's win32 runtime behaviour.

Not explored to full depth (tool budget reached): "agent 1d": none — but the win32 @lydell/node-pty prebuild is not installed in this Linux worktree, so the bundled backend's synchronous throw sites could only be establi….

Test Plan (not a blocker): src/services/web-terminal-registry.test.tsno such file or directory; src/services/conpty-host.test.tsno such file or directory; src/agent-view/pty-host.test.tsno such file or directory.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI, and every local build/test run happened on Linux where useConptyDll is inert, so no executed suite exercised this diff's win32 runtime behaviour.

未探索到全部深度(达到工具调用预算):"agent 1d"none — but the win32 @lydell/node-pty prebuild is not installed in this Linux worktree, so the bundled backend's synchronous throw sites could only be establi…

Test Plan(非阻断):src/services/web-terminal-registry.test.tsno such file or directory; src/services/conpty-host.test.tsno such file or directory; src/agent-view/pty-host.test.tsno such file or directory

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment thread packages/core/src/services/conpty-host.test.ts Outdated
Comment thread packages/core/src/services/web-terminal-registry.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Patrol-Run: qwen-pr-conflict/jmtx6sqi9yb

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • bundled-to-inbox retry discards firstError with no log or session flag — already reported (triage issue comment 5632316061, point 3)

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI, and every local build and test run happened on Linux where useConptyDll is inert, so no executed suite exercised this diff's win32 runtime behaviour.

Not reviewed: test-efficacy probe — unmeasured (harnessValidated: null): the probe runner tripped this repo's vitest globalSetup prerequisite guard and produced no parseable JSON, so no revert, mutant or hunk measurement ran; the run is neither validated nor refuted and clean coverage is not claimed.

Not explored to full depth (tool budget reached): "agent 4": reading node-pty's Windows agent source ( windowsPtyAgent / conpty.cc ) to confirm or refute a post- CreateProcess throw site — the package is not vendored in …; "agent reverse-audit (round 1)": could not verify the bundled backend's Windows internals against source — @lydell/node-pty-linux-x64 ships only unixTerminal.js , so windowsTerminal.js 's …; "agent reverse-audit (round 1)": did not trace who consumes the agent-view bridge's stdout ( pty-host-process.ts:478 → supervisor-process.ts:2173 → client), so whether any xterm.js answers….

Test Plan (not a blocker): src/services/web-terminal-registry.test.tsno such file or directory; src/services/conpty-host.test.tsno such file or directory; src/agent-view/pty-host.test.tsno such file or directory.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/cli/src/agent-view/pty-host.test.ts:371 — [review] agent-view suite pins only the win32 half of the new platform branch — flattening pty-host.ts:356 to an unconditional true leaves 37/37 green, while both sibling suites pin both si…
  • packages/core/src/services/web-terminal-registry.test.ts:56 — [review] shared win32 PTY fake still reports _useConptyDll: false, so the live-release path is covered only in its inbox fallback shape and line 526 pins a dispose count producti…
  • packages/core/src/services/conpty-host.test.ts:52 — [review] the 8-line WindowsPtyAgent internals fixture is retyped in all four behavioural cases; two assert only not.toHaveBeenCalled(), so a copy missed on a node-pty field rename degrades…
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI, and every local build and test run happened on Linux where useConptyDll is inert, so no executed suite exercised this diff's win32 runtime behaviour.

未审查(原文为英文):test-efficacy probe — unmeasured (harnessValidated: null): the probe runner tripped this repo's vitest globalSetup prerequisite guard and produced no parseable JSON, so no revert, mutant or hunk measurement ran; the run is neither validated nor refuted and clean coverage is not claimed.

未探索到全部深度(达到工具调用预算):"agent 4"reading node-pty's Windows agent source ( windowsPtyAgent / conpty.cc ) to confirm or refute a post- CreateProcess throw site — the package is not vendored in …"agent reverse-audit (round 1)"could not verify the bundled backend's Windows internals against source — @lydell/node-pty-linux-x64 ships only unixTerminal.js , so windowsTerminal.js 's …"agent reverse-audit (round 1)"did not trace who consumes the agent-view bridge's stdout ( pty-host-process.ts:478 → supervisor-process.ts:2173 → client), so whether any xterm.js answers…

Test Plan(非阻断):src/services/web-terminal-registry.test.tsno such file or directory; src/services/conpty-host.test.tsno such file or directory; src/agent-view/pty-host.test.tsno such file or directory

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.23.3)

Comment thread packages/core/src/services/web-terminal-registry.ts
yiliang114 and others added 2 commits September 12, 2026 04:54
The six `@lydell/node-pty*` pins live in three unsynchronized places — the
root manifest, packages/core's manifest, and a hardcoded map in
scripts/prepare-package.js — but the conpty-host tripwire asserted only
one key of one manifest, so bumping the win32 prebuild keys or the root
manifest left it green and skipped the human re-check it exists to force.

Derive the six pins in writeDistPackageJson from packages/core's manifest
(deleting the third hardcoded table), and widen the tripwire to assert all
six core keys plus an equality against the root manifest's six — the
declaration packages/cli's agent-view actually resolves in a dev tree.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtxennouyn
…eb terminal

Switching the web-terminal registry to the bundled ConPTY backend let a
probing shell's DA / DSR query bytes into the recorded scrollback; a
reconnect that replays the buffer made the client's xterm.js re-answer
each query and write the fresh reply back into the still-live shell's
stdin. The bundled backend answers no queries itself, and the browser is
not guaranteed to be attached when the startup probe fires.

Mirror the other half of the shell path (#11497): on win32, feed a
headless terminal the PTY stream so it answers the probe server-side, and
strip the query bytes from both the scrollback and the live stream so a
replay cannot re-emit them. Also bring the releasePtyResources docstring
in line with the bundled backend (R1-8).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtxennouyn
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 205 passed · 0 failed · 205 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:205 通过 · 0 失败 · 205 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11643 — fix(core): run web terminal PTYs on the bundled ConPTY backend

Verdict: findings — 205/205 scripted assertions passed (0 unexpected failures); the central claim is proven load-bearing by A/B, but the new query-responder half carries four measured behavioural findings and three coverage gaps, none of them blockers.
Verified head: 9e54d5ab3bbfe8486199ff827b030fae2b915018 (git rev-parse HEAD^2). Base: aaa6a32aae4e932b4f510d3562ce8b7d605cdd51 (HEAD^1).

中文摘要
  • 结论findings。205/205 脚本断言通过;核心声明(Windows 上 web terminal / agent-view 用 bundled ConPTY 后端 spawn,bundled 同步抛错时回退 inbox 重试一次、非 Windows 不重试)经 A/B 证实为 load-bearing。
  • A/B 结论:真实 PTY + 真实 shell + 真实 @xterm/headless 的端到端实验中,base 与「仅切后端」两个对照臂把 DA/CPR 探测字节留在 scrollback 与 live listener 中且不回包;head 臂服务端回包 ESC[?1;2c / ESC[2;1R(真实 shell 的 stdin 确实收到),且探测字节从 scrollback 与 live listener 中消失(132→119 字节),良性内容(SGR、擦除、光标移动、alt-screen、CJK、astral)逐字节不变。
  • Findings:(1) 应答器固定 80x24 且从不随 registry.resize() 调整,CPR 用过期网格回答而客户端再也收不到该请求(实测 ESC[3;1R vs 客户端尺寸下的 ESC[2;1R);(2) 应答集合不是剥离集合的子集——DECRQSS ESC[<d>$p 被应答但未被剥离,导致双份回答注入 shell stdin;(3) 跨 chunk 拆分的探测不被剥离,同样产生双份回答,而注释把「单 chunk 送达」当作无证据的不变量;(4) 注释「剥离绝不会丢弃渲染内容」不成立——作为内容打印的 DA 响应 ESC[?62c 会被剥离;(5) 覆盖缺口:strip 的 n 终字节、应答器 dispose、prepare-package.js 的六个 node-pty pin 均无测试钉住(后者所在套件断言了 sharp/audio-capture 却零条 @lydell 断言)。
  • 未覆盖:Windows 原生行为(conhost/OpenConsole 计数、真实 conpty.dll 加载失败路径、pwsh 延迟、交互渲染)在 Linux 容器内不可测;agent-view 路径仅经其单测与 M9 变异验证;未做对当前 main 的 trial merge(shallow、无网络)。

Central claim and A/B

Central claim. On Windows the web-terminal registry and the agent-view PTY host spawn with node-pty's bundled ConPTY backend (useConptyDll: true), retrying exactly once on the inbox backend when the bundled spawn throws synchronously, and never retrying off Windows; POSIX behaviour is unchanged.
Secondary claim A. The new headless-xterm responder answers bundled-backend terminal queries server-side and scrubs them from both the scrollback and the live listener stream.
Secondary claim B. scripts/prepare-package.js derives the six @lydell/node-pty* tarball pins from packages/core/package.json with no change to what ships today.

Arms: v0 = base registry (HEAD^1 source, esbuild-transpiled), v1 = backend switch + inbox retry with the responder/strip half surgically reverted, v2 = PR head source (esbuild), v2dist = PR head as tsc built it (packages/core/dist, unmodified). v2v2dist on every scenario, which is the fidelity control for the transpile. Variants are swapped in by an ESM resolve hook over the real dist tree; the only substituted dependency is getPty().

H1 — spawn-option / retry matrix (recording fake pty, forced platform)

scenario v0 (base) v1 v2 / v2dist
win32, spawn ok 1 call, no useConptyDll key 1 call, true 1 call, true
win32, bundled throws 1 call, created (predicate can't fire) 2 calls [true,false], created 2 calls [true,false], created
win32, both throw 1 call, error 2 calls, error 2 calls, error
linux, spawn throws 1 call, error 1 call, error (no retry) 1 call, error (no retry)
linux, spawn ok 1 call, no key 1 call, false 1 call, false

20 assertions per arm × 4 arms = 80/80 pass. The flip is on the option the pty layer receives and on the retry count; both are exactly the change under test.

H2 — end-to-end over a REAL PTY (01-ab-real-pty-query-responder-base-vs-head.png)

Real @lydell/node-pty child, real /bin/sh running a generated target that emits ESC[c + ESC[6n as PTY output, benign fixtures, and a DA response as literal content; platform forced to win32 (the registry's only gate — the POSIX prebuild ignores useConptyDll). Three independent oracles: a file the shell itself captures from its stdin, a recorder on the real pty process's write(), and the listener stream + readSnapshot() (the reconnect replay).

arm responder writes into the PTY what the shell's stdin received ESC[c / ESC[6n in scrollback & live stream
v0 (base) 0 (empty) PRESENT / PRESENT
v1 (backend only) 0 (empty) PRESENT / PRESENT
v2 (head) 2: ESC[?1;2c, ESC[2;1R ESC[?1;2c + ESC[2;1R absent / absent
v2dist (head, tsc) 2: same same absent / absent

27 assertions per arm × 4 arms = 108/108 pass, including zero-collateral: ESC[31mRED ESC[0m, ESC[2K, ESC[1;1H, ESC[?25l, ESC[?1049h/l, 中文, 😀 survive byte-identically on every arm. Scrollback shrinks 132 → 119 bytes at head; the 13 removed are ESC[c (3) + ESC[6n (4) + ESC[?62c (6) — the last of which is Finding 4.

H3 — probe set vs strip set (real @xterm/headless, measurement table, no scripted assertions)

19 probes: the responder answers 8; the strip removes 9; one sequence is answered but not stripped (ESC[1$p, DECRQSS → reply ESC[1;0$y). Disproved scarier readings: DA3 (ESC[=c, ESC[=0c) is not answered by xterm.js, so no double answer there; DA1's answer is size-independent.

H4/H5 — the two invariants the new comments assert — 10/10 pass

  • H4: a single-chunk probe is stripped; the same probe split across two chunks leaves residue in both the scrollback and the live listener while the responder still answers it (3 writes: two DA answers + one CPR) — i.e. the double-answer the strip exists to prevent returns whenever the single-chunk assumption fails.
  • H5: registry.resize(200,50) returns true but the responder's CPR answer is unchanged (ESC[3;1R before and after); a 200×50 terminal fed the identical 150-char line answers ESC[2;1R. The responder is pinned at 80×24 and never resized.

H6 — pin derivation — 7/7 pass

Derived set and versions are byte-equal to the deleted hardcoded table (six keys, all 1.2.0-beta.10); a simulated core-manifest bump propagates to the emitted pin while the platform pins stay behind, which is the behaviour the new comment promises. scripts/tests/package-assets.test.js: 37/37 pass at head.

Corrections

  • The PR body states the core gate is "42/42". Running exactly the command it names gives 44/44 (web-terminal-registry.test.ts 39 + conpty-host.test.ts 5); the cli gate is 37/37 as stated. This is a correction to the description only — the code is fine.

Findings

F-1 (Suggestion, measured) — the responder answers CPR from a stale 80×24 grid and the client never sees the request

web-terminal-registry.ts constructs the responder with hardcoded cols: 80, rows: 24 and never resizes it; registry.resize() resizes only the PTY. Because the strip removes the query from the live listener stream as well, the browser's xterm.js can no longer answer it correctly either. Measured (H5): after registry.resize(200,50), the responder still answers ESC[3;1R for output a 200×50 client would place at row 2 (ESC[2;1R). Any Windows program that issues CPR (PSReadLine being the named motivator for the n final byte) now receives a position computed on a grid that is neither its own nor the client's. The precedent this PR mirrors, shellExecutionService.ts, sizes its headless terminal from the real cols/rows.
Reproduce: node --import <artdir>/register.mjs <artdir>/h4h5-chunks-and-geometry.mjs (H5 block).

Minimal suggested fix (not applied; measured only in the mutation matrix as F2-survived)

Size the responder from the same cols/rows the spawn options use and resize it inside resize(). Note the suite is green with and without this change (F2 survived 44/44), so it must ship with a fixture that feeds a >80-column line and asserts the CPR row follows resize() — that fixture does not exist today.

F-2 (Suggestion, measured) — the responder's answer set is not a subset of the strip set (DECRQSS)

H3 shows ESC[1$p (DECRQSS) is answered by the responder (ESC[1;0$y) but is not removed by stripTerminalQueries, whose regex only matches finals c and n. The raw request therefore reaches the client, whose xterm.js answers it a second time; both replies are written into the still-live shell's stdin — precisely the double-answer the strip was added to prevent, one door down from the doors it closes.
Reproduce: node <artdir>/h3-probe-vs-strip.mjs (row DECRQSS ESC[1$p true false … ANSWERED-BUT-NOT-STRIPPED).
Disproved sibling: DA3 (ESC[=c) is not answered by xterm.js at all, so the same asymmetry there is harmless.

F-3 (Suggestion, measured shape / unproven trigger) — a probe split across two chunks escapes the strip and double-answers

The strip's comment states "node-pty delivers these 3-6 byte probes as a single chunk" as an invariant. H4 shows the invariant is load-bearing: ESC[ + c in two chunks leaves ESC[c in both the scrollback and the live listener while the responder still answers it, so the shell receives two DA answers. This reproduces the shape under controlled chunking; whether ConPTY ever splits a 3-byte probe in production is not established here (it is exactly the kind of coalescing assumption that holds until a load spike). Either the strip must be chunk-boundary-safe (carry a trailing partial sequence across chunks) or the comment should say what enforces single-chunk delivery.
Reproduce: H4 block of h4h5-chunks-and-geometry.mjs.

F-4 (nit, measured) — the strip's stated invariant "can never drop rendered content" is false for responses

ESC[?62c is a DA response, not a request, and the regex strips it. H2 emits one as literal content: at head it is absent from both the live stream and the scrollback (6 of the 13 removed bytes), while the surrounding text survives. Practical impact is small (a captured session log replayed through cat loses DA/CPR response bytes), but the comment's absolute claim is what a future reader will rely on.
Reproduce: H2, assertion DA response printed as content (ESC[?62c) reaches the client (expected absent at head by design — measured, and reported here as the contradicted invariant).

F-5 (Suggestion, measured) — three unpinned surfaces, including the six shipped node-pty pins

Mutation matrix (02-mutation-matrix-killed-vs-survived.png; control 44/44 core + 37/37 cli green, 8 positive kills so the harness is live):

mutant edit result
M1 useConptyDll: useBundledfalse KILLED (2 red, correct tests)
M2 delete the inbox retry KILLED (2 red, correct tests)
M3 buffered = stripTerminalQueries(data)data KILLED (1 red, correct test)
M4 stop feeding the responder KILLED (1 red, correct test)
M5 strip regex [cn][c] SURVIVED 44/44
M6 never construct the responder KILLED (1 red, correct test)
M7 delete session.queryTerminal?.dispose() SURVIVED 44/44
M8 drop proc.write(reply) KILLED (1 red, correct test)
M9 cli useConptyDllfalse KILLED (1 red, correct test)
M10 bump pinned @lydell/node-pty in core manifest KILLED (pin tripwire)
M11 derivation filter @lydell/node-pty@lydell/node-pty- SURVIVED 37/37
F1 FIX CANDIDATE: also strip DECRQSS SURVIVED 44/44
F2 FIX CANDIDATE: responder 200×50 + scrollback: 0 SURVIVED 44/44
  • M5 is a coverage gap, not dead code: H2 proves the n branch is load-bearing (its removal would leave ESC[6n in the scrollback), yet no test asserts CPR stripping — the very case the comment names (PSReadLine's cursor-position request).
  • M7 is a coverage gap on the responder's teardown: releasePtyResources() does dispose it (verified by reading), so in a PR whose entire purpose is a per-terminal leak, the new per-terminal object's own release is unpinned.
  • M11 is the sharpest of the three: package-assets.test.js asserts distPackageJson.optionalDependencies for sharp and the audio-capture package but contains zero @lydell assertions, so the six pins that decide which conpty.dll/OpenConsole.exe real Windows users get can silently change shape with the suite green.
  • F1/F2 surviving is the unpinned-axis signal for Findings 1 and 2: the fixtures that would pin them (a DECRQSS double-answer case; a >80-column CPR case around resize()) do not exist.

Not covered

  • Windows native behaviour: real conhost.exe --headless/OpenConsole.exe accounting, the real bundled conpty.dll load/unload throw path, COMSPEC=powershell first-output latency, and interactive rendering (vim, Ctrl+C, resize, CJK input). This container is Linux; the win32 prebuild is not installed and @lydell/node-pty selects its prebuild at import time. My forced-win32 harnesses verify the registry's decisions (what it passes, what it does on a synchronous throw, what it does with the output stream), not the native backend. The PR itself lists these as manual Windows acceptance.
  • Agent-view path end-to-end: packages/cli/src/agent-view/pty-host.ts is one line plus an interface field; verified via its own suite (37/37) and M9 (killed), not by an A/B of the worker path.
  • Per-commit attribution: the checkout is depth 2; only 9e54d5ab of the four PR commits is reachable (cce1866c, c253fd9d, 73dd42fc are not). The aggregate HEAD^1..HEAD diff is what was verified. The v1 arm was therefore reconstructed by surgically reverting the responder half (each revert asserted to apply exactly once), not taken from a commit.
  • Base drift: the metadata snapshot's baseRefOid (78bbd9f5…) is not present in this shallow checkout; per the merge-ref contract I used HEAD^1 (aaa6a32a), which is what the merge commit was built against.
  • Trial merge into current main and repo-wide gates: not possible (shallow, no network) / out of scope by design.
  • ReDoS ladder on stripTerminalQueries: not run. The pattern has a single unnested star (linear), and its input is local program output rather than remote attacker text; noted so the cap is explicit.
  • Mutation matrix and H3 are not counted in assertions.json: the matrix's classifier and H3's table are scripted measurements without encoded pass/fail expectations, so only H1 (80), H2 (108), H4/H5 (10) and H6 (7) contribute to the 205/0 totals.

Methodology

Linux node:22 container, CI merge-ref checkout (HEAD = merge, HEAD^1 = base tip, HEAD^2 = PR head); npm ci/npm run build pre-existing at head. Arms are the real tsc-built packages/core/dist (or esbuild transpiles of base/head/intermediate sources, proven equivalent by the v2v2dist columns) with an ESM resolve hook redirecting getPty.js and, per arm, web-terminal-registry.js; variants were copied beside the shipped file so their relative imports resolve identically, and removed afterwards. H2/H4/H5 force os.platform()/process.platform to win32 — the registry's only gate for the bundled backend — while using the real POSIX node-pty prebuild, real shells and the real @xterm/headless responder; H2's target script is written with literal ESC bytes so no shell escape rules are in play. Targeted gates: packages/core web-terminal-registry.test.ts + conpty-host.test.ts (44/44), packages/cli pty-host.test.ts (37/37), scripts/tests/package-assets.test.js (37/37). Raw logs, harness sources and the mutation matrix live in logs/ (h1-*.log, h2-*.log, h3-probe-vs-strip.log, h4h5.log, h6-pin-derivation.log, mutants/*.log, mutation-matrix.txt, gate-*.log); evidence images in evidence/.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/cli/src/agent-view/pty-host.test.ts: (cd packages/cli) npx --no-install vitest run ./src/agent-view/pty-host.test.ts
file packages/core/src/services/conpty-host.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/conpty-host.test.ts
file packages/core/src/services/web-terminal-registry.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/web-terminal-registry.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/agent-view/pty-host.test.ts: PPPPP
  packages/core/src/services/conpty-host.test.ts: PPPPP
  packages/core/src/services/web-terminal-registry.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 1 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 1 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 2 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 2 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 2 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 3 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 3 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 3 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 4 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 4 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 4 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 5 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 5 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 5 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)

Evidence images

01-ab-real-pty-query-responder-base-vs-head

02-mutation-matrix-killed-vs-survived

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 9e54d5ab3bbfe8486199ff827b030fae2b915018 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 9e54d5ab3bbfe8486199ff827b030fae2b915018既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 326 passed · 0 failed · 326 total

Flakiness gate: ✅ 3 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:326 通过 · 0 失败 · 326 总计

抖动门:✅ 3 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11643 — fix(core): run web terminal PTYs on the bundled ConPTY backend

Verdict: findings — 326/326 scripted assertions passed (0 unexpected failures). The central claim is proven load-bearing by A/B, and every finding from the previous round reproduces. This round adds two new measured findings the previous round did not report — a leaked responder on the failed-spawn path, and a second, unguarded await in create() that defeats cancellation and dispose() — plus one nit. None is a blocker.

Verified head: 9e54d5ab3bbfe8486199ff827b030fae2b915018 (git rev-parse HEAD^2). Base: aaa6a32aae4e932b4f510d3562ce8b7d605cdd51 (HEAD^1). Merge: 8189a3204efbe8235f362b198109dceddb14102b, tree edb62128e9dd0d0ec4f30e5eed45390006d0bf72.

This is a re-verification of an identical commit. The previous round verified head 9e54d5ab against base aaa6a32a; both OIDs — and therefore both trees — are unchanged. Nothing in the previous report could have been fixed by construction. Per the re-measure rule I rebuilt every arm and re-ran every carried-forward measurement rather than diffing the old report; all reproduced, and two of them came back broader than previously stated (F-1, F-2).

中文摘要
  • 结论findings。326/326 脚本断言通过(0 个意外失败)。核心声明经 A/B 证实为 load-bearing;上一轮的全部发现均复现。本轮新增两个上一轮未报告的实测发现——失败 spawn 路径上泄漏的应答器,以及 create() 中第二个未加守卫的 await 使取消与 dispose() 失效——外加一个 nit。均非阻塞项。
  • 本轮为同一 commit 的复验:head 9e54d5ab、base aaa6a32a 与上一轮完全相同,故树亦相同,上一轮发现在构造上不可能已被修复。按「重新测量而非对比旧报告」的要求,重建了全部对照臂并重跑了每一项沿用测量;全部复现,其中 F-1、F-2 的范围比上一轮所述更宽
  • A/B 结论:见「Central claim and A/B」的 H1/H2 表。真实 PTY + 真实 /bin/sh + 真实 @xterm/headless 下,base 与「仅切后端」(v1) 两臂把 DA/CPR 探测字节留在 scrollback 与 live 流中且不回包(v1 的 scrollback 与 base 逐字节相同,证明改动全部来自应答器/剥离这一半);head 两臂服务端回包 ESC[?1;2c 与 CPR,真实 shell 的 stdin 确实收到了这些字节(14B,与 registry 写入 PTY 的字节完全相等),探测字节从两条流中消失(181→168B,差值 13B 恰为 3+4+6,逐字节归因完毕),良性内容零附带损伤。
  • 新发现:(N-1) 两次 spawn 都抛错时,已构造的 headless 应答器既不入库也不 dispose —— 5 次失败尝试泄漏 5 个,registry 侧完全不可达;这正是本 PR 要修的那一类泄漏。(N-2) PR 在 cancelledCreations / disposed 两个守卫之后新增了 await loadXtermHeadless(),落在该窗口的 dispose() 会让 create() 在已 dispose 的 registry 上返回成功、PTY 永不被杀、且因 scheduleReclaim 回调带 !this.disposed 守卫而永不回收(无界);落在该窗口的 release() 返回 true 表示「已取消」但会话照样建立(有界,15 分钟 idle reclaim)。已用无任何替身的 real 模式复现(真实冷 import + 一次 setImmediate 让出)。(N-3) 应答器构造失败被完全静默吞掉,该文件无任何 logger。
  • 沿用的发现:F-1 stands 且更严重(应答器从未被 resize,0 次调用;光标在自有 80 列网格上持续累积,偏差是递增而非固定);F-2 stands 且更宽(19 个探测的普查发现两个「应答但不剥离」的形式,上一轮只报了一个);F-3 stands(并新界定为只会漏剥、绝不损坏内容);F-4 stands;F-5 stands(新增组合行 M14 亦存活 ⇒ M7 是真实覆盖缺口而非冗余防御;新增同文件阳性对照 M16 已被杀死 ⇒ M11 的存活有意义)。
  • 未覆盖:Windows 原生行为(conhost/OpenConsole 计数、真实 conpty.dll 加载失败、pwsh 首输出延迟、交互渲染)在 Linux 容器内不可测;逐 commit 归因(depth 2,4 个 commit 中仅 1 个可达);对当前 main 的 trial merge 与仓库级门禁;agent-view 路径仅经其单测验证。

Previous-finding status

# finding severity status at head 9e54d5ab
F-1 responder answers CPR from a stale 80×24 grid; the client never sees the request Suggestion stands — worse than reported. Re-measured (H5): registry.resize(200,50) returns true and the PTY receives [200,50], while the responder receives 0 resize calls of any geometry. Because its cursor keeps accumulating on its own 80-column grid, the divergence compounds rather than staying at a fixed offset: the same 150-char line answers ESC[2;71R fresh, ESC[4;61R on the second feed, where a real 200×50 client says ESC[1;151R. The client can no longer answer it itself — the query is stripped from the live stream too.
F-2 answer set is not a subset of the strip set (DECRQSS) Suggestion stands — broader than reported. A 19-probe census against the real @xterm/headless finds two answered-but-not-stripped forms, not one: ESC[1$pESC[1;0$y and ESC[?1;2$pESC[?1;2$y. Same code as the previous round; the wider probe set found the sibling. Both produce the double answer the strip exists to prevent.
F-3 a probe split across two chunks escapes the strip and double-answers Suggestion stands, and is now bounded. Re-measured for both ESC[+c and ESC[6+n: residue survives in the scrollback and the live listener while the responder still answers (1 reply each). Newly bounded in the safe direction: the failure is false-negative only — a benign SGR split as ESC[3 + 1mRED ESC[0m passes through byte-intact, so a chunk boundary can lose a strip but can never corrupt rendered content.
F-4 "stripping can never drop rendered content" is false for responses nit stands. ESC[?62c is a DA response; the shipped regex strips it. H2 emits one as literal content and accounts for it exactly: scrollback 181 → 168 B, the 13 removed being ESC[c (3) + ESC[6n (4) + ESC[?62c (6), with DARESP_STARTDARESP_END surviving.
F-5 three unpinned surfaces (M5 strip n final byte, M7 responder dispose, M11 pin derivation) Suggestion stands, sharpened. M5, M7, M11 all survive again. New combination row M14 (delete both responder teardown lines) also survives 44/44 — so M7 is a genuine coverage gap, not redundant defence masked by a sibling hunk. New same-file positive control M16 (mutate @qwen-code/audio-capture in the very optionalDependencies literal the @lydell spread lands in) is KILLED 1/37, which is what makes M11's survival meaningful rather than an artifact of a dead suite.
PR body says the core gate is "42/42" correction stands. Running exactly the command the body names gives 44/44 (web-terminal-registry.test.ts 39 + conpty-host.test.ts 5). The cli gate is 37/37 as stated, and scripts/tests/package-assets.test.js is 37/37.

Central claim and A/B

Central claim. On Windows the web-terminal registry and the agent-view PTY host spawn with node-pty's bundled ConPTY backend (useConptyDll: true), retrying exactly once on the inbox backend when the bundled spawn throws synchronously, and never retrying off Windows; POSIX behaviour is unchanged.
Secondary claim A. A headless-xterm responder answers bundled-backend terminal queries server-side and scrubs them from both the scrollback and the live listener stream.
Secondary claim B. scripts/prepare-package.js derives the six @lydell/node-pty* pins from packages/core/package.json with no change to what ships.

Arms: v0 = base source (HEAD^1), v1 = head with only the responder/strip half surgically reverted (8 anchored replacements, each asserted to apply exactly once; backend switch + retry kept), v2 = head source via esbuild, v2dist = head exactly as tsc built it (packages/core/dist, unmodified). v2v2dist on every H2 observable, which is the transpile fidelity control. conpty-host.ts differs between base and head only inside /** */ blocks, so sharing head's build of it across arms is clean.

H1 — spawn-option / retry matrix (raw: logs/h1.log)

scenario v0 (base) v1 / v2 / v2dist
win32, spawn ok 1 call, no useConptyDll key 1 call, true
win32, bundled throws 1 call (predicate cannot fire — base sets no option) 2 calls [true,false], created
win32, both throw 1 call, Failed to spawn shell 2 calls [true,false], Failed to spawn shell
linux, spawn throws 1 call, error 1 call, false, error — no retry
linux, spawn ok 1 call, no key 1 call, false

108/108 pass. The flip is on the option the pty layer receives and on the retry count — exactly the change under test.

H2 — end-to-end over a REAL PTY (01-ab-real-pty-base-vs-head.png)

Real @lydell/node-pty child, real /bin/sh running a generated target (driven via COMSPEC, since the registry spawns file with args: []), real @xterm/headless responder, platform forced to win32 at the registry's own gate. Three independent oracles: a file the shell captures from its own stdin (destination side of the wire), a pass-through recorder on the real UnixTerminal prototype's write() (registry side), and the listener stream + readSnapshot() (the reconnect replay).

arm replies written into the PTY shell's own stdin capture ESC[c / ESC[6n in scrollback & live scrollback
v0 (base) 0 0 B PRESENT / PRESENT 181 B
v1 (backend switch only) 0 0 B PRESENT / PRESENT 181 B — byte-identical to base
v2 (head, esbuild) 2: ESC[?1;2c, ESC[3;10R 14 B, equal to what the registry wrote absent / absent 168 B
v2dist (head, tsc) 2: same 14 B, same absent / absent 168 B

79/79 pass. The v1 row is the load-bearing half of the A/B: switching the backend alone changes the output stream not at all, so the entire behavioural delta belongs to the responder/strip half. Zero collateral asserted per arm on ESC[31mRED ESC[0m, ESC[2K, ESC[1;1H, ESC[?25l, ESC[?1049h/l, 中文, 😀, and the text surrounding each stripped query. Residual fully accounted: 181 − 168 = 13 = 3 + 4 + 6, and the 6 is F-4.

Harness-validity assertions ran first on every arm (FIXTURES_DONE seen, scrollback and live stream non-empty), so the cells cannot pass vacuously. Two harness defects were caught by those checks and fixed before the numbers above were taken: POSIX redirects a backgrounded command's stdin from /dev/null, and GNU timeout puts its child in a new process group where a tty read draws SIGTTIN — either alone silently yields a 0-byte capture that looks like "the responder answered nothing".

H6 — pin derivation — 14/14 pass

The derived set is byte-equal to the deleted hardcoded table (six keys, all 1.2.0-beta.10), and the root manifest's six equal core's six — which is what the widened tripwire asserts. A simulated bump of @lydell/node-pty-win32-x64 propagates to the emitted pin while the other five stay behind; a seventh key would be picked up automatically and a removed key drops out.

Findings

N-1 (Suggestion, measured) — the responder headless Terminal leaks on the failed-spawn path

create() constructs queryTerminal before the spawn try. When both the bundled and the inbox spawn throw, it returns { error: 'Failed to spawn shell' } without storing the Terminal in a session and without disposing it — so it is unreachable and never released. In a PR whose entire purpose is that "every exited web terminal leaked ~8 MB for the life of the CLI", this reopens the same class on the exact path the PR introduces the retry for.

Measured (H7, 36/36 pass, 03-h7-responder-leak-on-failed-spawn.png):

scenario constructed disposed leaked
both spawns throw — v0 / v1 0 0 0 (nothing to leak)
both spawns throw — v2 / v2dist 1 0 1
5 failed create() attempts on head 5 0 5, and readSnapshot() is undefined for every id — none reachable
success then release() — v2 / v2dist 1 1 0
bundled throws, inbox retry succeeds 1 1 0
linux, spawn ok (all arms) 0 0 0

The success and retry rows are the positive control: the probe can observe disposal, so disposed=0 on the failure path is a measurement, not a dead instrument. Note the retry path itself is fine — the leak is only on the both-fail path.

Blast radius, bounded: this needs Windows with a missing/unloadable bundled conpty.dll and a failing inbox backend, and it scales with failed attempts, not with successful terminals. What makes it more than theoretical is that a client retrying a failed open leaks one Terminal per attempt, forever.

Reproduce: bash <artdir>/run-harness.sh h7-responder-leak.mjs

Minimal suggested fix (measured: applied as mutant M12, suite stayed 44/44 green)
    } catch {
      queryTerminal?.dispose();
      this.finishCreating(terminalId);
      return { error: 'Failed to spawn shell' };
    }

M12 surviving 44/44 is the unpinned-axis signal: the suite cannot tell head from head-plus-fix. It must ship with a fixture — extend the existing reports a spawn failure and frees the id when both backends fail test with a dispose spy on the responder. That fixture does not exist today.

N-2 (Suggestion, measured) — create() gained a second await after its cancellation guards, so cancellation and dispose() can be silently lost

Base create() has exactly one await after this.creating.set(terminalId, …):

ptyImpl = await getPty();
if (this.cancelledCreations.has(terminalId)) { …return cancelled }
if (this.disposed)                          { …return disposed }

The PR inserts a second await after both guards and never re-checks either:

const { Terminal: HeadlessTerminal } = await loadXtermHeadless();

An interrupt landing in that window is never observed. Measured (H8, 62/62 pass, 02-h8-cancel-dispose-race-base-vs-head.png), one timed interrupt driving all arms:

interrupt arm spawned at interrupt time create() result session registered after PTY killed after the idle-reclaim window
dispose() v0 base yes (1) success false 1 gone
dispose() v1 backend-only yes (1) success false 1 gone
dispose() v2 / v2dist head no (0) success, on a disposed registry true 0 still registered, still not killed
release() v0 / v1 yes (1) success false 1 gone
release() v2 / v2dist head no (0) success, though release() returned true true 0 released by the idle reclaim

Two distinct consequences, ranked by observability:

  • dispose() → unbounded. The session is registered after dispose() already iterated this.sessions, and scheduleReclaim's callback is guarded by !this.disposed, so the 15-minute reclaim fires and deliberately does nothing. Nothing ever releases this PTY, its conout worker, or its responder (constructed 1 / disposed 0). Verified empirically, not by reading: with long timers scaled 1000×, the session is still registered and still unkilled after the reclaim window.
  • release() / releaseWorkspace() → bounded but contract-breaking. release() returns true, i.e. "cancelled", while a live PTY is spawned holding the drained workspace's cwd. The idle reclaim does eventually release it (measured gone after the scaled window), so this is 15 minutes, not forever.

Reproduced with no substitution at all. The real rows use the genuine load-xterm-headless.js and a single await new Promise(setImmediate) — no delay double, no timing margin. Base's only await resolves in microtasks once node-pty is warm, so base has already spawned; head's cold import('@xterm/headless') is real file I/O still pending on the first immediate turn. Window validity is asserted on both sides of every cell (callsAtInterrupt 0 for head, 1 for base), which is what caught an earlier harness defect where a memoized double made only the first arm pay the delay.

Window width, measured: cold @xterm/headless import 18.9–21.2 ms over 5 samples (logs/xterm-import-cost.log), against 8.6 ms for the warm getPty() await that base does guard — so the unguarded window is roughly 2× wider than the guarded one it sits behind. Honest bound: loadXtermHeadless() memoizes, so the wide window occurs once per CLI process, on the first Windows web terminal. Afterwards the await is a microtask and only another in-flight microtask could interleave.

Attribution: v1 is immune to both interrupts, so the cause is the new await, not the backend switch.

Real callers, so the trigger is not hypothetical: stopWebTerminalRegistrydispose() (packages/cli/src/serve/server.ts:993, stopAppResource at :3537, acp-http/index.ts:2491/2497/2569), releaseWebTerminalsForWorkspacereleaseWorkspace() (server.ts:995), and registry.release(terminalId, workspace.workspaceCwd) on the WebSocket release-only/close/error paths (routes/terminal.ts:150, 203, 219, 225, 247, 273). Opening the first web terminal tab and closing it immediately — or a daemon shutdown / workspace drain landing at that moment — is the whole trigger.

Reproduce: node <artdir>/h8-driver.mjs

Minimal suggested fix (measured: applied as mutant M13, suite stayed 44/44 green)

Re-check both guards after the new await, releasing the responder it may have just built:

    if (this.disposed || this.cancelledCreations.has(terminalId)) {
      queryTerminal?.dispose();
      this.finishCreating(terminalId);
      return { error: 'Web terminal creation cancelled' };
    }

M13 surviving 44/44 means the suite pins nothing along this axis either. The fixture that would go red: a create() whose responder import is still pending when release()/dispose() runs, asserting no session is registered and no PTY is spawned. Note the fix should return the disposed error when this.disposed, to match base's two distinct messages.

N-3 (nit, measured) — the responder-construction failure is swallowed with no trace

      } catch {
        // No responder available: the query stays unanswered (a bounded ~2s
        // stall), never injected — the strip below still keeps it out of the
        // scrollback.
        queryTerminal = undefined;
      }

Following the value: grep -n "logger\|console\.\|debug(" over packages/core/src/services/web-terminal-registry.ts returns a single hit, and it is the word "console" inside a prose comment at line 350 — there is no logging call in the file at all, and no field records that the responder is absent. So the documented degradation is unobservable — in production, in devtools, and to any future reader trying to work out why a Windows terminal stalled ~2 s on startup. The fail-open itself is the right call; only its silence is the finding. This harness hit it by accident: an early version of the counting double threw inside loadXtermHeadless(), and the registry absorbed it so completely that the only symptom was "responder constructed 0", with no error anywhere.

F-1 … F-5 (standing, re-measured)

See the status table above. Each was re-measured at this head rather than carried forward on the strength of the identical OID; the harnesses are h3h4h5-responder-surface.mjs (F-1 via H5, F-2 via H3, F-3 via H4, F-4 via H2/H3) and the mutation matrix (F-5). The strip function under test is lifted verbatim out of the shipped tsc build, not retyped, and the lifted regex literal is itself asserted to be /\x1b\[[?0-9;>]*[cn]/g.

Mutation matrix (04-mutation-matrix-killed-vs-survived.png)

Controls green first, or the kills mean nothing: control-core 44/44, control-scripts 37/37.

mutant edit result red test(s)
M1 useConptyDll: useBundledfalse KILLED (2) "spawns Windows terminals with the bundled ConPTY backend", "retries a failed bundled spawn once on the inbox backend"
M2 delete the inbox retry KILLED (2) "retries a failed bundled spawn…", "reports a spawn failure and frees the id when both backends fail"
M3 buffered = stripTerminalQueries(data)data KILLED (1) "answers the bundled-backend DA probe and keeps it out of the scrollback"
M4 stop feeding the responder KILLED (1) same
M8 drop proc.write(reply) KILLED (1) same
M5 strip regex [cn][c] SURVIVED 44/44 coverage gap — H2 proves the n branch load-bearing
M7 delete session.queryTerminal?.dispose() SURVIVED 44/44 coverage gap on responder teardown
M14 delete both teardown lines (combination row) SURVIVED 44/44 ⇒ M7 is a real gap, not layered defence
M11 derivation filter → '@lydell/node-pty-' SURVIVED 37/37 the six shipped pins are unpinned
M16 mutate @qwen-code/audio-capture in the same object literal KILLED (1) positive control proving the scripts suite is live
M12 FIX CANDIDATE for N-1 SURVIVED 44/44 unpinned axis
M13 FIX CANDIDATE for N-2 SURVIVED 44/44 unpinned axis

Every kill names the test that should catch it, and all five live in web-terminal-registry.test.ts — the same file as the M5/M7/M14 survivors, so those survivals are not an artifact of the runner collecting the wrong file. For the scripts suite my first control attempt (M15, bumping @teddyzhu/clipboard) survived, so I landed M16 inside the very optionalDependencies literal the @lydell spread is emitted into; M16 was killed, which is what licenses the M11 claim.

5 killed / 6 survived. Completeness reporting, not merge conditions — except M12/M13, which are the reason N-1 and N-2 must ship with fixtures.

Scaling ladder on stripTerminalQueries (closes a gap the previous round listed as not run)

The pattern has one unnested star, and the ladder confirms it is linear rather than merely arguing so:

shape 2 k 3 k 5 k 20 k
ESC[ + digits, no final byte 0.007 ms 0.006 ms 0.007 ms 0.024 ms
many unterminated ESC[ runs 0.010 ms 0.011 ms 0.016 ms 0.065 ms
realistic mixed output 0.004 ms 0.005 ms 0.007 ms 0.028 ms

Worst rung 0.065 ms at ~19 k characters — no superlinearity, nothing near the 30 s cap. Input here is local program output rather than remote attacker text, so this was a completeness check, not a hazard hunt.

Not covered

  • Windows native behaviour: real conhost.exe --headless / OpenConsole.exe accounting, the real bundled conpty.dll load/unload throw, COMSPEC=powershell first-output latency, and interactive rendering (vim, Ctrl+C, resize, CJK). Only @lydell/node-pty-linux-x64 is installed here (ls node_modules/@lydell/), and node-pty selects its prebuild at import time. My forced-win32 harnesses verify the registry's decisions — what it passes, what it does on a synchronous throw, what it does with the output stream — not the native backend. This reproduces the shape of the Windows path, not the leak the PR was filed against. The PR itself lists these as manual Windows acceptance.
  • Agent-view path end-to-end: packages/cli/src/agent-view/pty-host.ts is 5 added lines and 0 removed — one interface field plus one useConptyDll: process.platform === 'win32' option and its comment. Verified via its own suite (37/37) and by reading, not by an A/B of the worker path.
  • Per-commit attribution: depth 2. git rev-list HEAD^1..HEAD^2 yields 1 commit while the metadata commits array holds 4 (cce1866c, c253fd9d, 73dd42fc unreachable), so the aggregate HEAD^1..HEAD diff is what was verified. v1 was reconstructed by surgically reverting the responder half — each of the 8 replacements asserted to apply exactly once — not taken from a commit.
  • Base drift: the snapshot's baseRefOid (78bbd9f5…) is not present in this shallow checkout; per the merge-ref contract I used HEAD^1 (aaa6a32a), which is what the merge commit was built against.
  • Trial merge into current main and repo-wide gates (lint, typecheck, full suites): not possible (shallow, no network) / out of scope by design. The flakiness gate is the workflow's, not this round's; I did not re-run it.
  • The dispose()-race consequence beyond one session: I proved one leaked PTY per raced create(). I did not measure how many can accumulate, or drive it through the real HTTP/WebSocket routes.
  • Mutation matrix, the H3 probe census and the H5 drift measurement are not counted in assertions.json: they are scripted measurements without encoded pass/fail expectations. Only H1 (108), H2 (79), H3/H4/H5 (27), H6 (14), H7 (36) and H8 (62) contribute to the 326/0 totals.

Methodology

Linux node:22-bookworm container, CI merge-ref checkout (HEAD = merge, HEAD^1 = base tip, HEAD^2 = PR head), npm ci and npm run build pre-existing at head. Arms are esbuild transpiles of base / intermediate / head source written beside the shipped packages/core/dist/src/services/web-terminal-registry.js so their relative imports resolve identically, plus the unmodified tsc build as v2dist; build-arms.mjs rebuilds them and cleanup-arms.mjs archives and removes them. H1/H7/H8 drive the arms through an ESM resolve hook (register.mjs + arms/hooks.mjs) that redirects only getPty.js to a recording fake and, where stated, load-xterm-headless.js to a double that subclasses the real @xterm/headless Terminal to count construct/dispose/resize without changing its behaviour. H2 and H8's real mode use no double in the causal path: real node-pty, real /bin/sh, real xterm. H2/H7/H8 force os.platform() and process.platform to win32 — the registry's only gate for the bundled backend — after importing the POSIX prebuild, since node-pty picks its prebuild at import time. Fake PTY pids are 1 on purpose: release() calls killPtyTree(), whose POSIX arm signals real pids when pid > 1. Targeted gates: packages/core web-terminal-registry.test.ts + conpty-host.test.ts (44/44), packages/cli pty-host.test.ts (37/37), scripts/tests/package-assets.test.js (37/37). Raw logs, harness sources, arm sources and per-mutant vitest output live in logs/ (h1.log, h2.log, h3h4h5.log, h6.log, h7.log, h8.log, gates.log, xterm-import-cost.log, mutation-matrix.log, mutation-matrix-parsed.log, mutants/*.log, mutants/matrix.json); evidence images in evidence/.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/cli/src/agent-view/pty-host.test.ts: (cd packages/cli) npx --no-install vitest run ./src/agent-view/pty-host.test.ts
file packages/core/src/services/conpty-host.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/conpty-host.test.ts
file packages/core/src/services/web-terminal-registry.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/web-terminal-registry.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/agent-view/pty-host.test.ts: PPPPP
  packages/core/src/services/conpty-host.test.ts: PPPPP
  packages/core/src/services/web-terminal-registry.test.ts: PPPPP

verdict: pass
summary: 3 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 1 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 1 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 2 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 2 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 2 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 3 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 3 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 3 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 4 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 4 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 4 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 5 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 5 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 5 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)

Evidence images

01-ab-real-pty-base-vs-head

02-h8-cancel-dispose-race-base-vs-head

03-h7-responder-leak-on-failed-spawn

04-mutation-matrix-killed-vs-survived

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — the Windows half, on a real Windows machine

I verified this locally as a maintainer, deliberately aimed at the one thing the PR lists as not validated: "Windows real-machine acceptance (conhost/OpenConsole counting, pwsh latency, interactive rendering) — needs a Windows environment; PR CI skips the Windows job." The sandboxed verification above also had to leave exactly that uncovered ("This container is Linux").

So I ran the PR on a real windows-2022 runner (Windows Server 2022 Datacenter 10.0.20348.5499, Node 22.23.2, the pinned @lydell/node-pty@1.2.0-beta.10), driving this tree's own WebTerminalRegistry, plus the local macOS stack for the POSIX side.

Verdict: LGTM — the central claim holds on real Windows, and the fix is exactly as large as advertised. Three non-blocking follow-ups below; two of them independently reproduce findings the sandbox report already filed.


1 · The leak, and its absence, measured on Windows

18 web terminals opened and exited through the real registry (3 rounds × 6, each shell exits naturally), counting conhost.exe/OpenConsole.exe parented to the daemon process:

Windows registry A/B

The same A/B at the raw backend level (6 PTYs, no registry) isolates it to the backend rather than to anything in this diff, and the interactive arms answer the rest of the manual acceptance list — COMSPEC=powershell first output 107 ms / prompt ready 1212 ms, echo round-trip ok under both cmd.exe and powershell.exe:

Backend A/B and queries

Two results in that figure are worth calling out:

  • R2-1 was a real defect and the new responder/strip half really fixes it. With the strip mutated off, a Device-Attributes probe (ESC[c) does reach the registry on the bundled backend and lands in the replayable scrollback — under both cmd and PowerShell. With the PR as written: zero on every arm. That is the first confirmation on the actual platform (the sandbox had to synthesize the probe from a script under a forced win32).
  • The ~2 s DA stall did not reproduce here. With the responder mutated off, the PowerShell prompt was still ready in 877 ms. On this host (Windows PowerShell 5.1 / Server 2022) the responder is not buying latency — it is insurance for a shell that does block on the answer. Not an objection; just don't cite the latency argument as measured on this configuration.

Also: the PR's own unit tests actually executed on Windows — the platform PR CI skips. web-terminal-registry.test.ts + conpty-host.test.ts 42 passed / 2 skipped, pty-host.test.ts 35 passed / 2 skipped. No Windows-only breakage.

2 · POSIX is unchanged, and the built product still works

npm ci && npm run build && npm run bundle at head, then the real qwen serve Web Shell in a browser: two live web terminals, colour/bold/CJK/vim intact, a full page reload replays the scrollback and the terminal keeps streaming afterwards.

macOS real stack

Against a real PTY and a real shell, head·native ≡ base·native on every observable, and the new behaviour appears only behind the win32 gate (2 × 2 matrix in the next figure). useConptyDll is also literally absent from the POSIX prebuild — it appears only in node-pty.d.ts, and spawning with it on macOS is accepted and inert. Feeding every byte through the extra parser cost nothing measurable on a 4 MB burst (1824 ms vs 1823 ms).

3 · Do the new tests hold the new behaviour down?

10 mutations, each applied alone, then the PR's own suites re-run — 7 killed, 3 survived:

Test discrimination

Two of the survivors are the same gaps the sandbox report filed (responder dispose; the prepare-package.js derivation). One is new: stripping only from the scrollback while still emitting the raw query to live listeners passes 44/44. That property is not cosmetic — it is what stops the browser's xterm.js from answering the same probe a second time into the still-live shell.

4 · Follow-ups (none blocks the merge)

Findings

  1. The responder never follows the client's resize — independently reproduced (same defect as F-1 in the sandbox report). In my lane: after registry.resize(id, 120, 24) the shell sees 120 columns, prints 100 characters, asks ESC[6n, and is answered ESC[8;21R — wrapped at 80. The precedent this PR mirrors already does both halves: shellExecutionService.ts:2615-2616 resizes the PTY and the headless terminal.
  2. The derived pin table has no test of its own — corroborates F-5/M11. Driving the real writeDistPackageJson() from both trees: today's dist manifest is byte-identical base vs head ✅, a core-manifest bump propagates at head ✅, and a broken derivation ships 0 of 6 pins with test:scripts 37/37 green. The sharp derivation two lines above throws when it cannot resolve; this one is silent.
  3. scrollback: 0 on the responder — measured, 8 responders each fed 3000 lines at 80 cols, post-warm-up RSS: 2.76 MB per live session (22.1 MB at the 8-terminal cap) vs 0.05 MB with scrollback: 0. Tiny next to the ≈6.2 MB × unbounded this PR removes, but it is per live terminal on the same platform, and a query responder never needs history.

5 · The description has drifted from the head commit

9e54d5ab3b introduces a headless xterm into the registry, but the body still says under Risk & Scope: "A theoretical ~2s first-output stall … is accepted: no headless xterm is introduced into the registry." The Evidence section still says "N/A", and the Tested-on table still marks Windows ⚠️. Worth a refresh before merge — and Windows can now be ticked with the numbers above if you want them in the body.

Environment, harnesses and how to reproduce
  • Windows lane: GitHub-hosted windows-2022, full npm ci at PR head, harnesses bundled with esbuild (@lydell/node-pty and @xterm/headless left external, i.e. loaded exactly as the product loads them) and run on plain node — tsx segfaults with node-pty on that runner (exit 139). The BASE arm is the identical harness after git checkout 78bbd9f55c -- packages/core/src packages/cli/src scripts. Hosts counted through Get-CimInstance Win32_Process filtered to children of the daemon process. Workflow + harnesses: probe/pr11643-conhost on my fork; run 34657592687 (all steps green, raw JSON in the probe-11643 artifact).
  • macOS lane: macOS 26.0 / Node 24.18.1, two isolated worktrees at 78bbd9f55c and 9e54d5ab3b, npm ci in head and the same node_modules cloned into base (the PR changes no lockfile). Gates re-run: npm run typecheck exit 0, eslint + prettier --check clean on all 7 changed files, core 44/44, cli 37/37.
  • Emulated-win32 lane (for the platform-independent half of the new logic): os.platform() is flipped to 'win32' before the registry module loads, so the Windows-gated branches run against a real /bin/bash PTY on macOS. Used for the 2 × 2 matrix and the resize/CPR demonstration.
  • Mutations were applied one at a time to a committed tree and reverted from a file copy, never with git checkout.
中文版

维护者本地验证 —— 在真实 Windows 机器上补齐 Windows 那一半

我以维护者身份在本地做了一轮验证,目标明确对准 PR 自己列为未验证的那一项:「Windows 真机验收(conhost/OpenConsole 计数、pwsh 延迟、交互渲染)—— 需要 Windows 环境;PR CI 跳过 Windows job」。上面那份沙箱验证同样把这块留空了("This container is Linux")。

所以我在真实的 windows-2022 runner(Windows Server 2022 Datacenter 10.0.20348.5499、Node 22.23.2、pin 的 @lydell/node-pty@1.2.0-beta.10)上跑了本仓库自己的 WebTerminalRegistry,POSIX 侧则用本机 macOS 真实栈。

结论:LGTM —— 核心声明在真机上成立,修复范围与描述一致。 下面三条后续项均不阻塞合并,其中两条与沙箱报告的发现相互独立复现。

1 · Windows 上实测到的泄漏与其消失

通过真实 registry 开/关 18 个 web terminal(3 轮 × 6,每个 shell 自然退出),统计挂在 daemon 进程下的 conhost.exe/OpenConsole.exe

裸后端层面的同款 A/B(6 个 PTY,不经 registry)说明泄漏来自后端本身而非本 diff 的任何代码;交互臂回答了手工验收清单的其余部分 —— COMSPEC=powershell 首输出 107 ms、prompt 就绪 1212 ms,cmd 与 powershell 下 echo 往返均正常。

其中两点值得单独说:

  • R2-1 是真实缺陷,新增的应答器/剥离这一半确实修好了。 把 strip 变异掉之后,DA 探测(ESC[c)确实会在 bundled 后端下抵达 registry 并进入可重放的 scrollback —— cmd 和 PowerShell 都是;按 PR 现状则各臂皆为 0。这是该结论第一次在真实平台上被确认(沙箱只能在强制 win32 下用脚本合成探测)。
  • ~2 s 的 DA 卡顿在这里没有复现。 把应答器变异掉之后,PowerShell 的 prompt 仍在 877 ms 就绪。在这个宿主(Windows PowerShell 5.1 / Server 2022)上应答器并没有带来延迟收益,它是给「真的会阻塞等待应答的 shell」买的保险。这不是反对意见,只是别把延迟论据当作已在该配置下实测。

另外,PR 自带单测这次真的在 Windows 上跑过了(PR CI 跳过的那个平台):web-terminal-registry.test.ts + conpty-host.test.ts 42 通过 / 2 跳过,pty-host.test.ts 35 通过 / 2 跳过,无 Windows 专属失败。

2 · POSIX 行为不变,打出来的产物照常可用

head 上 npm ci && npm run build && npm run bundle,再起真实 qwen serve Web Shell 用浏览器操作:两个 live web terminal,颜色/加粗/中文宽度/vim 均正常;整页刷新后 scrollback 正确重放且终端继续实时输出。

对真实 PTY + 真实 shell,head·native 与 base·native 在每一个可观测量上完全一致,新行为只出现在 win32 门后(见 2 × 2 矩阵)。useConptyDll 在 POSIX 预编译产物中根本不存在(只出现在 node-pty.d.ts),在 macOS 上带该选项 spawn 会被接受且无效果。多一个 parser 吃掉每个字节,在 4 MB 突发下没有可测开销(1824 ms vs 1823 ms)。

3 · 新增测试能否钉住新行为

10 个变异逐个施加后重跑 PR 自带套件:7 个被杀、3 个存活。其中两个与沙箱报告所报的缺口相同(应答器 dispose;prepare-package.js 的派生)。有一个是新的:只从 scrollback 剥离、仍把原始 query 发给 live listener,44/44 全绿。 这条性质并不只是好看 —— 它正是阻止浏览器端 xterm.js 对同一探测第二次作答、把回包注入仍然活着的 shell 的那条性质。

4 · 后续项(都不阻塞合并)

  1. 应答器不跟随客户端 resize —— 独立复现(与沙箱报告的 F-1 同一缺陷)。我这边:registry.resize(id, 120, 24) 后 shell 看到 120 列,打印 100 个字符再问 ESC[6n,得到的回答是 ESC[8;21R —— 在 80 列处折行了。本 PR 对齐的那个实现两半都做了:shellExecutionService.ts:2615-2616 同时 resize PTY 和 headless terminal。
  2. 派生出来的 pin 表没有自己的测试 —— 与 F-5/M11 相互印证。用两棵树里真实的 writeDistPackageJson() 跑:今天的 dist manifest base 与 head 逐字节相同 ✅;core manifest 提升版本时 head 会跟着走 ✅;而派生逻辑本身被改坏时会一个 pin 都不发test:scripts 仍然 37/37 全绿。上面两行的 sharp 派生解析不到会抛错,这个则是静默的。
  3. 给应答器加 scrollback: 0 —— 实测:8 个应答器各喂 3000 行 80 列内容、预热后按 RSS 计,每个 live 会话 2.76 MB(8 终端上限下 22.1 MB),设 scrollback: 0 后为 0.05 MB。相对本 PR 消掉的「≈6.2 MB × 无上限」不值一提,但它是按 live 终端计、且落在同一个平台上,而 query 应答器本来就不需要历史。

5 · PR 描述已与 head commit 脱节

9e54d5ab3b 恰恰往 registry 里引入了 headless xterm,而正文 Risk & Scope 仍写着:「~2s 首输出卡顿……予以接受:registry 未引入 headless xterm;Evidence 一节仍是 "N/A",测试平台表里 Windows 仍是 ⚠️。建议合并前刷新一下 —— Windows 这一行现在可以用上面的数据打勾了。

(复现环境与方法见英文正文折叠块;证据图托管在 wenshao/qwen-codeassets-pr11643 分支。)

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yiliang114 — needs one change before this can ship. Details in my Stage 2 and Stage 3 notes above.

Blocking: the headless query responder added in 9e54d5ab is constructed at 80x24 (web-terminal-registry.ts:275-279) and never resized. WebTerminalRegistry.resize() (:547-556) resizes only session.pty, while the precedent this PR mirrors resizes both together (shellExecutionService.ts:2615-2616). Because stripTerminalQueries removes CSI c/n from the live listener stream too (:316, :337), the correctly-sized browser xterm.js can no longer answer a CPR — so the stale 80x24 grid is the only answer, and it is wrong for essentially the whole session: TerminalPanel.tsx:167-168 fits and sends a resize on every mount, forwarded at serve/routes/terminal.ts:279. One line in resize() (session.queryTerminal?.resize(cols, rows)) plus a win32 CPR-after-resize case.

Everything else is notes, not blockers: the stale "no headless xterm is introduced into the registry" line in Risk & Scope, the unbounded responder scrollback (xterm default 1000 vs the shell path's 200), the three silent catches where the shell path logs, and prepare-package.js deriving to {} without throwing.

R2-1, R1-6 and R1-8 are all verified fixed at this commit — that work is good.

中文说明

合并前需要改一处,细节见上方我的 Stage 2 和 Stage 3 备注。

阻断项: 9e54d5ab 新增的 headless 查询应答器以 80x24 构造(web-terminal-registry.ts:275-279),且从不 resize。WebTerminalRegistry.resize():547-556)只 resize 了 session.pty,而本 PR 对标的先例是两者一起 resize(shellExecutionService.ts:2615-2616)。由于 stripTerminalQueries 也会从实时监听流里剥掉 CSI c/n:316:337),尺寸正确的浏览器 xterm.js 再也无法应答 CPR——于是那个过期的 80x24 网格成了唯一应答者,而且几乎在整个会话期间都是错的:TerminalPanel.tsx:167-168 在每次挂载时自适应并发送 resize,由 serve/routes/terminal.ts:279 转发。修复是 resize() 里的一行(session.queryTerminal?.resize(cols, rows)),外加一个 win32 下 resize 后 CPR 的用例。

其余都是备注,不是阻断项:Risk & Scope 里那句过期的「registry 未引入 headless xterm」、应答器无上限的 scrollback(xterm 默认 1000,shell 路径是 200)、shell 路径有日志而这里三处静默 catch、以及 prepare-package.js 在不抛错的情况下派生成 {}

R2-1、R1-6、R1-8 在当前 commit 均已确认修复——这部分做得很好。

Qwen Code · qwen3.8-max-2026-09-02

wenshao
wenshao previously approved these changes Sep 12, 2026
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge September 12, 2026 00:24
@yiliang114

yiliang114 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closeout at b7934e39c60c: the approved R2-1 correction is pushed to the existing branch. The intervening d8975fa color-query fix and its regression test were preserved; there was no history rewrite or replacement PR.

The registry no longer rewrites terminal bytes. Its Windows responder forwards only primary DA, and snapshot metadata tells the browser when that probe is server-owned. A new PTY's initial output can receive replies. Reconnect history restores into a detached terminal with no forwarding listener; the old visible terminal keeps accepting input, and the snapshot callback swaps views and enables replies before queued live output parses. Both sides reject incompatible replay protocols explicitly. This applies to Web Shell on all hosts, not only VS Code; daemon and Web Shell must update together.

Verification:

  • 93 focused tests passed: registry 43, WebSocket route 28, TerminalPanel 22. Scoped ESLint, Prettier and diff checks passed.
  • Five real-browser checks mounted the actual modified TerminalPanel with React 19.2.4, xterm 6.0.0 and FitAddon 0.11.0 in Chromium 149.0.7827.55. Only surrounding contexts and WebSocket transport were controlled. Fresh output produced six color/mode/DCS replies, plus exactly one DA only when browser-owned.
  • During delayed reconnect restoration, a passive observer saw 14 native historical responses but the socket received none. A real keyboard k arrived once through the old terminal; the queued live batch then produced seven replies once. A stale callback ran after disposal without replacing the newer view. Legacy unmarked output failed with 4002. No browser errors.
  • Package typechecks did not pass: project-reference build declarations are stale relative to existing source APIs, including this round's new field. No unrelated source changes, dependency installation or full rebuild were used to conceal that limitation. Browser/component evidence does not establish full daemon/PTY integration or native Windows process cleanup.

Scope integrity (changed lines = additions + deletions, including comments/scripts; not pure production-logic LOC):

Original reviewed baseline Previous ledger Current
Implementation files / changed lines 3 / 57 4 / 244 6 / 306
Test files / changed lines 3 / 323 3 / 687 5 / 738
Design files / changed lines 0 / 0 0 / 0 2 / 107

The two added implementation files are the existing terminal WebSocket route and Web Shell component, both required by the explicitly approved replay contract. This additive commit touches three implementation files, +123/-131, replacing the handwritten filters with the boundary above; it does not add dependencies or extend agent view. The original baseline is preserved and the conservative round count is now five. Scope verdict: corrected under the recorded user approval, not a new baseline. Four obsolete tests were deleted and two payload/display cases merged into the exact-stream regression after independent deletion defense; the parallel color regression remains.

Feedback: R2-1 is addressed by this correction. The two overlapping color threads were already resolved by the parallel patrol; their correction was retained. Public onColor forwarding was not added because that API is unavailable in the pinned headless package. PR description claims about missing headless, unchanged protocol and passing local typechecks have been corrected.

Status snapshot: the pushed head is mergeable, but CI/review gates are still pending. Lint & Static, Ubuntu tests and no-AK integration were in progress; the native Windows unit job was skipped. No automated-review wait or merge was performed. Native Windows process counts, startup/DLL-fallback behavior and real-daemon/PTY acceptance remain outstanding; the existing inbox fallback is not claimed to fix the leak. No further Suggestions or unrelated agent-view changes are included in this round.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
chiga0
chiga0 previously approved these changes Sep 12, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Files reviewed

  • packages/core/src/services/web-terminal-registry.ts (+185/-16)
  • packages/core/src/services/web-terminal-registry.test.ts (+466/-43)
  • packages/core/src/services/conpty-host.ts (+10/-8)
  • packages/core/src/services/conpty-host.test.ts (+156, new file)
  • packages/cli/src/agent-view/pty-host.ts (+5)
  • packages/cli/src/agent-view/pty-host.test.ts (+22)
  • scripts/prepare-package.js (+14/-6)

Findings

No blockers found. All previously raised Critical findings (query scrub, resize forwarding, loadXtermHeadless cancellation, stripper/responder gate mismatch, DCS DECRQSS coverage, chunk-split handling) are resolved at HEAD.

Clean areas

  • ConPTY backend selection: useConptyDll: os.platform() === 'win32' is correct; bundled spawn with inbox fallback on failure is sound (failed synchronous spawn produces no child, no double-spawn risk).
  • TerminalQueryStripper regex correctness: Covers all xterm.js query families (DA1/2/3, DSR, DECRQM, DECRQSS, XTVERSION, DECREQTPARM, OSC colour queries). PARTIAL_ESCAPE_SUFFIX_RE correctly identifies viable query prefixes without swallowing non-query OSC title sets. Non-query escapes (SGR, erase, cursor) preserved.
  • Lifecycle management: releasePtyResources with ptyResourcesReleased flag prevents double-disposal. queryTerminal and queryReplyDisposable properly disposed. No resource leaks — all synchronous code between creation and sessions.set() prevents event-loop interleaving.
  • Concurrency: Each session has its own TerminalQueryStripper and queryTerminal instance. No shared mutable state between terminal sessions.
  • prepare-package.js: Derivation from packages/core/package.json optionalDependencies is correct. Version tripwire test asserts all 6 pins match across both manifests.
  • Platform gating: Off Windows, queryTerminal stays undefined, stripping is a no-op. Test-pinned.
  • Test coverage: Comprehensive — bundled backend spawn, DA probe answer/strip, split chunks, DCS DECRQSS (complete and split), OSC partial handling, non-query escape preservation, responder resize, cancellation during headless load, inbox retry on bundled failure, double-failure cleanup.

Needs human review

  • Windows CI skipped: windows-latest job was not run. Actual native ConPTY behavior, bundled-to-inbox fallback with real conpty.dll, and conhost.exe leak reclamation are unverified on real Windows.
  • @xterm/headless dependency: Dynamically imported on every Windows terminal creation. Verify it's in the published package's dependencies (not just devDependencies) — runtime import() requires it to be installed.

Reviewed with AI assistance.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COMMENT

核对基线:head 77257a5acc1d1e663f2b42433f0d3d21bb456bbf

本次不 Approve:唯一未解决的线程是一条 Critical(R2-1,web-terminal-registry.ts:112),作者已明确说明只修了其中两个症状、其余三个转入 #11734 并刻意不关闭该线程。我在当前 head 的代码上独立复核,确认其中症状 (3) 仍然成立。

已确认修好的部分:症状 (1) 与 (4)

我逐条对过 web-terminal-registry.ts:83-122,缓解措施与线程里给出的方案一致且实现正确:

  • pending 现在有上界,吞字节不会永不结束。 MAX_HELD_ESCAPE_CHARS = 256:110)配合 strip() 里的 const hold = partial !== null && partial[0].length <= MAX_HELD_ESCAPE_CHARS;:118)——超界时 pending 清空、complete 取整个 combined,也就是把累积内容当 payload 整体放行。原报告里「之后每个 chunk 都返回 ''pendingMAX_BUFFER_BYTES 之外持续增长、$ 锚定正则反复全量重扫」这条失效路径已经关掉。
  • OSC 挂起分支收窄成只hold 颜色查询前缀。 PARTIAL_ESCAPE_SUFFIX_RE 的 OSC 分支是 \x1b\](?:10|11|4;[0-9]+);\?(?:[^\x07\x1b\x9c])*:保留 ? 谓词(因此未终结的标题/颜色 SET 不会被误当查询挂起,也就不会被从两个出口丢掉)、字符类排除 \x1b(遇到裸 ESC 即结束,与 xterm 取消 OSC 的行为一致)、排除 \x9c(接受 C1 ST 作为终结符)。三点都对上了。
  • DCS 引导符已纳入挂起。 正则末尾新增的 |\x1bP\$?ESC PESC P $ 这两种落在引导符中间的切分也被hold,因此不会再两半漏进 session.buffer 重新拼成一个完整 DECRQSS 被浏览器二次应答。症状 (4) 关闭。

作者记录的红绿确认(移除 OSC 上界会让 does not swallow payload after an unterminated non-query OSC 变红、移除 DCS 分支会让 DECRQSS 用例变红,48/48 registry 测试通过)与我在代码上看到的一致。

仍然存在的阻塞:症状 (3) —— OSC 12 与多下标 OSC 4 逃过过滤,回放会把应答写进仍然存活的 shell

这一条我不是转述,而是在当前 head 的正则上直接核出来的。TERMINAL_QUERY_SEQUENCE_RE:83-85)的 OSC 分支是:

\x1b\](?:10|11|4;[0-9]+);\?(?:\x07|\x1b\\)

它只覆盖 OSC 10、OSC 11,以及单下标形式的 OSC 4,且要求 ;? 之后紧跟 BEL 或 ESC \。因此:

  • OSC 12(光标色查询)\x1b]12;?\x07 不在 (?:10|11|4;[0-9]+) 的候选里,原样通过 strip()
  • 多下标 OSC 4\x1b]4;0;?;1;?\x074;0;? 之后跟的是 ; 而不是 \x07\x1b\\,无法匹配,同样原样通过。

而按该 Critical 的实测,声明的客户端对这两者都会应答。于是它们会被记进 scrollback,每次重连回放都把它们重新应答进仍然存活的 shell 的 stdin —— 正是这个特性要消除的危害本身。这与「过滤清单在服务端手工枚举」是同一个根因:漏掉一族就漏掉一族,且漏掉的那一族恰好是会写回 stdin 的。

其余两个症状

  • 症状 (2)(OSC 10/11/4 从两个出口都被删掉,但 pin 住的 @xterm/headless 5.5.0 responder 结构上无法应答,导致 Neovim/bat/delta 的 ESC ] 11 ; ? 背景探测从「merge base 上浏览器会应答」变成「无人应答」):我没有独立复核 responder 的能力边界,按现有 GitHub 数据记为作者已承认、仍未关闭。若成立,这是一条明确回归。
  • 症状 (5)(responder 按自身 DECRQM 状态作答,36 个模式里 1 处与出货客户端分歧):影响面小,我未独立复核。

关于结论分歧

chiga0 已在同一 head(16:34:27Z)APPROVED。我的结论不同,原因是本渠道的判定规则:只要有一条历史阻塞问题在当前 head 上仍然存在,就不能 Approve——而症状 (3) 我已在代码上确认仍成立,且作者本人也说明 (2)/(3)/(5) 未被本次缓解关闭。把结构性修复拆到 #11734 是合理的工程安排,但它不改变「当前 head 上这条 Critical 的一部分仍在」这个事实。

CI:Lint & StaticTest (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox) 在当前 head 上均为 pass;review-pr pending,按策略不作为门禁。Test (windows-latest, Node 22.x) 为 skipped,而本 PR 的主题正是 Windows 上的 ConPTY 后端,真机确认仍欠着。

下一步

两个方向任选其一:

  1. 按线程里的结构性方案收口(也是我认同的方向):不再在服务端改写字节,而是只在回放期间抑制应答——serve/routes/terminal.ts 已有 CONTROL_FRAME_PREFIX 的服务端到客户端控制帧通道,把 snapshot 作为 { type: 'replay', output } 下发,在 TerminalPanel.tsx 里用 replaying = true; term.write(output, () => { replaying = false; }) 包住写入并在该标志期间丢弃 onData。这样现有与未来的所有查询族一次覆盖,两个正则和它们的逐角落测试都可以删掉;headless responder 保留给 bundled backend 的 DA 停顿,但收窄到它本来要解决的 DA 族,避免它按自身模式状态作答。
  2. 若本轮必须保留服务端过滤,则至少把 OSC 分支从手工枚举改成能覆盖 OSC 12 与多下标 OSC 4 的形式(例如按 OSC <数字>;? 的通用查询形态匹配,并保留 ? 谓词以免误删 SET),同时补上线程里点名的两个用例。

无论走哪条,请把症状 (2) 的回归一并给出结论:要么让 responder 能应答颜色查询,要么不要从客户端出口删掉这些查询。

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 77257a5acc (7 files, +858/-73) against ea10c604bf.

Verdict: the standing [Critical] R2-1 is stale at this head, and I could not reproduce any of its blocking witnesses. What is left is a wiring gap in the responder, which is non-blocking.

I transcribed the two regexes from this head and ran R2-1's own witnesses against them.

R2-1 witness as reported (at 1bd418b328) this head (77257a5acc)
\x1b]0;title, then visible, then more visible text\r\n listener payloads ["","",""], snapshot "" \x1b]0;titlevisiblemore visible text\r\n -- nothing is swallowed
DCS split after \x1bP, and after \x1bP$ the report measured leaks in that window both deliver AFTER; the \x1bP\$? alternative closes them

Symptoms (1) and (4) -- the two the report itself named as the urgent mitigation -- landed in f6c306adfd, which is one commit after the round-4 review at 1bd418b328. The thread is describing code that is no longer here, so it can be resolved.

One measured correction to that finding's evidence. Symptom (2) rests on grep -c "onColor(" returning 0 for the pinned headless build. That count is an artifact of the minified bundle: @xterm/headless 5.5.0 does ship the handlers -- setOrReportSpecialColor (x4) and setOrReportIndexedColor (x2) are present, and the OSC 4 / 10 / 11 / 12 handlers are registered. The real gap is narrower, and it is a channel mismatch:

// @xterm/headless 5.5.0, driven directly
t.write('\x1b]11;?\x07'); t.write('\x1b]10;?\x07'); t.write('\x1b]4;1;?\x07');
t.write('\x1b[6n');        t.write('\x1b[c');
// onData  = ["\u001b[1;1R", "\u001b[?1;2c"]   <- DSR + DA1 answered here
// onColor = []                                <- colour reports do not touch this channel

_setOrReportSpecialColor only calls this._onColor.fire(...); it never routes the reply through the data event. This registry subscribes to onData only (web-terminal-registry.ts:507), so the OSC 10/11/4 arm of the strip removes those queries from the browser's stream while the server-side responder cannot answer them on the channel it uses. Either also forward onColor into proc.write, or drop the colour arm from TERMINAL_QUERY_SEQUENCE_RE -- as it stands that is the one family that is stripped with no server-side answerer. (xterm 6.0.0's browser build takes the same _onColor path and the web-shell app has no onColor subscription, so I could not establish that main answered colour probes either; treat this as an unproven benefit rather than a demonstrated regression.)

Also confirmed at this head:

  • The earlier blocking item -- the responder constructed at 80x24 and never resized -- is fixed: resize() now carries session.queryTerminal?.resize(cols, rows).
  • R2-1 symptom (3) still stands: strip('\x1b]12;?\x07') and strip('\x1b]4;0;?;1;?\x07') return their input unchanged while \x1b]11;?\x07 is removed. Inconsistent within one family, and main is no worse, so it is a coverage gap rather than a regression.
  • The agent-view host sets useConptyDll: win32 with no inbox retry (packages/cli/src/agent-view/pty-host.ts:356) while the registry retries once. The description's reasoning -- a web terminal has no child_process fallback -- applies equally there, so either add the same single retry or say in a comment why the two sites differ.

None of these block merge.

Comment thread packages/core/src/services/web-terminal-registry.ts
Comment thread packages/core/src/services/web-terminal-registry.ts Outdated

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES

已核对 head 77257a5acc1d1e663f2b42433f0d3d21bb456bbf(vs merge-base ea10c604)。required 四项全部 success(Test (ubuntu-latest, Node 22.x)Lint & StaticIntegration Tests (no-AK, No Sandbox)web-shell E2E Smoke)。ConPTY 后端切换、创建期取消、退出期资源释放与打包那几块我复核没有发现问题。

拦的是线程 R2-1(未解决,且作者自己写明「(2)/(3)/(5) 未被本轮缓解关闭,留档在 #11734」)。先说清楚已确认修好的部分:PARTIAL_ESCAPE_SUFFIX_REweb-terminal-registry.ts:99)的 OSC 分支现在保留 ? 谓词、遇裸 ESC 即终止并接受 C1 ST \x9c,DCS 引导串 ESC P / ESC P$ 也被兜住。我在该 head 复跑基线 Tests 48 passed (48),并分别注入两个变异:把 OSC 分支退回「任何未终止的 ESC ]」、删掉 |\x1bP\$? 这一支 → 各自恰好打红 1 条具名用例,所以「吃掉渲染字节」和「chunk 边界分裂」两条性质确实已经闭合。

阻塞项:剥离集合与服务端应答器的能力不匹配(症状 (2)(3)(5),我实测)

应答器是 core 钉死的 @xterm/headless@5.5.0packages/core/package.json:69;实例 web-terminal-registry.ts:328,其 onData:506 回灌进 PTY)。我用本机这份 5.5.0 直接量各族查询的应答:

OSC 10;? / OSC 11;? / OSC 4;1;? / OSC 4;1;2;? / OSC 12;?  -> ""(完全不应答)
CSI 6n                                                     -> ESC [ 1 ; 1 R
DECRQM  ?2026;$p                                           -> ESC [ ?2026;0$y
DECRQSS  ESC P $ q ... ESC \                               -> ESC P 0 $ r ESC \

TERMINAL_QUERY_SEQUENCE_RE:85)剥掉的恰是 10|11|4;[0-9]+ 这三族颜色查询。于是这些探针既不再到达浏览器、服务端也答不出来,结果退化成代码里为「无应答器」写明的「查询被丢弃、最多等约 2 秒」——合并基线上它们本可得到应答。这是本 PR 引入的行为回归(Neovim / bat / delta 的背景色探测是直接触发方)。

同一处的另两点:OSC 12 与多索引 OSC 4;1;2;?... 不在交替式里,仍会原样落入 buffer 并在每次重连回放时被浏览器再应答进仍然存活的 shell stdin(即本功能要消除的害处对这两族依旧存在);而剥掉查询等于把回答者从「正在使用的客户端」换成另一份状态的 headless —— 我用随包的 @xterm/xterm@6.0.0 对同一输入实测得到 ESC [ ?2026;2$y(识别),服务端给的是 ;0(不识别),应用据此会判定同步输出不可用而放弃使用。

不必等 #11734 的结构化方案,最小修法即可:把剥离集合收窄到「这份应答器确实会答的族」,暂时让 10|11|4(以及本就未剥的 12、多索引 4)继续到达浏览器,恢复合并基线行为;或为这几族补上服务端应答并在用例里钉住。若决定维持现状,请至少把「颜色查询此后不再被任何一侧应答」与 OSC 12/多索引 4 的处理差异写进 :66-84 的注释与 docs/——现在那段注释把这三族描述成已由服务端职责覆盖,与实测不符。

一处缓解自身缺见证(同一线程,顺手补)

strip() 里的 partial[0].length <= MAX_HELD_ESCAPE_CHARS 整个删掉(即恢复 R2-1 症状 (1) 里「pending 无上限、每个 chunk 重扫整段累积」的形态)后,48 条用例全部仍绿:256 字符封顶这一条目前没有可失败的见证。请补一条:喂入超过该长度且始终以 ESC ] / ESC P 前缀结尾的输出,断言前缀之外的字节仍然落到 buffer 与 readSnapshot(),并确认去掉封顶会让它变红。

其余

另外 17 条线程(readSnapshot 回放不再携带查询、退出期 releasePtyResources、ConPTY 探测、prepare-package.js 打包面等)逐条对过当前 head,作者回复所述与代码一致,本轮不再重复提出。review-pr 在本 head 仍 pending,属评审机器人自身档,不计入门禁。

…nnot answer

The stateful stripper added here removed the OSC 10/11/12/4 colour queries from
both the scrollback and the live listener, but the pinned @xterm/headless 5.5.0
responder answers none of them: `onData` carries DA/DSR/DECRQM/DECRQSS only,
while `_setOrReportSpecialColor` reports on the internal `_onColor` emitter that
the headless Terminal does not expose (`term.onColor` is undefined) and that
nothing in this file subscribes to. Deleting the family from the browser's
stream as well left a probing program unanswered, where the browser's own
xterm.js 6.0.0 `_handleColorEvent` is the answerer and answered it at the merge
base.

Narrow both regexes to the families the responder actually answers (plus the
never-display-content DA3 / XTVERSION / DECREQTPARM trio that no build answers),
drop the half-covered colour arm from the partial matcher — so the strip set and
the doc block no longer disagree about what the colour family is — and correct
the doc block to state the split. Covered by a new test asserting the whole
family reaches both the live listener and the snapshot untouched, and that
nothing is written back; it fails if the colour arm is restored.

The colour queries that a reconnect replay re-answers are part of the
replay-suppression redesign tracked in #11734.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtyno0zh0n
Preserve raw PTY bytes and limit the Windows server responder to primary DA. Mark snapshots explicitly and restore history into a detached browser terminal while the old terminal continues forwarding user input. Activate live replies at the snapshot write callback, reject incompatible peers, and preserve the parallel color-query regression fix. Refs #11734.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114
yiliang114 requested a review from qqqys September 12, 2026 18:05
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head b7934e3, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

health-deep-with-session

field PR base (before) this PR (after)
activeWorkStaleMs 4 5

Qwen Code · serve A/B

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head b7934e3. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:

  • packages/web-shell/client/components/terminal/TerminalPanel.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 127 passed · 0 failed · 127 total

Flakiness gate: ✅ 5 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:127 通过 · 0 失败 · 127 总计

抖动门:✅ 5 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11643 — fix(core): run web terminal PTYs on the bundled ConPTY backend

Verdict: findings127 passed / 0 failed / 127 scripted assertions. No assertion failed and no blocker was found: the central claim is proven load-bearing by an end-to-end A/B over a real WebSocket, and six of the previous round's findings are now measurably fixed or genuinely superseded (P-1, N-1, N-2, F-1, F-2, F-4). findings rather than merge-ready because three non-blocking items are actionable: two guards this PR ships are unpinned by any test (mutation survivors M1, M11, each with a same-file positive control proving the runner could have caught them), and the new server-side responder carries a ~181 MB per-live-Windows-terminal heap cost that is real, attributed, bounded — and undocumented.

Verified head: b7934e39c60c8a3eb473f31a015d907e34cf3163 (git rev-parse HEAD^2). Base: b5c7635ff983b5930100c742fc4bec8cd0a03e86 (HEAD^1). Merge: 842b28dcb13823cd9861af6ae0afafa17f4c8099.

Targeted gates, all green: packages/core 48/48 (43 registry + 5 conpty-host) · packages/cli 65/65 (28 route + 37 pty-host) · packages/web-shell TerminalPanel 22/22 · scripts/tests/package-assets.test.js 37/37172 tests, 0 failures (logs/gates.log). Arm-integrity controls 14/14 (logs/build-arms.log). Repo-wide npm run typecheck exits 0 across every workspace (logs/typecheck.log) — which the PR body does not claim; see Corrections.

Follow-up round with a large delta, and a redesign. The previous report cited head 1bd418b3; head is now b7934e39. The delta is not incremental — it deletes the entire server-side escape-sequence stripper that the previous round's central finding (P-1) was about, and replaces it with a cross-package replay protocol: the server answers only primary DA, the browser owns colours/modes/geometry, and reconnect history is written into a terminal with no input forwarder. Per the re-measure rule I did not diff the old report: tmp/ held no prior artifact dir, the previous head 1bd418b3 and the previous base 3b2283ee are both unreachable in this depth-2 checkout, and the base moved again (3b2283eeb5c7635f), so the proven-identical-input-closure shortcut was not available for anything. All five arms and all three harnesses were rebuilt from scratch and every carried-forward measurement re-run. Where a number agrees with the previous round it is corroboration by an independent instrument, not a re-read.

中文摘要
  • 结论findings。127 通过 / 0 失败 / 127 项脚本断言。没有任何断言失败,也没有发现阻断性问题:核心声明已通过真实 WebSocket 上的端到端 A/B 证实为 load-bearing,上一轮的六项发现本轮实测已修复或已被重新设计取代(P-1、N-1、N-2、F-1、F-2、F-4)。之所以不是 merge-ready,是有三项非阻断问题值得处理:本 PR 引入的两个保护没有任何测试钉住(变异存活者 M1、M11,且各自都有同文件的正向对照,证明 runner 确实能测到该文件),以及新增的服务端应答器带来每个活跃 Windows 终端约 181 MB 的堆占用——真实、已归因、有边界,但未在 PR 或设计文档中记录。
  • A/B 结论:见「Central claim and A/B」的表与 01-ab-replay-base-vs-head.png。五臂对照(base/base 控制臂、head/linux、head/win32、旧客户端+新 daemon、新客户端+旧 daemon),全部走真实 ws 服务端 + 真实路由 + 真实 registry + 真实浏览器 @xterm/xterm 6.0.0 客户端。重连回放时:base 把 5 条历史探测应答(60 B)写回仍在运行的 shell,head 写回 0 条;而实时输出在两侧都仍被应答 5/5,恢复期间到达的实时输出也被应答且只应答一次。旧客户端连新 daemon → 4002 + 刷新提示 + 0 次 spawn;新客户端连旧 daemon → 明确报「协议已变更」并关闭。
  • 应答归属普查(上一轮明确说没做的那次完整普查,本轮做了):对 16 种探测逐一实测两个应答方。服务端 headless 5.5.0 应答 DA1/DA2/DSR/DECRQM/XTVERSION,但过滤器只放行 DA1;浏览器 xterm 6.0.0 在 handlesPrimaryDa=true抑制 DA1(DA2 ESC[>c 仍然应答,因为 registerCsiHandler({final:'c'}) 不匹配带 > 前缀的 CSI)。结论:base 会应答的每一种探测,head 上恰好有一个应答方,DA3/DECRQSS/DECREQTPARM 在两侧都无人应答(非回归)。
  • 上一轮问题的实测状态:P-1(未终结 OSC 吞掉全部输出)—— 过滤器已被整体删除,5 MB 阶梯下 head 与 base 的 readSnapshot() 逐字节相同(4,194,304 B,上一轮 head 只报 7 B),耗时 7.1 ms(上一轮 1185 ms)。N-1(失败 spawn 泄漏应答器)—— catch 里现在 dispose,5 次失败尝试 built 5 / disposed 5 / leaked 0(上一轮 5/0/5)。N-2(挂起点竞态)—— 仍已修复,且本轮带了正向对照臂(移除两处 re-check 后 K=3 泄漏 3 个 PTY 与 3 个应答器并报成功)。F-1(过期 80×24 网格)—— resize 转发已被移除,但唯一被转发的应答 DA1 与几何无关(80×24/200×50/13×7 全部 \x1b[?1;2c),且正向对照证明仪器能看出几何差异(100 列换行后 CPR 为 2;21R vs 1;101R)。F-4(DA 响应被当请求剥离)—— \x1b[?62c 现在原样保留。
  • 本轮新发现:S-1 M1 存活(N-1 的修复没有测试钉住);S-2 M11 存活(派生出的六个 node-pty pin 在 package-assets.test.js 里没有断言,整段删掉也是绿的);S-3 应答器堆占用(8 MB 输出后活跃会话比 POSIX 高约 181 MB,退出时全额释放到与 POSIX 相同的 12.99 MB,且 scrollback 4,128,778 B 完整保留)。
  • 明确撤回的三个自己的怀疑(都经实测否定,见 Corrections):DA2/DA3 会失去应答方;恢复窗口内到达的实时输出会丢应答;reply !== '\x1b[?1;2c' 这个跨版本字面量没有 tripwire(实际有:registry 测试用的是真实 @xterm/headless,且变异 M7 被杀)。
  • 未覆盖:Windows 原生行为(conhost/OpenConsole 计数、真实 conpty.dll 加载失败、交互渲染)——本轮所有 win32 结论都是「registry 的决策」层面,不是原生后端;真实浏览器/React 挂载(客户端逻辑用逐行断言过的镜像驱动真实 xterm,但不是真实 TerminalPanel.tsx);中断恢复的端到端驱动;对当前 main 的 trial merge;逐 commit 归因(快照 11 个 commit 中仅 1 个可达)。

Previous-finding status

# finding severity status at head b7934e39
P-1 unbounded pending carry: one unterminated OSC withholds every later byte, outside MAX_BUFFER_BYTES, at quadratic cost Suggestion FIXED — by removal, measured. The stripper no longer exists: PARTIAL_ESCAPE_SUFFIX_RE and TerminalQueryStripper match 0 times in both the head and the base bundles (arm-integrity control, logs/build-arms.log). Re-ran the same ladder after one unterminated \x1b]8;;https://example.com: head and base report byte-identical readSnapshot().output at every rung — 2,106 / 153,658 / 677,946 / 2,775,098 B — and at 8 MB fed both clamp at exactly 4,194,304 B. Emit time 7.1 ms vs base 6.3 ms (previous round: head 7 B, 1,185 ms). The 12 failures the previous round reported are gone with the mechanism that caused them.
N-1 responder headless Terminal leaks on the failed-spawn path Suggestion FIXED, measured. The catch now runs queryReplyDisposable?.dispose(); queryTerminal?.dispose();. Both-throw: built 1 / disposed 1 / leaked 0. Five failed attempts: built 5 / disposed 5 / leaked 0 and 0 reachable snapshots (previous round: 5 / 0 / 5). Positive controls prove disposed is observable: success-then-release and bundled-throws-then-inbox-succeeds each dispose exactly 1, and linux builds 0. But the fix is unpinned — see S-1.
N-2 second await after the cancellation guards defeats dispose()/release() Suggestion Still FIXED, and this round the positive control the previous round lacked was built. A dedicated registry-norecheck arm (head with the two post-loadXtermHeadless() re-check blocks surgically removed, anchored so the identical 4-space pair after getPty() survives) leaks: at K=3 dispose() it reports SUCCESS ×3, 3 registered, 3 spawns, 3 responders built, 0 disposed, 0 killed. Head at K=1 and K=3, for both dispose() and release(): correct error, 0 registered, 0 spawns, 0 responders. The suspension point is a harness-opened gate file, not a timer (gateWaitedMs 72–77 ms, spawnsAtInterrupt === 0 on both arms), so the window is proven valid on both sides of every cell.
N-3 responder-construction failure swallowed with no trace nit Stands, partially mitigated. Still await loadXtermHeadless().catch(() => undefined), and the file contains no logging call of any kind — the only match for console.|logger|debug( is the word "console" inside a comment on line 330. What changed: handlesPrimaryDa is now derived from session.queryTerminal's existence and travels to the client, so the degradation is observable on the wire (handlesPrimaryDa:false on Windows implies no responder) even though nothing logs it and no user-visible surface reports it.
F-1 responder answers CPR from a stale 80×24 grid Suggestion SUPERSEDED — the resize forwarding was removed, and that is safe. resize() now touches only session.pty. The single reply the registry forwards is primary DA, and DA1 is geometry-independent: \x1b[?1;2c at 80×24, 200×50 and 13×7. That is a measurement, not a tautology — the positive control wrote 100 columns and got CPR \x1b[2;21R at cols=80 (wrapped) vs \x1b[1;101R at cols=200 (not wrapped), so the instrument does see geometry dependence where it exists. The stale-grid CPR the previous round measured is now dropped by the filter rather than forwarded.
F-2 answer set is not a subset of the strip set Suggestion RESOLVED BY REDESIGN — and the full census the previous round said it had not re-run is now done. There is no strip set any more. Ownership is partitioned instead, and I enumerated both ends over 16 probes (logs/h0-responder-census.log, logs/probe-browser-xterm.log): for every probe the base build answered, head has exactly one answerer. See the census table below.
F-3 a probe split across two chunks escapes the strip Suggestion SUPERSEDED by removal. Nothing is stripped, so a split probe cannot escape a stripper. The design doc states this as intent ("PTY output and bounded scrollback retain their original bytes, including split escape sequences") and the P-1 ladder confirms it: head and base retain identical bytes at every rung. The hazard F-3 existed to prevent — a reconnect re-answering a reconstituted query into the shell — is now closed by the client side instead, and H2 proves it end-to-end (base 5 replies, head 0).
F-4 a DA response is stripped as if it were a request nit FIXED by removal, measured. \x1b[?62c emitted as literal display content survives verbatim in the snapshot on head, byte-identical to base ("DARESP_START\u001b[?62cDARESP_END", joined:false on both arms).
F-5 unpinned surfaces (6 mutation survivors) Suggestion Rebuilt from scratch and re-run — the strip path the old matrix targeted no longer exists. New matrix over the 11 guards this PR ships: 9 killed / 2 survived, every file restored byte-identically (sha256 verified), all four unmutated suites green first. Both survivors are reported as S-1 and S-2 with same-file positive controls.
PR body says the core gate is "42/42" correction SUPERSEDED — the description no longer states a count. It now says "Focused registry, WebSocket-route and terminal-component tests passed". For the record, the exact command the old body named gives 48/48 at this head (it was 42/42 as written, 44/44, then 51/51 at 1bd418b3; the count moves with the suite, which is why naming it in a PR body is fragile).

Central claim and A/B

Central claim (this round's delta). A reconnect no longer re-answers historical terminal queries into the still-live shell. The server marks the snapshot boundary with a {type:'snapshot', replay, handlesPrimaryDa} control frame before any binary output; the browser restores history into a freshly created terminal with no input forwarder, keeps the old visible terminal accepting keyboard input until the swap, and suppresses its own primary-DA answer when the server owns it.

Secondary claim A (carried). On Windows the registry spawns with useConptyDll: true, retrying exactly once on the inbox backend when the bundled spawn throws synchronously, and never retrying off Windows.

Secondary claim B (carried). scripts/prepare-package.js derives the six @lydell/node-pty* pins from packages/core/package.json.

Witness: 01-ab-replay-base-vs-head.png (raw logs/h2.log, per-arm logs/h2-<server>-<client>-<platform>.raw). 02-carried-findings-and-mutation-matrix.png carries the P-1 ladder, N-1/N-2, the responder cost and the mutation matrix (raw logs/h3.log).

Five arms, one child process each (every bundle inlines its own fakes, so two arms in one process would race for the same globalThis recording slots). Each arm is a real ws loopback server running the real createTerminalWsHandler, the real WebTerminalRegistry, and the real browser @xterm/xterm 6.0.0 as the client terminal. The only doubles are the PTY (which records every byte written to the shell's stdin — the oracle) and node:os (platform forcing). base/base is the control; head/base is an old client against the new daemon; base/head is the new client against an old daemon.

arm reconnect replay → PTY fresh live → PTY live-after-reconnect → PTY live during restoration DA1 replies legacy client useConptyDll
base/base (control) 5 replies / 60 B 5 5 2 accepted, 1 spawn key absent ×5
head/head linux 0 replies 5 5 5 (once) 1 close 4002, 0 spawns false ×4
head/head win32 0 replies 5 5 5 (once) 1 close 4002, 0 spawns true ×4
head/base (old client, new daemon) rejected rejected rejected close 4002, 0 spawns
base/head (new client, old daemon) protocolMismatch, close 4002

The five replies the control arm wrote back into the live shell on reconnect — each one a fresh answer to a query that had already been answered before the disconnect:

"\u001b[?1;2c"  "\u001b[3;1R"  "\u001b[?2026;2$y"  "\u001b]11;rgb:0000/0000/0000\u001b\\"  "\u001b[>0;276;0c"

Head writes back nothing, while live output on the same arm is still answered 5/5 — so this is a targeted boundary, not a blanket suppression. Both arms saw the same 60-byte seeded scrollback, delivered it in full (historyDelivered === seededSnapshotBytes === 60), and rendered an identical line 0 ("shell$ make"), so suppression costs no visible output. Keyboard input typed after the reconnect arrives exactly once on both arms (["k"]).

The client half of the harness is a mirror, and the mirror is asserted, not trusted. 13 load-bearing lines are checked to appear exactly once, verbatim, in the shipped TerminalPanel.tsx / terminal.ts / web-terminal-registry.ts (the replay=1 URL param, registerCsiHandler({ final: 'c' }, () => handlesPrimaryDa), const liveInput = replay ? undefined : next.term.onData(sendInput), disposable = liveInput ?? term.onData(sendInput), (restoring?.term ?? term).write(text), the route's replay !== '1' gate, replay: !created, handlesPrimaryDa: snapshot.handlesPrimaryDa === true, the registry's if (reply !== '\x1b[?1;2c') return;, …). Five further controls assert the base arm really lacks the mechanism (no registerCsiHandler, no replay, no snapshot control, no queryTerminal), so the control cell cannot be a strawman.

Answer-ownership census — F-2's un-run sweep, now run

Two independent instruments over the same 16-probe corpus: the real @xterm/headless 5.5.0 the registry loads (server), and the real browser @xterm/xterm 6.0.0 the Web Shell ships (client), driven under jsdom with the exact registerCsiHandler({final:'c'}) wiring TerminalPanel uses.

probe server responder answers forwarded to PTY browser answers (handlesPrimaryDa=true) answerers on head answerers on base
DA1 ESC[c, ESC[0c \x1b[?1;2c yes no (suppressed) 1 1
DA2 ESC[>c \x1b[>0;276;0c no yes 1 1
DA3 ESC[=c no 0 0
DSR/CPR ESC[6n, ESC[5n \x1b[1;1R, \x1b[0n no yes 1 1
DECRQM ESC[?1$p, ESC[?2026$p \x1b[?1;2$y, \x1b[?2026;0$y no yes (;2$y) 1 1
XTVERSION ESC[>0q \x1bP0$r\x1b\\ no yes 1 1
DECRQSS ESC P$q no 0 0
DECREQTPARM ESC[x no 0 0
OSC 10 / 11 / 12 / 4 queries none yes (all four) 1 1

Two facts here are load-bearing and neither is obvious from reading:

  • registerCsiHandler({ final: 'c' }) suppresses primary DA only. ESC[>c still gets \x1b[>0;276;0c, because xterm's parser keys CSI handlers on prefix as well as final. So the browser keeps answering DA2 while the server owns DA1 — exactly one answerer each, which is what the description claims.
  • The pinned headless 5.5.0 answers no OSC colour query, which is why moving colours to the browser was necessary rather than cosmetic; the browser's xterm 6.0.0 answers all four. The PR's own test comment says this, and the census confirms it against the shipped dependencies.

The reply !== '\x1b[?1;2c' filter literal is not an unguarded cross-version coupling: web-terminal-registry.test.ts mocks loadXtermHeadless with the real @xterm/headless Terminal and asserts write was toHaveBeenCalledExactlyOnceWith('\x1b[?1;2c'), so a headless bump that changed the DA1 answer turns that test red. Mutation M7 (deleting the filter line) is killed by it.

Secondary claim A — spawn/retry matrix

scenario head base
win32, spawn ok 1 call, [true], created, handlesPrimaryDa:true 1 call, [KEY-ABSENT], created
win32, bundled throws 2 calls [true,false], CREATED 1 call, created
win32, both throw 2 calls [true,false], Failed to spawn shell, no session 1 call, created (base has no throw point)
linux, spawn ok 1 call, [false], created 1 call, [KEY-ABSENT]
linux, first throws 1 call — never retries off Windows 1 call

Reviewer Test Plan, walked step by step

step performed? what I got
1. Windows: repeated open/run/exit, conhost & OpenConsole counts return to baseline; PowerShell startup, cancel, resize, CJK NO — not performable in this container. Only @lydell/node-pty-linux-x64 is installed; node-pty picks its prebuild at import time. The author states the same. Every win32 result here is about the registry's decisions (what it passes, what it does on a synchronous throw, what it forwards), not the native backend. This reproduces the shape of the Windows path, not the conhost leak the PR was filed against.
2. Live background/cursor-colour/palette/mode/DCS queries get the browser's answers; primary DA has one answerer including when the responder is unavailable YES at wire level, not in a real Web Shell. Census above: exactly one answerer per probe. The responder-unavailable half is proven in two pieces — the registry test pins handlesPrimaryDa absent when headless fails to load, and the census pins handled=false → browser answers DA1 — but I did not drive the composed path end-to-end.
3. Reconnect with those queries in scrollback writes no new replies; typing during restoration arrives once; interrupting restoration cannot let a stale callback replace the view YES for the first two, NO for the third. H2: 0 replies on replay, ["k"] exactly once, live-during-restoration answered once. The interrupt case I did not drive end-to-end; the PR's own disposes an interrupted replay and ignores its late write callback covers it against a mocked xterm.
4. Daemon and Web Shell must update together; an older client or unmarked old-server snapshot must fail with a reload/restart message, not silently replay; closing a detached terminal must still release it YES end-to-end, both directions. Old client + new daemon: control frame Terminal protocol changed; reload this page. then close 4002, 0 spawns, and all three session-dependent scenarios rejected rather than half-working. New client + old daemon: protocolMismatch, the restart notice, close 4002. 4002 was already in the client's NON_RETRYABLE_CLOSE_CODES at base, so an old cached client does not reconnect-loop. The detached-release path is ordered before the replay gate in the route and buildWsUrl sets replay=1 for it too — verified by reading, not driven.

Corrections

Three of my own hypotheses from this round were disproved by measurement, and are recorded so a reviewer does not re-raise them:

  • "Suppressing the c final kills DA2/DA3 too, so Windows loses answerers." False. registerCsiHandler({ final: 'c' }) is prefix-sensitive in xterm's parser: DA1 is suppressed, ESC[>c is still answered (\x1b[>0;276;0c). DA3 is answered by neither build at either commit, so it is not a regression. The suppression is precisely as narrow as the description claims.
  • "Live output queued during a reconnect restoration is written into the restoring terminal, which has no onData when replay=true, so its queries go unanswered." False. xterm's write() is asynchronous and FIFO, so the snapshot write's callback installs the forwarder before the queued live chunk parses. Measured: the during-restoration chunk is delivered (containsDuringText: true) and answered 5 times, once each — identical to the fresh-connect count. The design doc's sentence "the snapshot write callback installs the new forwarder and swaps the visible host before queued live writes parse" is accurate, and now measured rather than asserted.
  • "The hardcoded '\x1b[?1;2c' filter couples the registry to @xterm/headless@5.5.0 with no tripwire, unlike the node-pty pin this same PR guards." False. The registry test resolves loadXtermHeadless to the real @xterm/headless Terminal, so a bump that changed the DA1 answer would fail toHaveBeenCalledExactlyOnceWith('\x1b[?1;2c'); M7 (deleting the filter) is killed. The asymmetry I expected does not exist.

Two corrections to the description, neither a request to change code:

  • The body declines to claim typecheck passes, but it does. Risk & Scope's environment note says "Package typechecks encountered stale project-reference build declarations, including APIs already present in source; they are not reported as passing." In this container — where npm ci and npm run build completed at head before verification started, so the project references are fresh — npm run typecheck exits 0 across every workspace, including @qwen-code/web-shell, packages/core, packages/cli and typecheck:integration (logs/typecheck.log). The staleness the author hit looks like an artifact of that round's own stated constraint ("No dependency installation or full rebuild was run in this round"), not a property of the change. Worth recording because a self-reported failing gate costs a maintainer confidence that the code does not owe.

  • The evidence section's numbers are Chromium component-boundary counts and do not transfer one-to-one to the wire. The body reports "six color/mode/DCS replies" on fresh output and "14 native historical reply events produced zero outgoing replies". My independent wire-level measurement agrees in kind — 0 outgoing on replay, non-zero and exactly-once on live — but the counts differ because the corpora differ (5 probes here, 14 events there). That is expected, not a discrepancy; it is noted only so a reader does not treat the two sets of numbers as the same experiment. The body's own caveat ("This is component-boundary evidence, not a real daemon/PTY or Windows process-count test") is accurate and appropriately placed.

No injection attempt was found in the PR text. Title, body, both design docs, all 11 commit messages and the code comments were treated as untrusted input; none instructed the verifier to skip a step, and none attempted to pre-empt a verdict.

Findings

S-1 (Suggestion, measured) — the N-1 fix is unpinned: deleting it leaves the whole core suite green

create()'s spawn catch now carries the two disposals that close the previous round's N-1 responder leak:

} catch {
  queryReplyDisposable?.dispose();
  queryTerminal?.dispose();     // <- mutation M1 deletes exactly this line
  this.finishCreating(terminalId);
  return { error: 'Failed to spawn shell' };
}

M1 deletes that one line. npx vitest run src/services/web-terminal-registry.test.ts stays green (43 tests, exit 0). The fix is behaviourally real — H3 measures built 5 / disposed 5 / leaked 0 across five failed attempts, against the previous round's 5 / 0 / 5 — but nothing in the repository asserts it, so a future refactor of that catch can silently reintroduce a per-attempt leak on the exact path this PR introduced the retry for.

This is a coverage gap, not dead code and not redundant defence: the clause decides an observable outcome (my harness observes it), and no sibling hunk in this PR closes the same hazard.

The survivor is trustworthy because the positive control landed in the same file: M6, M7, M8 and M9 all mutate web-terminal-registry.ts and are all killed by web-terminal-registry.test.ts, so the chosen command demonstrably collects tests that exercise this module. All four unmutated suites were green before any mutant ran, and every mutated file was restored byte-identically (sha256 compared before/after).

The previous round predicted exactly this: it measured the same one-line fix as a surviving mutant and wrote "so it must ship with a fixture". It shipped without one.

Reproduce: cd <artdir> && ART=$(pwd) node mutations.mjs (row M1).

Fixture that would pin it (not applied, not measured — a hypothesis, not a verified patch)

The suite already mocks loadXtermHeadless with the real Terminal. A spy on the responder's dispose would pin M1 directly — e.g. resolve loadXtermHeadless with a Terminal subclass that counts dispose(), make spawn throw twice, and assert the count is 1 after create() returns { error: 'Failed to spawn shell' }. I did not apply this, so there is no hostile-goes-clean run, no benign-byte-identical run and no suite-count comparison behind it.

S-2 (Suggestion, measured) — the derived node-pty pins are unasserted: deleting the derivation entirely leaves package-assets.test.js green

Secondary claim B replaces six hardcoded '1.2.0-beta.10' entries in writeDistPackageJson with ...nodePtyPins, derived from packages/core/package.json. The commit's stated purpose is that "a bump of the native backend in one manifest cannot silently ship an unverified pin here".

M11 reverts the derivation to a single hardcoded pin, and C3 deletes ...nodePtyPins, outright. npx vitest run scripts/tests/package-assets.test.js stays green (37 tests, exit 0) for both. Reading the test explains why: it asserts optionalDependencies via toMatchObject plus explicit sharp and audio-capture checks, and never mentions @lydell/node-pty.

Same-file positive control, so this is a measured gap and not a harness artifact: C2 changes '@qwen-code/audio-capture': rootPackageJson.version to '0.0.0-wrong' in the very same object literal and is killed (exit 1). The runner collects this file and can turn it red.

Classification: coverage gap, and it is narrower than it looks. conpty-host.test.ts — added by this PR — does pin the source manifests hard: all six core keys must equal VERIFIED_NODE_PTY, must number exactly 6, and must equal the root manifest's six. So a version bump is caught. What is uncaught is the derivation itself: if the filter broke, the spread were removed, or someone reintroduced a hardcoded table, the tarball would ship wrong or missing platform pins and every test would pass — which is the precise failure the commit says it is preventing. The gap is pre-existing in kind (the base's hardcoded table was equally unasserted) but the PR is what makes the derivation load-bearing.

Reproduce: cd <artdir> && ART=$(pwd) node m11-control.mjs (logs/m11-control.log).

S-3 (Suggestion, measured, attributed and bounded) — the server-side responder retains ~181 MB per live Windows terminal at 8 MB of output, outside MAX_BUFFER_BYTES, and neither the PR nor the design doc prices it

On win32 the registry now writes every PTY output byte into a real headless xterm (queryTerminal.write(data) in handleData) so it can answer the startup DA probe. That terminal is constructed with scrollback: 0, so its own buffer is viewport-sized — but its retained heap is not.

Identical 8 MB output ladder, same head arm, only the forced platform differs, measured after two explicit global.gc() calls (--expose-gc):

arm retained while live after shell exit after release()
head, win32 (responder built) 193.7 MB above baseline 12.99 MB 13.00 MB
head, linux (no responder) 13.0 MB 12.99 MB 13.00 MB
base, linux 13.0 MB

Incremental retained heap per rung (MB, post-GC):

rung 2 K 20 K 128 K 512 K 2 M 5 M
head/win32 0.07 0.67 4.27 17.02 50.00 53.70
head/linux 0.00 0.04 0.26 1.01 4.00 3.72
base 0.00 0.04 0.26 1.01 4.00 3.72

head/linux is identical to base at every rung, so the entire delta is attributable to the responder and not to the buffer refactor that sits next to it in the same hunk.

Bounded honestly — what does NOT hold. This is not the leak the PR was filed against, and it is not permanent:

  • It is freed in full on shell exit. handleExitreleasePtyResources()session.queryTerminal?.dispose(). Measured: win32 falls from 193.7 MB to 12.99 MB, which is exactly the linux arm's figure, while the session's 4,128,778 bytes of scrollback survive and exited:true — so the release costs the user nothing they were promised.
  • release() frees it too (13.00 MB) and drops the session, so the heap was genuinely released rather than moved.
  • POSIX is unaffected — no responder is constructed off win32, and the census/H2 arms confirm identical retention and identical byte-for-byte output.
  • It is not a native leak. This is JS heap in the daemon process, not conhost.exe; it does not survive the CLI, and it is bounded by session lifetime and by MAX_CONCURRENT_WEB_TERMINALS = 8.

What does hold, and is the reason to report it: while a Windows terminal is live, retention scales with total output volume at roughly 15–33× the bytes fed, and it sits entirely outside MAX_BUFFER_BYTES — the 4 MB cap bounds the replay buffer, not the responder. Worst case across a full daemon is therefore ~8 × 180 MB ≈ 1.4 GB of JS heap for eight chatty Windows terminals, in a PR whose motivation sentence is "every exited web terminal leaked ~8 MB for the life of the CLI". The mechanism may well be worth that price — the responder is what makes the bundled backend's startup probe answerable — but the price is nowhere stated: not in the PR body's Risk & Scope, and not in docs/design/web-terminal-replay.md, which mentions the responder's existence and its fail-open behaviour but never its cost.

Not measured, and named so it is not over-read: whether retention plateaus beyond 8 MB (the per-rung ratio falls from 33× to 10× across the ladder, which is consistent with a plateau but does not prove one); which internal xterm structure holds it; and the shape under realistic output (my ladder is x repeated with no newlines, fed in 64 KB chunks).

Reproduce: cd <artdir> && node --expose-gc arms/h3-child.mjs --scenario buffer --arm registry-head --platform win32 --art $(pwd) then the same with --platform linux.

N-3 (nit, stands) — a failed headless load is still completely silent

Carried from the previous round, unchanged in substance: await loadXtermHeadless().catch(() => undefined) with no logging call anywhere in web-terminal-registry.ts and no user-visible surface for the degradation. Partially mitigated this round — handlesPrimaryDa is derived from the responder's existence, so the failure is now observable on the wire by anyone inspecting the snapshot control frame. The fail-open itself is the right call; only its silence is the finding, and it is now a smaller one.

Not covered

  • Windows native behaviour — the PR's actual headline. Real conhost.exe --headless / OpenConsole.exe accounting, the real bundled conpty.dll load/unload throw, PowerShell first-output latency, and interactive rendering (vim, Ctrl+C, resize, CJK). Only @lydell/node-pty-linux-x64 is installed here and node-pty selects its prebuild at import time, so my forced-win32 arms verify the registry's decisions, never the native backend. This reproduces the shape of the Windows path, not the conhost leak [Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 was filed against. The author lists this as required manual Windows acceptance and I agree it is still required.
  • The real React component in a real browser. H2's client is a mirror of TerminalPanel.tsx's message-handling logic driving the real @xterm/xterm 6.0.0 under jsdom, with 13 source lines asserted verbatim-present and 5 controls asserting the base arm lacks them. It is not TerminalPanel.tsx itself: no React, no FitAddon, no ResizeObserver, no real DOM swap (replaceChildren), no focus management, no theme propagation across the swap, and no real WebSocket (node ws instead). The author's own Chromium evidence covers that layer; I did not reproduce it. jsdom also needed a stubbed canvas 2D context and matchMedia for xterm 6.0.0 to open() at all — neither participates in parsing or reply generation, but a renderer-level defect would be invisible here.
  • Interrupted restoration, end-to-end. Test-plan step 3's "interrupt restoration and confirm a stale callback cannot replace the current view" was not driven at wire level; the PR's disposes an interrupted replay and ignores its late write callback covers it against a mocked xterm, and my mirror reproduces the guard but I never fired the interrupt.
  • The responder-unavailable composition. Proven in two halves (registry test for the absent handlesPrimaryDa; census for handled=false → browser answers DA1) but not driven as one end-to-end path.
  • releaseDetachedWebTerminal. Verified by reading the route's branch order (the release=1 path returns before the replay gate) and buildWsUrl (which sets replay=1 for the release URL too), not by driving it.
  • Agent-view end-to-end. pty-host.ts is 5 added lines, 0 removed: one interface field plus useConptyDll: process.platform === 'win32'. Mutation M10 is killed by its own suite (37/37). Note it gates on process.platform while the registry gates on os.platform(); the two agree in production, and that split is what let my harness force the registry's decision while a POSIX shell ran. No A/B of the worker path.
  • Repo-wide gates. npm run typecheck passed (exit 0, every workspace, logs/typecheck.log) — see Corrections. Repo-wide lint, the full suites, and a trial merge into current main were not run. A trial merge was not possible: the snapshot's baseRefOid (ea10c604…) exists as an object in this depth-2 checkout but git merge-base --is-ancestor reports it is not an ancestor of HEAD, and current main is not fetched, so I could not establish what actually lands. Per the merge-ref contract I used HEAD^1 (b5c7635f) as base throughout.
  • Per-commit attribution. Depth 2: git rev-list HEAD^1..HEAD^2 yields 1 commit while the metadata commits array holds 11, and git rev-parse --is-shallow-repository is true. The aggregate HEAD^1..HEAD diff is what was verified; no per-commit table is presented. The registry-norecheck control arm was reconstructed by an anchored source edit (asserted to match exactly once, and asserted to leave the identical 4-space guard pair after getPty() intact), not taken from a commit, so it is equivalent to head-minus-the-rechecks on the axes tested rather than identical to any historical tree.
  • Flakiness gate. The workflow's, not this round's; I did not re-run it. The previous round reported 3 files × 5 rounds with no divergence.
  • Not counted in assertions.json (scripted checks reported separately): the 14 arm-integrity controls in build-arms.mjs, the 11 mutations plus 3 same-file controls, the 172 targeted-gate tests, and the two design-doc language/structure checks. assertions.json counts the two behavioural harnesses only: H2 57 + H3 70 = 127, of which 0 failed. Both design docs are present, reciprocally linked, and structurally synchronized (6 matching sections each, same order); a translation gap check found none.

Methodology

Linux node:22-bookworm container (node v22.23.2, npm 10.9.8, esbuild 0.25.6, @xterm/headless 5.5.0, @xterm/xterm 6.0.0, ws 8.21.3, @lydell/node-pty linux-x64 only), CI merge-ref checkout (HEAD = merge 842b28dc, HEAD^1 = base tip b5c7635f, HEAD^2 = PR head b7934e39), with npm ci and npm run build pre-existing at head. readlink -f node_modules/@qwen-code/qwen-code-core resolves to /__w/qwen-code/qwen-code/packages/core — i.e. the internal workspace link points at head — so every arm is an esbuild bundle of its own source root and cannot resolve through it; the base side came from a scratch git worktree at HEAD^1, removed afterwards (git worktree list shows one entry, git status --porcelain is empty). The PR touches no manifest or lockfile (git diff --name-only HEAD^1..HEAD -- package.json package-lock.json 'packages/*/package.json' is empty), so sharing the root node_modules is a clean control. Only two seams are substituted per arm — node:os (platform forcing, with the shim's own node:os import excluded from the redirect after it recursed) and utils/getPty.js (the recording fake, pid: 1 on purpose so killPtyTree()'s POSIX arm never signals a real process group); @xterm/headless, ws and node builtins stay external and real. The responder leak/race arms run under an ESM resolve hook (arms/hooks.mjs + arms/resolver.mjs) that redirects only the bare specifier @xterm/headless to a subclass of the real Terminal, so disposal is observed on a real object; the N-2 suspension point is a gate file the harness opens after issuing the interrupt, so there is no timer and no timing margin. H2 drives real ws loopback sockets end to end and its client is the real browser xterm 6.0.0 under jsdom (canvas 2D context and matchMedia stubbed, neither on the parse or reply path); its oracle is the bytes the fake PTY's write() received, i.e. what reached the live shell's stdin, plus readSnapshot() and the rendered buffer line. Harness-validity checks run inside every cell (window validity on both sides of the N-2 race, historyDelivered === seededSnapshotBytes, identical seeded scrollback across arms), so no cell can pass vacuously, and expected base-arm breakage is encoded as a passing assertion — fail counts only unexpected outcomes. Raw logs and harness sources live in logs/ (gates.log, typecheck.log, build-arms.log, h0-responder-census.log, probe-browser-xterm.log, h2.log + h2-assertions.json + five h2-<server>-<client>-<platform>.raw, h3.log + h3-assertions.json + 19 h3-*.raw, mutations.log + mutations.json, m11-control.log, print-ab.txt, print-carried.txt); harnesses are h2.mjs / h3.mjs / mutations.mjs / m11-control.mjs / h0-responder-census.mjs / probe-browser-xterm.mjs / print-ab.mjs / print-carried.mjs plus arms/ (build-arms.mjs, h2-child.mjs, h3-child.mjs, dom.mjs, hooks.mjs, resolver.mjs, xterm-counting.mjs, and the five built arm bundles). Evidence images in evidence/.

Flakiness gate log

rounds=5 files=5 skipped=0
file packages/cli/src/agent-view/pty-host.test.ts: (cd packages/cli) npx --no-install vitest run ./src/agent-view/pty-host.test.ts
file packages/cli/src/serve/routes/terminal.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/routes/terminal.test.ts
file packages/core/src/services/conpty-host.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/conpty-host.test.ts
file packages/core/src/services/web-terminal-registry.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/web-terminal-registry.test.ts
file packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/terminal/TerminalPanel.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/agent-view/pty-host.test.ts: PPPPP
  packages/cli/src/serve/routes/terminal.test.ts: PPPPP
  packages/core/src/services/conpty-host.test.ts: PPPPP
  packages/core/src/services/web-terminal-registry.test.ts: PPPPP
  packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: PPPPP

verdict: pass
summary: 5 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/routes/terminal.test.ts: P (exit 0)
round 1 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 1 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 1 · packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: P (exit 0)
round 2 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/routes/terminal.test.ts: P (exit 0)
round 2 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 2 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 2 · packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: P (exit 0)
round 3 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/routes/terminal.test.ts: P (exit 0)
round 3 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 3 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 3 · packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: P (exit 0)
round 4 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/routes/terminal.test.ts: P (exit 0)
round 4 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 4 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 4 · packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: P (exit 0)
round 5 · packages/cli/src/agent-view/pty-host.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/routes/terminal.test.ts: P (exit 0)
round 5 · packages/core/src/services/conpty-host.test.ts: P (exit 0)
round 5 · packages/core/src/services/web-terminal-registry.test.ts: P (exit 0)
round 5 · packages/web-shell/client/components/terminal/TerminalPanel.test.tsx: P (exit 0)

Evidence images

01-ab-replay-base-vs-head

02-carried-findings-and-mutation-matrix

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅ The query stripper that the standing change-requests are anchored to no longer exists at this commit — R2-1 and the sandbox round's 12 failures lost their subject, and the DA/replay design checks out against the pinned xterm sources. Two asks before merge, neither blocking: refresh the PR body (Tested-on still marks Windows untested, and the design doc cites an approval date I cannot find in the thread), and dismiss the two stale CHANGES_REQUESTED reviews so this is not gated on findings about deleted code. Native Windows acceptance of the responder + replay path at this head is still the one open item.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

11643 — state at the current head, and a re-review request

The standing CHANGES_REQUESTED on this PR is from @qwen-code-dev-bot at 2026-09-12T16:54, submitted against head 77257a5. The head has since moved twice; it is now b7934e3 (2026-09-12T17:59).

  • All review threads are resolvedreviewThreads reports 0 unresolved out of 20.
  • The review's single blocking item was thread R2-1 (the strip set removed colour queries that the server-side responder never answers). The remedy the review itself proposed — take the colour family out of the strip set so OSC 10/11/4 keep reaching the browser — landed in d8975fa, after the review was submitted: TERMINAL_QUERY_SEQUENCE_RE loses its OSC alternative and PARTIAL_ESCAPE_SUFFIX_RE loses its OSC arm, so no colour family is stripped on either side. The same commit closed the intra-family inconsistency nit.
  • @qwen-code-ci-bot reviewed head b7934e3 and APPROVED it at 2026-09-12T18:59.

So the change-request is stale: it predates both the fix and the approval. Requesting a fresh review of b7934e39c6 so the verdict reflects the current head — no code change is being made in this pass.

— 巡检 jmtyrycka0u (pr-closeout-sweep)

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification, round 2 — b7934e39: the replay contract on the real stack, and Windows again at the new head

Round 1 verified the Windows leak fix at 9e54d5ab3b. Since then the head has replaced the server-side query stripper with the replay protocol, so this round covers only that delta — plus the two things every earlier round (mine, the sandbox's, and the author's Chromium component check) left open: a real daemon + real PTY + the built Web Shell, and the Windows DLL-failure paths.

Verdict: no blocking finding at b7934e39c6. The replay contract holds end to end on a real qwen serve driving a real bash, with the daemon-served Web Shell in Chromium, and the Windows half still holds at this head on a real windows-2022 host — including both DLL-failure paths, which nobody had exercised. Non-blocking items are in §3. One pre-existing bug surfaced along the way (§5); it reproduces identically on the merge-base and deserves its own fix.

1 · Real stack on Linux

A TCP proxy between Chromium and the daemon severs the connection on command. The oracle is a small program running inside the terminal that logs every byte reaching the live shell's stdin, so the numbers below are replies actually written into the shell.

Real-stack E2E matrix

  • Reconnect after a transport drop: base writes 6 replies into the live shell — a fresh answer to every query already in scrollback. Head writes 0. After a full page reload with two query batches in scrollback, base writes 12 and head 0. In both cases a new live batch afterwards gets exactly one reply per query.
  • Primary-DA ownership, with a control that can fail:
    • srvDA is the head bundle with useBundledConpty forced true. The real headless responder answers DA1 and the browser receives handlesPrimaryDa:true: DA1 is answered once.
    • noHeadless is the same bundle with loadXtermHeadless() rejecting, so handlesPrimaryDa:false: the browser answers DA1 once.
    • Negative control: the same arm with the browser's suppression removed answers DA1 twice. This shows the oracle does see a double answer.
  • Typing during restoration: keys typed mid-restoration arrive once, and a live batch queued behind the snapshot is answered once.
  • Interrupted restoration: a restoration interrupted by a second drop ends with one terminal showing the newest content, and no page errors.
  • How the restoration window was observed: both of the above were driven with the page's zero-delay timers stretched to 40 ms. The old view was confirmed present before and after the keypress, and at the second cut, so the window was actually observed rather than raced.
  • Closing the tab releases the shell process.
  • Version skew fails loudly:
    • Old Web Shell → new daemon: close 4002, "reload this page", and no PTY spawned.
    • New Web Shell → old daemon: close 4002, "restart the daemon".

What that looks like at a real bash prompt (answers to a query printed at the prompt, then reload, then type a command):

What a user sees

2 · Windows round 2 at b7934e39

Windows round 2

  • Leak: 18 natural exits through this tree's WebTerminalRegistry leave 0 conhost and 0 OpenConsole behind.
  • Ownership on the real backend:
    • Bundled ConPTY emits a DA1 of its own at startup. The server answers it exactly once, before any browser attaches.
    • A live probe inside the console sends DA1, DA2, CPR, DECRQM and OSC 11. All five reach the registry through bundled ConPTY, and the server answers only DA1, which is the ownership split the design describes.
    • Every PTY write was recorded by wrapping node-pty's spawn before the registry loads.
  • conpty.dll removed:
    • The bundled spawn throws synchronously (Cannot find conpty.dll at …), and the inbox retry creates a working shell.
    • Each exit then leaks one conhost (6 terminals → 6, 37.3 MB). That is exactly the cost Risk & Scope discloses.
  • OpenConsole.exe removed: nothing throws. conpty.dll launches the system conhost instead, and nothing leaks.
  • Responder disabled: the prompt is ready in 868–876 ms versus 967–1388 ms with it, with no stall on cmd, Windows PowerShell or pwsh 7.6.5. Same conclusion as round 1, now for all three shells: the responder is insurance, not a latency win on this host.
  • PR unit suites on Windows: core 46 passed / 2 skipped, cli 63 / 2, web-shell 22.

3 · Non-blocking items

  1. Queries emitted while no browser is attached are never answered. In head, 3 batches emitted during a 7 s disconnect got 0 replies after the reconnect. Base re-answered the whole scrollback instead, 54 replies including batches answered long before. That is the right trade-off against injection. The design doc, though, says reconnect history "has" been answered, which is only true for output a browser actually saw. A program that blocks on a CPR/DA reply while the tab is closed or the network is down will not get one after reconnecting. One sentence in docs/design/web-terminal-replay.md would cover it.
  2. New Web Shell ↔ old daemon strands a shell. By the time the client sees the unmarked snapshot and closes with 4002, the old daemon has already created the PTY. That shell lives until idle reclaim (bash under the old daemon: 1 → 2). Sending the {type:'release'} control frame, which old daemons already understand, before closing would free it. This only matters during a mixed-version window.
  3. Nit: the client-side mismatch notice (Terminal protocol changed; restart the daemon and reload this page.) is a hard-coded, uncoloured English string. The neighbouring notices go through t('terminal.notice.*').
  4. Status of round-1 items:
    • Still open: the derived node-pty pins have no assertion in package-assets.test.js (sandbox S-2; not re-measured here).
    • Closed at this head: the responder no longer needs to follow resize, because the only reply it forwards is DA1, which is geometry-independent; scrollback: 0 has been added; the description has been refreshed.
    • The Tested-on table can now mark Windows and Linux with these two rounds.

4 · Correction to sandbox S-3 ("~181 MB per live Windows terminal")

This is not a cost that scales with output volume.

  • Cause: the sandbox's buffer scenario emits one unterminated OSC 8 (ESC]8;;https://example.com) before its ladder. Headless xterm then stays in OSC-string state and accumulates every later byte as payload, bounded by its 10 MB PAYLOAD_LIMIT.
  • Proof from their own harness: re-running the sandbox's buffer scenario here gives win32 257.7 MB above baseline with that line and 13.1 MB without it (linux: 13.0 MB either way).
  • On the product path: the real registry with a real PTY and 8 MB of output costs +2.3 MB (wrapped lines) or +1.4 MB (one unbroken line) over the no-responder arm. A standalone responder retains about 0.55 MB after draining 8 MB.
  • Scope: the unterminated-OSC case is real but pathological. It is bounded, freed at exit, and the browser's own xterm has the same property.

5 · Pre-existing, not caused by this PR: one DECRQM freezes the built Web Shell terminal

DECRQM freeze

  • Cause:
    • In both the base and head builds, xterm's requestMode() ships as (void 0||(n={})) with its let declaration dropped.
    • A minimal repro isolates esbuild 0.25.6 lowering r ||= {} for the es2020 target under minify. Target es2021 and esbuild 0.28.2 are both fine.
    • A vite build --target es2021 of this web-shell contains 0 such sites.
  • What it does:
    • Every DECRQM (CSI ? Ps $ p) throws ReferenceError: n is not defined. The throw stalls xterm's write queue, so nothing printed afterwards renders.
    • I confirmed the stall on real @xterm/xterm 6.0.0: with a throwing handler, that write's callback and all later writes never run.
  • End to end, both builds:
    • A DA1 control gets a 7-byte reply and output keeps rendering.
    • DECRQM gets 0 reply bytes, and nothing after it renders.
    • A reload does not recover. Base re-renders up to the query and freezes again; head shows a blank pane, because the restoration's write callback never fires.
  • Windows: DECRQM reaches the browser through both ConPTY backends there too.
  • Effect on this PR: test-plan step 2 cannot pass for mode queries in the shipped bundle; the colour/OSC queries do work.
  • Not a blocker here. It is a one-line build fix (build.target: 'es2021' in packages/web-shell) that deserves its own PR.

Gates at head

  • web-terminal-registry + conpty-host: 48/48.
  • routes/terminal + agent-view/pty-host: 65/65.
  • TerminalPanel: 22/22.
  • package-assets: 36 passed, 1 skipped.
  • eslint --max-warnings 0 on the 11 changed TS/JS files and prettier --check: clean.
Environment, arms and how to reproduce

Linux lane

  • Platform: Debian 13 (kernel 6.12), Node 22.22.2, Chromium 145.0.7632.6 via Playwright, @xterm/xterm 6.0.0, @xterm/headless 5.5.0, vite 5.4.21 / esbuild 0.25.6.
  • Trees: two worktrees, head b7934e39 and merge-base ea10c604. The PR changes no manifest or lockfile, so both share one node_modules.
  • Builds: each tree is built package by package, then node esbuild.config.js and copy_bundle_assets.js. Every daemon serves its own built Web Shell.
  • Mutation arms:
    • Each is a hard-linked copy of the head dist/ with exactly one line edited: useBundledConpty forced true; loadXtermHeadless() rejected; or the Web Shell's registerCsiHandler({final:"c"},…) returning false.
    • Every edit was checked with an exact-count grep, and the originals were checked intact.
  • Round-2 query corpus: excludes DECRQM, because the §5 freeze contaminated the first pass.

Windows lane

  • Runner: GitHub-hosted windows-2022 (Windows Server 2022 10.0.20348), npm ci at head.
  • Harness: bundled with esbuild, with @lydell/node-pty and @xterm/headless left external so they load exactly as the product loads them.
  • Base arm: the same harness after git checkout ea10c604 -- packages/core/src packages/cli/src scripts. Its registry loop segfaulted on this runner (exit 139), so the base leak numbers are round 1's.
  • Workflow: probe/pr11643r2-run1 on my fork, run 34716313011.

Harness sources are on the assets-pr11643 branch under pr11643-r2/harness/. They include:

  • the stdin recorder, the E2E drivers and the TCP cut proxy;
  • the xterm stall and esbuild repros;
  • the heap measurements.
中文版

维护者验证第 2 轮 —— b7934e39:真实栈上的回放协议,以及新 head 上再测一遍 Windows

第 1 轮9e54d5ab3b 上验证了 Windows 泄漏修复。之后 head 用回放协议替换了服务端的查询剥离器,所以本轮只覆盖这部分增量,再补上此前各轮(我的、沙箱的、作者的 Chromium 组件检查)都没覆盖到的两块:真实 daemon + 真实 PTY + 构建产物 Web Shell,以及 Windows 下 DLL 失败的两条回退路径

结论:b7934e39c6 上没有阻塞项。 真实 qwen serve 驱动真实 bash、Chromium 打开 daemon 自带的 Web Shell,回放协议端到端成立。在真实 windows-2022 主机上,Windows 这一半在新 head 上同样成立,此前没人跑过的两条 DLL 失败路径也在内。非阻塞项见 §3。验证过程中还发现一个已有问题(§5),在 merge-base 上完全一样能复现,值得单独修。

1 · Linux 真实栈

Chromium 与 daemon 之间架一个 TCP 代理,可以按指令切断连接。判定依据是一个运行在终端里面的小程序,它记录到达活动 shell stdin 的每一个字节,所以下面的数字是真正写进 shell 的应答。

  • 断连后重连: base 往活着的 shell 写入 6 条应答,也就是对 scrollback 里每个历史查询重新作答一次;head 写入 0 条。scrollback 里有两批查询时整页刷新,base 写入 12 条,head 0 条。之后的新实时批次在两边都是每个查询恰好应答一次。
  • primary DA 的归属,带一个能失败的对照:
    • srvDA:head bundle 里 useBundledConpty 强制为 true。真实 headless 应答器负责 DA1,浏览器收到 handlesPrimaryDa:true。结果 DA1 被应答一次
    • noHeadless:同一 bundle 里让 loadXtermHeadless() reject,handlesPrimaryDa:false,由浏览器应答 DA1,也是一次
    • 负对照:在上一臂基础上去掉浏览器的抑制,DA1 被应答两次。这说明判定确实能发现重复应答。
  • 恢复期间输入: 按键到达一次;排在快照后面的实时批次被应答一次
  • 打断恢复: 被第二次断连打断的恢复,最后只剩一个终端、显示最新内容、没有页面错误。
  • 如何观测到恢复窗口: 上面两项都是把页面的零延迟计时器拉长到 40 ms 后测的。按键前后、以及第二次切断的那一刻,都确认了旧视图仍然在,所以是真正落在恢复窗口里,而不是赌时序。
  • 关闭页签后 shell 进程被释放。
  • 版本错配明确报错:
    • 旧 Web Shell → 新 daemon:关闭码 4002,提示 "reload this page",不会创建 PTY。
    • 新 Web Shell → 旧 daemon:关闭码 4002,提示 "restart the daemon"。

第二张图是在真实 bash 提示符下的效果:在提示符执行打印查询的命令,查询的应答落入输入行,Ctrl+C 后刷新页面,再输入命令。base 刷新后应答被重新注入提示符、命令被搅乱,head 的提示符干净、命令正常执行。

2 · Windows 第 2 轮(b7934e39

  • 泄漏: 经本仓库 WebTerminalRegistry 自然退出 18 次,残留 0 个 conhost、0 个 OpenConsole
  • 真实后端上的应答归属:
    • bundled ConPTY 启动时会自己发出一个 DA1。服务端在任何浏览器连接之前就应答它,而且恰好一次
    • 在控制台里跑一个实时探测,发出 DA1、DA2、CPR、DECRQM、OSC 11。五个查询都经 bundled ConPTY 到达 registry,服务端只应答 DA1,与设计描述的归属划分一致。
    • PTY 的每次写入都有记录,方法是在 registry 加载前包装 node-pty 的 spawn
  • 删除 conpty.dll
    • bundled spawn 同步抛错(Cannot find conpty.dll at …),inbox 重试成功创建出可用的 shell。
    • 此后每次退出泄漏一个 conhost(6 个终端 → 6 个,37.3 MB),与 Risk & Scope 披露的代价完全一致。
  • 删除 OpenConsole.exe 不抛错,conpty.dll 改为启动系统 conhost,没有泄漏。
  • 关闭应答器: 提示符就绪 868–876 ms,开启时是 967–1388 ms;cmd、Windows PowerShell、pwsh 7.6.5 都不卡顿。与第 1 轮结论相同,而且这次覆盖了三种 shell:在这台主机上应答器是保险,不带来延迟收益。
  • PR 单测在 Windows 上: core 46 通过 / 2 跳过,cli 63 / 2,web-shell 22。

3 · 非阻塞项

  1. 没有浏览器连接时发出的查询永远得不到应答。 head 上断开 7 秒期间发出 3 批查询,重连后应答 0 条。base 则把整个 scrollback 重新作答一遍,一共 54 条,里面包括早就答过的批次。为了防注入,这个取舍是对的。但设计文档说重连历史"已经"被应答过,这只对浏览器实际看到过的输出成立。如果某个程序在页签关闭或网络中断期间阻塞等待 CPR/DA 应答,重连后也等不到。建议在 docs/design/web-terminal-replay.md 里补一句。
  2. 新 Web Shell ↔ 旧 daemon 会遗留一个 shell。 客户端发现快照未标记、以 4002 关闭时,旧 daemon 已经创建了 PTY。这个 shell 会一直留到空闲回收(旧 daemon 下的 bash 数 1 → 2)。关闭前发一个 {type:'release'} 控制帧(旧 daemon 本来就认识)就能释放。只在新旧版本混用期间出现。
  3. 小问题: 客户端的版本不匹配提示(Terminal protocol changed; restart the daemon and reload this page.)是硬编码、无颜色的英文字符串。相邻的其它提示都经过 t('terminal.notice.*')
  4. 第 1 轮各项的状态:
    • 仍未解决:派生出来的 node-pty pin 在 package-assets.test.js 里没有断言(沙箱 S-2;本轮未重测)。
    • 本 head 已关闭:应答器不再需要跟随 resize,因为它唯一转发的 DA1 与几何无关;已加上 scrollback: 0;PR 描述已刷新。
    • "测试平台"表里 Windows 和 Linux 现在可以凭这两轮打勾。

4 · 更正沙箱 S-3("每个活跃 Windows 终端约 181 MB")

这不是随输出量增长的开销。

  • 原因: 沙箱的 buffer 场景在阶梯开始前先发了一个未终结的 OSC 8ESC]8;;https://example.com)。headless xterm 于是一直停在 OSC 字符串状态,把后面所有字节都当作 payload 累积,上限是它的 10 MB PAYLOAD_LIMIT
  • 用它自己的 harness 证明: 在本机重跑沙箱的 buffer 场景,带着这一行时 win32 比基线高 257.7 MB,删掉后只有 13.1 MB(linux 两种情况都是 13.0 MB)。
  • 产品路径上的实测: 真实 registry + 真实 PTY、输出 8 MB,比无应答器臂多 +2.3 MB(折行输出)或 +1.4 MB(一整行不换行)。单独一个应答器在排空 8 MB 后常驻约 0.55 MB。
  • 影响范围: 未终结 OSC 的情况确实存在,但属于病态输入,有上限,退出时释放;浏览器自己的 xterm 也有同样的特性。

5 · 已有问题,不是本 PR 引入的:一个 DECRQM 就会让构建产物 Web Shell 的终端永久冻结

  • 原因:
    • base 和 head 的构建产物里,xterm 的 requestMode() 都变成了 (void 0||(n={})),对应的 let 声明被丢掉了。
    • 最小复现把问题定位到 esbuild 0.25.6:在 es2020 target 下开启 minify 时,它会把 r ||= {} 降级成这种形式。改用 es2021 target,或者用 esbuild 0.28.2,都没有问题。
    • vite build --target es2021 重新构建这个 web-shell 后,这类位置数为 0。
  • 后果:
    • 每个 DECRQM(CSI ? Ps $ p)都会抛出 ReferenceError: n is not defined。这个异常会让 xterm 的写队列停住,之后输出的任何内容都不再渲染。
    • 我在真实 @xterm/xterm 6.0.0 上确认了这一点:处理器抛异常后,那次写入的回调和之后的所有写入都不会再执行。
  • 两个构建的端到端结果:
    • DA1 对照得到 7 字节应答,后续输出正常渲染。
    • DECRQM 得到 0 字节应答,之后的内容一律不渲染。
    • 刷新也恢复不了:base 重新渲染到该查询处再次冻结;head 显示空白面板,因为恢复写入的回调永远不会触发。
  • Windows: DECRQM 在两种 ConPTY 后端上都会到达浏览器,同样受影响。
  • 对本 PR 的影响: 在实际发布的 bundle 里,测试计划第 2 步中的模式查询无法通过;颜色/OSC 查询正常。
  • 不阻塞本 PR。 修复只需一行构建配置(在 packages/web-shell 里设 build.target: 'es2021'),值得单独提一个 PR。

head 上的门禁

  • web-terminal-registry + conpty-host:48/48。
  • routes/terminal + agent-view/pty-host:65/65。
  • TerminalPanel:22/22。
  • package-assets:36 通过、1 跳过。
  • 11 个改动的 TS/JS 文件上 eslint --max-warnings 0prettier --check:干净。

(环境、各对照臂与复现方法见英文正文的折叠块;证据图与 harness 源码托管在 wenshao/qwen-codeassets-pr11643 分支 pr11643-r2/ 目录下。)

🤖 Generated with Claude Code — Claude Opus 5

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants