test(core): pin the deferred exit-time PTY release against trailing output - #11585
Conversation
An exited web terminal session kept its PTY-side resources — the conout worker thread and the data/exit listeners — until the 15-minute idle reclaim, and exited sessions do not count against the admission cap, so accumulation inside that window was unbounded. handleExit now frees those resources via a deferred (one-tick) releasePtyResources, keeping the session object and its scrollback buffer in the map so readSnapshot() replay and the route's releaseAfterReplay path are unaffected. The defer lets trailing onData callbacks node-pty still has queued after onExit reach the buffer first. release() shares the helper, guarded by a per-session ptyReleased flag so the exit-time release and a later tab-close/reclaim release cannot run twice; the live arm still runs killPtyTree first so the wrapper kill() stays the single pseudo-console closer. Fixes #11353
|
Thanks — re-run picked this up after the body edit, and the template gate that stopped the last pass is cleared. Template looks good ✓ — Problem: a real, verified coverage gap — not theoretical hardening. Direction: aligned. AGENTS.md asks for collocated regression tests, and this area has churned through #11303 / #11313 / #11353 — a timing invariant that three existing tests cannot distinguish is exactly the kind of thing that regresses silently. Size: Approach: minimal, and it follows the file's own conventions rather than inventing new ones — same One framing nit for the description, not the code: change #1 is described as a "mock isolation fix" for a stale release contaminating another test's spy, but Risk: no elevated risk signals — the Stage 1e high-risk path scan matches nothing once Moving on to code review. 🔍 中文说明感谢 —— 本次 re-run 是在正文修改之后触发的,上一轮拦住 PR 的模板闸门已经解除。 模板完整 ✓ —— 问题:真实且已核实的覆盖缺口,不是理论性加固。 方向:对齐。AGENTS.md 要求测试与源码同目录,而这个区域已经过 #11303 / #11313 / #11353 的反复修改 —— 一个现有三个测试都区分不出来的时序不变量,正是最容易悄悄回归的东西。 规模: 方案:改动最小,并且沿用了文件自身的约定而不是另起一套 —— 与紧邻的三个同级测试相同的 一个关于描述(而非代码)的措辞小问题:改动 #1 被描述成修复"陈旧释放污染另一个测试的 spy"的"mock 隔离修复",但 风险:无升级风险信号 —— 过滤掉 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewNo Critical findings. The one blocking item that two independent reviewers raised on earlier heads is genuinely closed here, and I re-derived it against the production code rather than taking the fix on faith. What I would have written independently. To pin a one-tick defer you need an observation between the trigger and the tick, because anything asserted after Why the added assertion actually discriminates (static trace; see the verification note below for what this is and isn't). The chain that makes
So under a synchronous release Status of the prior findings, checked against
Two nits, neither blocking. The comment at House style is clean: ESM, no Test evidenceUnattended CI run (
Two things about that table worth saying out loud. Not verified: the mutation behavior — that Sandboxed verification would settle exactly that: Real-scenario tmux testing: N/A — test-only change with no user-visible surface, and this is the CI path where live behavior is driven only by the isolated 中文说明代码审查:无 Critical。前几轮由两位独立评审提出的唯一阻塞项,在当前 head 上确实已经关闭 —— 我是对着生产代码重新推导的,没有直接采信"已修复"这个说法。 我自己会怎么写:要钉住一个 tick 的延迟,必须在触发和该 tick 之间插入观测点,因为任何放在 新增断言为什么真的有判别力(静态推导): 历史结论对照 两个非阻塞小问题: 代码风格符合仓库约定:ESM、无 测试证据:本次为无人值守 CI 运行( 表格有两点需要明说: 未验证:变异行为 —— 即把 沙箱验证恰好能定这件事: 真实场景 tmux 测试:N/A —— 纯测试改动,无用户可见面;且本次为 CI 路径,真实行为只由隔离的 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the coverage gap is real and the fix is the right one-liner; the only thing keeping this off 5 is that the test's discriminating power is a hand trace here rather than a measurement. Stepping back: this PR is 41 lines in one test file that make a documented production invariant observable for the first time. Against my own baseline it is a match — I would have written the same observation order, and there is no smaller version of it. Every line in the diff earns its place: the new test, and the fake's My reservation, stated plainly so it is not mistaken for confidence I do not have: green CI proves the suite passes on this head, and my trace proves why Non-blocking, for whoever lands this: the detach now lives in one of the file's three One process note, because it changes what the PR page shows. The previous pass stopped at the template gate and left a CI on the reviewed commit is green with zero failures out of 124 check-runs, no 中文说明信心度:4/5 —— 覆盖缺口是真实的,修复也正是那一行该加的断言;没到 5 分的唯一原因是:本测试的判别力在这里是手工推导,而非实测。 退一步看:本 PR 用一个测试文件里的 41 行,第一次让一条已写进注释的生产不变量变得可观测。 对照我自己的基线:一致 —— 我会写出同样的观测顺序,也想不出更小的版本。diff 里每一行都有存在理由:新测试,以及 fake 的 坦白说明我的保留,以免被误当成我并没有的信心:CI 全绿只证明套件在当前 head 通过;我的推导证明的是 非阻塞项,留给合并者:摘除行为现在只存在于文件三个 一条流程说明,因为它会改变 PR 页面的显示。 上一轮停在模板闸门,并在同一个 commit 上留下了本 bot 的 被审 commit 上 CI 全绿,124 个 check-run 零失败;没有 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
doudouOUC
left a comment
There was a problem hiding this comment.
Review at head c344ab78fd27a2731dd34a5876c1fa7a3aebee86 — verdict: no Criticals, 2 Suggestions.
Basis for the no-Critical verdict
- Grepped every read site of the added
ptyReleased?: boolean: onlyreleasePtyResources(web-terminal-registry.ts:523-529) writes and reads it, so it is not a dead switch. Double-free is blocked three independent ways: the flag itself,conpty-host.ts'sreleasedHostsWeakSet, and the native exit watcher having already cleared the pty baton, soPtyKillno-ops after a natural exit. Both dispose paths inconpty-host.tsare try/catch-guarded and win32-only. release()still keepsif (!session.exited) { killPtyTree(session.pty); }ahead of the shared helper, so no signal reaches a possibly-recycled pid — the #6067 hazard is preserved.- Reordering on the live arm is safe: the listener sets are cleared before the synchronous
killPtyTree. - Session and
bufferstay in the registry map, soreadSnapshot()replay,releaseAfterReplay, theMAX_CONCURRENT_WEB_TERMINALSnon-exited admission count andIDLE_RECLAIM_MSare all unaffected.write()/resize()already short-circuit onsession.exited. - The test-harness change captures
disposeDataby value and detaches the listener on dispose, so the pre-existing synchronous assertions still hold, and the new fourth test pins output queued behindonExitreaching scrollback.
Suggestions
packages/core/src/services/web-terminal-registry.ts:286—setImmediate(() => this.releasePtyResources(session)).unref?.();. A single event-loop tick is weaker than this repo's own precedent inpackages/core/src/services/shellExecutionService.ts:1927-1935, which drains twice (flushChain().then(drain).then(drain)) precisely because one tick can leave queued PTY bytes unflushed; the comment near:2199documents the same lost-tail trade-off. A tail of scrollback can still be dropped. You disclose this in the description, so it reads as an accepted trade-off rather than a defect — but the two-turn drain is already available in-repo.packages/core/src/services/conpty-host.ts— thereleaseHostcall-site inventory comment still describesrelease()'selsearm as "the primary web-terminal path for #11303", which is the arm this PR deletes. Stale comment, no behavioural impact.
Relationship to #11572
Same author, same file, same fix; this one lands about an hour later and is strictly better: it adds the trailing-output regression test (which answers the open Suggestion on #11572 that nothing pinned the setImmediate), adds .unref?.() so the deferral cannot hold the loop open at shutdown, and makes the flag optional. Recommend landing this and closing #11572 as superseded.
Agent-assisted review. Findings were re-read in the file content at the exact head SHA above before filing. Posting as a comment only — no approval implied.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] Blocking finding(s) follow.
Partially reviewed — gaps disclosed. Suggestions are inline.
Unresolved, please confirm:
- [Critical] PR-level approval blocker asserted by the triage stage-3 review comment (id 5621199896, by @qwen-code-ci-bot): this PR duplicates the still-open #11572 — same author, same two files, same
Fixes #11353, and both edit the same lines of `pac…
Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and the win32 ConPTY path could not run locally either: there is no Windows host here, the win32 native conpty module cannot load on Linux, and the changed suite drives that path only through an os.platform() mock, so the platform this fix exists for was executed by nobody (the PR's own Tested-on table marks Windows "not tested").
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。
未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and the win32 ConPTY path could not run locally either: there is no Windows host here, the win32 native conpty module cannot load on Linux, and the changed suite drives that path only through an os.platform() mock, so the platform this fix exists for was executed by nobody (the PR's own Tested-on table marks Windows "not tested").
— qwen3.8-max via Qwen Code /review (v0.23.2)
Resolve conflict in web-terminal-registry: main already landed the same exit-time PTY release via #11572. Take main's implementation (ptyResourcesReleased flag) and its three tests, keep this PR's test-isolation fake (capture disposeData by value) plus the trailing output scrollback test. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-pr-conflict/jmtwanbuowv
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
Not explored to full depth (tool budget reached): "agent 3b": I could not confirm the fake's detach model against the real @lydell/node-pty bundle — the installed package ships a single index.js whose onData /disposab…; "agent 6c": empirical mutation check of the new test (remove the setImmediate defer at web-terminal-registry.ts:296 and confirm the test goes red) — the first attempt r…; "agent 6c": reading packages/core/src/services/conpty-host.ts ( releaseConPtyHost / disposeConoutWorker / noteConPtyHostReleased ) to confirm the win32 release path t….
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/core/src/services/web-terminal-registry.test.ts:511 — [probe] The new test cannot distinguish a setImmediate defer from a microtask defer
中文说明
已审查。 建议见行内评论。
未探索到全部深度(达到工具调用预算):"agent 3b":I could not confirm the fake's detach model against the real @lydell/node-pty bundle — the installed package ships a single index.js whose onData /disposab…;"agent 6c":empirical mutation check of the new test (remove the setImmediate defer at web-terminal-registry.ts:296 and confirm the test goes red) — the first attempt r…;"agent 6c":reading packages/core/src/services/conpty-host.ts ( releaseConPtyHost / disposeConoutWorker / noteConPtyHostReleased ) to confirm the win32 release path t…。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.3)
Add expect(disposeData).toHaveBeenCalledOnce() to make the test fail on both mutations: synchronous release (listener detached before trailing output) and no release at all.
…ling-output test comment The pinned @lydell/node-pty gates exit emission on socket teardown, so asserting it can deliver onData after onExit misstates the dependency. The comment now states the registry invariant the test actually pins: the release is deferred one tick after onExit, and the fake's detach on dispose makes the test fail if the defer is removed.
chiga0
left a comment
There was a problem hiding this comment.
No blocking findings. Approved.
Approval blockers: none.
Scope
Test-only change: packages/core/src/services/web-terminal-registry.test.ts.
Checked:
- Production
handleExit→setImmediate(() => this.releasePtyResources(session))(line 296) is unconditional;releasePtyResourcesalways callssession.dataDisposable?.dispose()regardless of platform. ConfirmedosPlatform("win32")in the new test is consistent with similar tests but not required fordisposeDatato fire. - Mock isolation fix:
const disposeDataSpy = disposeData;is necessary — thedisposebody is an arrow function (a closure), so without the capture it late-binds the describe-scopelet disposeDatavariable; a deferred release firing during a later test would call the later test's spy. The original{ dispose: disposeData }did not have this problem (it evaluated the variable at object-creation time, not in a closure), but the new arrow wrapper requires the explicit capture. - Listener identity guard
if (onData === listener) onData = () => {}correctly prevents a stale deferred release from clobbering the live test'sonDatavariable after a new test has registered its own listener. - New test efficacy:
expect(disposeData).toHaveBeenCalledOnce()fails if the release is removed.readSnapshot({ output: "trailing", exited: true })fails if the release is made synchronous — the mock detaches the listener on dispose, so a synchronous release clearsonDatabeforeonData("trailing")runs, and "trailing" never reaches the buffer. Both assertions are independently discriminating at the current mock shape. readSnapshotreturn type confirmed:{ output, exited, exitCode, workspaceCwd }— thetoMatchObjectshape is valid.
Not run: execution rungs — test infrastructure unavailable locally. PR's Reviewer Test Plan (npx vitest run src/services/web-terminal-registry.test.ts) covers this; 33 tests documented as passing.
Minor finding
Minor · test-gate robustness — inline comment at line 512.
Cross-check against existing reviews
Reviewed by qwen-code-ci-bot (2 rounds) and doudouOUC (1 round).
- R1-1 (spy capture): Bot Round 1 incorrectly concluded the hazard did not exist; Round 2 self-corrected. I independently confirmed the hazard is real: the arrow closure late-binds
disposeData, so the explicitconst disposeDataSpycapture is load-bearing. Author's response and the current code are correct. - R1-2 (description accuracy): Author rewrote the description to match test-only reality after #11572 merged. Current PR body correctly describes test-only work and credits #11572 for the production change. Confirmed.
- R1-3 (incorrect comment about node-pty tail-delivery race): Filed against
web-terminal-registry.ts:281, which is main's code, outside this diff. Author acknowledged and says the comment was reworded ina2a6653; the diff does not include that file, so the concern belongs to a main follow-up, not this PR. - R1-4 (stale call-site enumerations): Same — production-file changes are outside this diff. Author acknowledged a follow-up cleanup.
- R2-1 (detach gate depends on mock shape): Bot's concern is mechanically correct: removing the detach line and making the release synchronous yields 33/33 green. Author says the test goes red locally under the synchronous-release mutation — also correct, but only while the detach line is present. Filed as inline Minor at line 512.
- Bot [Critical] — duplicates #11572: #11572 merged on 2026-09-11; this PR is test-only pins on top of that merge. Critical no longer applies at this head.
- doudouOUC suggestion (
setImmediate.unref?.()): About production code on main; outside this diff.
Reviewed with AI assistance.
doudouOUC
left a comment
There was a problem hiding this comment.
Agent-assisted review at a2a665303f0540b35ae16847576697dfba48aada — full static review of the current test-only scope; no confirmed Critical findings.
The current diff is one test file (+37/-1), not the earlier production fix. Read the complete test file and pinned registry implementation. The disposable captures its own spy and guards listener identity, so a previous session's deferred release cannot clear the next session's callback. At packages/core/src/services/web-terminal-registry.test.ts:499-519, the output assertion distinguishes synchronous release with the current detaching fake; the once assertion also rejects removing release entirely. The production path at web-terminal-registry.ts:271-296,540-545 leaves scrollback in the registry and detaches through the guarded helper.
Prior review reassessment: my earlier review reported no Criticals. The historical bot Critical about duplicating the production fix does not describe this current one-file test delta; it is not a code defect here. The earlier production drain/comment Suggestions concern code outside the current diff and are not renewed. The existing suggestion to assert no disposal before the await remains a test-strengthening suggestion, not a present correctness blocker; no additional suggestion or diff growth requested.
Validation: static inspection only. No PR code, Vitest suite, mutation test, native PTY, or Windows execution was run in this review; I do not adopt previous reviewers' runtime measurements as my own evidence. No production core logic changed, and no daemon route changed.
Comment only — no approval implied.
Refresh the lint gate (eslint.legacy-filenames.mjs changed on main in ae78d5b, removing the trustedHooks entry) so the Lint & Static lane re-validates against the current gate.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed. Suggestions are inline.
1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- the setImmediate-vs-microtask defer gap in the trailing-output test — already reported (round 2 review 5174801312, deferred list, packages/core/src/services/web-terminal-registry.test.ts:511)
Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and the win32 ConPTY path could not run locally either: there is no Windows host here, the win32 native conpty module cannot load on Linux, and the changed suite drives that path only through an os.platform() mock, so the changed test file was never executed on the platform the behaviour it pins is specific to.
Test Plan (not a blocker): 33 tests pass — this review observed 25472, 2025, 30783, 1016, 1997, 535, 7343 passed.
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and the win32 ConPTY path could not run locally either: there is no Windows host here, the win32 native conpty module cannot load on Linux, and the changed suite drives that path only through an os.platform() mock, so the changed test file was never executed on the platform the behaviour it pins is specific to.
Test Plan(非阻断):33 tests pass — this review observed 25472, 2025, 30783, 1016, 1997, 535, 7343 passed。
— qwen3.8-max via Qwen Code /review (v0.23.3)
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
结论:REQUEST_CHANGES —— 仅剩 1 个阻塞项(测试有效性),生产代码无问题
已核对 head d50127e563f0adf586f2ed620e7bd4c511c93417;required CI 全绿(pass x14 / skipping x27,无 pending/失败);0 Critical;web-terminal-registry.ts:296 的延迟释放实现正确,本 Review 不质疑。
阻塞项:新测试的判别力只由 mock 的一行撑着,文件里没有任何东西守住它
对应未解决线程 web-terminal-registry.test.ts:82(R2-1)与 :512,两者是同一个诉求。test.ts:515 的 expect(disposeData).toHaveBeenCalledOnce() 关不掉它:releasePtyResources 按会话幂等(web-terminal-registry.ts:541 的 if (session.ptyResourcesReleased) return;),所以释放是同步还是延迟,disposeData 都恰好调用一次,该断言两种情况下都通过。真正让 lets output queued behind onExit reach the scrollback before the release 能观察到同步释放的,只有 fake 在 dispose 时把 listener 摘掉这一行;而 handleData 没有 exited 短路(web-terminal-registry.ts:243-270),listener 还挂着时尾部字节一定会进 buffer。
在本次审查的 head 上实测(隔离副本,packages/core/src/services/web-terminal-registry.test.ts):
| fake detach | 生产释放 | 结果 |
|---|---|---|
| 保留 | 同步(回归) | Tests 1 failed | 32 passed (33) — 当前有效 |
| 摘掉 | 同步(回归) | Tests 33 passed (33) — 失效复现:回归上线且全绿无信号 |
| 摘掉 | 延迟(未改) | Tests 33 passed (33) |
| 保留 | 延迟(未改) | Tests 33 passed (33) — 基线 |
这个退回并非假想:本文件 :574 和 :637 两处 fake 仍原样写作 return { dispose: disposeXxx };,想把 harness 统一成一种写法的人两屏之内就有范例。
修复(一行,不依赖 mock 形态)
在 test.ts:511 与 :512 之间加一条断言,直接把「延迟」钉住:
onExit({ exitCode: 0 });
expect(disposeData).not.toHaveBeenCalled();
onData('trailing');disposeData 就是同一个 spy(fake 里 const disposeDataSpy = disposeData 按值捕获的当前会话 spy),断言必须放在 setImmediate 的 await 之前:过了那个 tick,延迟释放已经正当地跑过。实测:加上这一行后,无论 detach 保留还是摘掉,同步释放都会因 disposeData 已被调用而变红;未修改的生产代码下仍然 Tests 33 passed (33),不会误报。detach 那行可作为 node-pty 真实性建模保留,但不再有任何东西依赖它。
其余
- 历史项:
:70-86的 mock 隔离(按值捕获 spy、dispose 时按 listener 身份摘除)已解决上一轮关于跨会话污染的顾虑,本轮不再提出。 - 除上述一行外无其他阻塞项;补完这一行并推新 head 后可直接重新触发 Review,CI 与其余结论无需重做(该改动不影响生产代码)。
中文说明:唯一阻塞点是「新加的回归测试可以被 mock 的 detach 行为独自撑起,文件里没有断言守住延迟本身」;上面给出已在该 head 实测过的一行修复(expect(disposeData).not.toHaveBeenCalled(),放在 await setImmediate 之前)。生产实现、CI、其余历史项均已通过,改完推一版即可复审。
…s detach
The regression test added here only detected a synchronous release because the
fake's `dispose` detaches the data listener. Nothing in the file gated that
detach, and two other fakes in it still read `return { dispose: disposeData }`,
so a harness tidy-up two screens away would have left a synchronous release
passing green. `expect(disposeData).toHaveBeenCalledOnce()` could not close the
gap either: `releasePtyResources` is idempotent per session, so the spy fires
exactly once whether the release is synchronous or deferred.
Assert instead that `disposeData` has not been called between `onExit` and the
trailing `onData`, before the `setImmediate` tick that legitimately runs the
deferred release. That pins the defer directly, independent of the fake's
shape. The detach stays as node-pty modelling.
Test-only: no production code touched.
Closes the blocking item in review 5179234752, chiga0's Minor at
web-terminal-registry.test.ts:512 and ci-bot R2-1 at :82.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The single blocking item is fixed at b1e4f30 with the exact one-line assertion this review prescribed: expect(disposeData).not.toHaveBeenCalled() between onExit({ exitCode: 0 }) and the trailing onData('trailing'), before the setImmediate await. The defer is now pinned directly, so the fake's listener detach is no longer load-bearing and a harness tidy-up back to 'return { dispose: disposeData }' can no longer let a synchronous release pass green. Test-only change, one file, +6/-3, production code untouched (this review confirmed web-terminal-registry.ts:296 is correct and not in question). Both corresponding threads are answered with the SHA and resolved: chiga0's Minor at :515 and ci-bot R2-1 at :82. CI is re-running at b1e4f30; this review noted CI and the other conclusions need no redo, so the Test lane result on this head is the only outstanding verification.
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (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: 55 passed · 0 failed · 55 total Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:55 通过 · 0 失败 · 55 总计 抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #11585 — deep verification reportVerdict: 中文摘要
ScopeCentral claim (the one behaviour this PR exists to change): the added test Secondary claims
Method chosen: this is a test-only PR, so the load-bearing proof is a mutation A/B across test files — 16 single-point mutants of the unmodified production file, each run against the pre-PR test file and the PR test file, changing nothing else. Central claim — A/B tableEnvironment per cell:
Counts: killed by old test file 14/16; killed by new test file 15/16; newly killed by the PR: The timing axis is now fully bracketed, and the two halves come from different commits of history: the upper bound ("not later than one macrotask turn") was already pinned by the base — Vacuity / scenario-reached: reverting the production hunk makes the new test fail on the intended assertion with real expected-vs-actual values, not on an import or fixture break —
Stability: 20/20 consecutive runs of the 33-test file are identically green (the assertion is event-loop ordering, which Node's check phase makes deterministic, not wall-clock). Across the ~50 further executions of the file in the matrix, probes and suite runs, the new test's outcome matched the prediction for the production variant under test in every cell (that is what the 55/55 assertion total records). Reviewer Test Plan, step by step
Corrections (to the description, not requests to change code)C1 — description change #1 misattributes the isolation property. The description says the by-value capture is what makes "a stale exit-time release from one test … can't contaminate another test's spy". Measured against a contamination probe (create a session, fire C2 — the identity-guard sentence describes the wrong direction. "The listener-identity check … prevents a stale deferred release from re-attaching and disposing the wrong session." The check is By contrast, commit FindingsF1 — Suggestion (completeness, pre-existing on both arms): the new test pins "not synchronous" but not "after the poll phase", which is the property it is named forThe production comment's actual rationale for Reproduce (all three cells in node tmp/pr11585-verify-20260911-151316/probe.mjs # rows B1/B2/B3/B4
Adjudication: coverage gap, and a pre-existing one — M03 survives on both arms, so the PR introduced nothing wrong; it simply had the chance to close this door and did not. Per the mutation-matrix rule this is completeness reporting, not a merge condition: no guard the PR added is load-bearing on it, and production code is correct as written. It is worth a reviewer's attention because the gap sits exactly on the axis the new test's name advertises, which is where the next regression will land. Measured suggested fix: append one fixture (green at head, red under M03 and M01)Verified in a scratch copy: with the fixture appended, the file is 34/34 green at head (B1), red under it('lets output delivered from a queued I/O callback reach the scrollback', async () => {
osPlatform.mockReturnValue('win32');
const registry = new WebTerminalRegistry();
await registry.create({ terminalId: 'terminal:probe-poll', workspaceCwd: '/workspace' });
// node-pty delivers late PTY output from an I/O (poll-phase) callback that is
// already queued when onExit fires — not synchronously inside handleExit.
setImmediate(() => onData('trailing-poll'));
onExit({ exitCode: 0 });
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
expect(registry.readSnapshot('terminal:probe-poll')).toMatchObject({
output: 'trailing-poll',
exited: true,
});
});F2 — nit (description only): see Corrections C1/C2The two description sentences quoted there state mechanisms the code does not have. No code change is requested; leaving the description uncorrected would cost the next reader, since C1's property (cross-test isolation) is precisely the kind of thing a future author would "fix" again. F3 — nit: the Reviewer Test Plan's parenthetical predates the final commit"…fails if the release is made synchronous (listener detached before trailing output)". After Not covered
MethodologyEnvironment: CI verify container ( Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
@yiliang114 — this stops at the template gate, not on the code.
The body was rewritten when the PR was reduced to test-only, and the rewrite dropped four headings that .github/pull_request_template.md requires:
## Why it's needed### Evidence (Before & After)—N/Ais the right content for a test-only change, it just has to be present and said out loud rather than omitted## Risk & Scope## Linked Issues— worth filling in carefully: #11353 is already closed as completed by the merged #11572, so a non-closing reference ("follow-up to #11572") is the accurate form. AFixes/Closeskeyword here would point at an issue that is already closed.
Present and fine: ## What this PR does, ## Reviewer Test Plan, ### How to verify, ### Tested on. ### Environment (optional) is the only heading the template marks optional, so it is the only one legitimately absent.
To be explicit that this is a body problem and not a substance problem — here is what I checked against main before filing it:
- The deferred exit-time release really has landed:
handleExit(line 271 ofweb-terminal-registry.ts) ends insetImmediate(() => this.releasePtyResources(session)), andreleasePtyResources' own doc comment records that it is called fromhandleExitdeferred one turn. So the behaviour this PR sets out to pin exists onmain. - The trailing-output case is not on
main— noqueued behind onExittest and noexit-trailingterminal id there — so this is a genuine remaining delta after #11572 merged, not a re-post of work that already landed. - The mock-isolation change targets the right fake. The
onDatafake it rewrites is the describe-scope one insidebeforeEach; the two otheronDatafakes in the file are per-testspawn.mockReturnValueOnceoverrides insideit.skipIf(process.platform === 'win32')blocks and are correctly left alone.
Restore those four headings and re-run @qwen-code /triage — the review picks up from there. Note that the three stage comments already on this thread were written against c344ab78, when the PR still carried the production fix, so their findings describe a diff that no longer exists here; their Reviewed at footers mark them as stale.
中文说明
@yiliang114 —— 拦住这个 PR 的是模板闸门,不是代码。
本 PR 缩减为纯测试改动时正文被重写过,重写时丢掉了 .github/pull_request_template.md 要求的四个小节标题:
## Why it's needed### Evidence (Before & After)—— 对纯测试改动来说写N/A就是正确答案,但这个小节需要在,并明确写出来,而不是整节省略## Risk & Scope## Linked Issues—— 这一节需要认真填:#11353 已经由合并的 #11572 关闭(completed),所以准确的写法是不带关闭关键字的引用("follow-up to #11572")。在这里用Fixes/Closes关键字会指向一个已经关闭的 issue。
已经存在且没问题的:## What this PR does、## Reviewer Test Plan、### How to verify、### Tested on。模板里唯一标注 optional 的是 ### Environment (optional),所以它是唯一可以合法缺席的小节。
为了说清楚这是正文问题而不是实质问题,下面是我在提交这条意见之前对着 main 核对过的内容:
- 延迟释放确实已经合入:
handleExit(web-terminal-registry.ts第 271 行)以setImmediate(() => this.releasePtyResources(session))收尾,而releasePtyResources自己的文档注释也写明它由handleExit延迟一个 turn 调用。所以本 PR 想要锁定的行为在main上是存在的。 - trailing-output 这个用例在
main上并不存在 —— 那里既没有queued behind onExit测试,也没有exit-trailing这个 terminal id —— 所以在 #11572 合并之后,这是一个真实的剩余增量,不是把已合并内容重发一遍。 - mock 隔离那处改动改对了对象。它重写的
onDatafake 是beforeEach里 describe 作用域的那个共享 fake;文件中另外两个onDatafake 是it.skipIf(process.platform === 'win32')块内各自的spawn.mockReturnValueOnce覆盖,没有被改动,这是正确的。
把这四个小节补回来,然后重新触发 @qwen-code /triage,审查会从那一步继续。另外提醒一下:本 thread 上已有的三条 stage 评论是针对 c344ab78 写的,那时本 PR 还带着生产代码修复,所以它们的结论描述的是这里已不存在的 diff;它们的 Reviewed at 脚注已标明了这一点。
— Qwen Code · qwen3.8-max-2026-09-02
|
Restored the required PR template sections in English and Chinese, including N/A evidence for this test-only change, explicit risk/scope, and a non-closing follow-up reference to #11572. @qwen-code /triage |
|
@qwen-code /triage Re-running triage: the four required sections ( CI is green on |
|
No blocking findings. Approved. Approval blockers: none. Round-2 update (head
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Test Plan (not a blocker): 33 tests pass — this review observed 25441, 2025, 30783, 1016, 1997, 535, 7343 passed.
Convergence: round 4 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 1 (0 new). Findings keep coming back to the same files: packages/core/src/services/web-terminal-registry.test.ts (findings in round 2; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
已审查——无阻断问题。 建议见行内评论。
Test Plan(非阻断):33 tests pass — this review observed 25441, 2025, 30783, 1016, 1997, 535, 7343 passed。
收敛情况:第 4 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 1 条(其中 0 条首次提出)。发现反复回到同一批文件:packages/core/src/services/web-terminal-registry.test.ts(第 2 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.3)
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
APPROVE
已核对 head b1e4f307019d011af98dfc916a219a9fc7a6174e。
- 历史阻塞项(本 Review 在
d50127e563提出的 R2-1)已在b1e4f30701关闭:新测试在onExit与onData('trailing')之间加了expect(disposeData).not.toHaveBeenCalled(),直接钉住延迟本身。 - 在该 head 的隔离副本复核:基线
Tests 33 passed (33);仅摘掉 fake 的 detach →33 passed;释放改为同步 → 新测试变红;摘掉 detach 且释放改为同步 → 依然变红(此前这一组合是33/33全绿,即失效路径已封住)。回归测试的判别力不再依赖 mock 形态。 - required checks 全部完成且成功:
Test (ubuntu-latest, Node 22.x)、Lint & Static (ubuntu-latest, Node 22.x)、Integration Tests (no-AK, No Sandbox)、web-shell E2E Smoke (ubuntu-latest, Node 22.x)均为 success。上一轮web-shell E2E Smoke的失败重跑后转绿,且本 PR 只改一个 core 单测文件,与该 lane 无因果。 - 独立复查未发现新的 Critical;生产代码
web-terminal-registry.ts:296的延迟释放本次未改动。
遗留(不阻塞,请在合并前顺手处理):线程 R4-1 指出 PR 正文与 Reviewer Test Plan 仍把回归信号归于已被 b1e4f30 取代的两处(tick 之后的 toHaveBeenCalledOnce()、fake 的 listener 摘除),而未提到现在真正承载信号的 tick 之前那条断言;照正文去验证会得到一次全绿运行、误读成测试空转。请更新描述与 Test Plan 的中英文两处归因,代码无需改动。
Independent verification at
|
| file | base e40bf35eb932 |
head b1e4f307019d |
|---|---|---|
services/web-terminal-registry.test.ts |
b503fadd9631 (30 250 B) |
86bb8d20fc99 (32 030 B) |
services/web-terminal-registry.ts |
e0f48f10021b (20 400 B) |
unchanged by this PR |
Both base shas are byte-identical to git rev-parse HEAD:<path> in a main checkout, and both head shas match what GitHub reports for this head (recomputed as sha1(b'blob %d\0'+data), not trusted from the API field). The PR's production delta is empty (PRODUCTION COUNT = 0 of 1 files), so the base arm is the head arm for production code and the only variable across every arm below is the mutation named in its row. Production was re-hashed to e0f48f10021b… after every arm.
Results — 7 arms
| arm | change | result | assertion |
|---|---|---|---|
| A base | base test file, pristine production | 32 passed | exit-trailing occurrences = 0 (the new test does not exist at base) |
| B head | head test file, pristine production | 33 passed | — |
| M1 | production: setImmediate(() => this.releasePtyResources(session)) → synchronous call |
1 failed / 32 passed — the failure is the new test | :514 expected "spy" to not be called at all, but actually been called 1 times |
| M2 | production: that setImmediate line removed entirely |
3 failed / 30 passed — new test + 2 pre-existing | :441, :467, :518, each expected "spy" to be called once, but got 0 times |
| M3 | test fake: detach guard if (onData === listener) onData = () => {} at :82 removed |
33 passed / 0 failed | — |
| M4 | test fake: disposeDataSpy() → disposeData() at :83 (by-value capture removed) |
1 failed / 32 passed — frees an exited session at exit time, not at the idle reclaim; the new test passes |
:442 expected "spy" to be called once, but got 2 times |
| D | production: session.pty.releaseHost?.() removed (different line, same function) |
7 failed / 26 passed — the new test is not among them | — |
What this adds over the reviews already on this thread
chiga0's two predicted mutants are confirmed by execution, at the exact predicted lines. The round-2 approval traced statically — "If production release is made synchronous …expect(disposeData).not.toHaveBeenCalled()at line 514 FAILS" and "If thesetImmediatedefer is removed entirely …expect(disposeData).toHaveBeenCalledOnce()at line 518 FAILS" — and recorded "Not run: vitest execution". M1 fails at:514, M2 at:518. Both predictions hold.- The author's by-value-capture claim is confirmed. "with the capture removed,
frees an exited session at exit time, not at the idle reclaimgoes red (disposeDatacalled twice)" — M4 reproduces exactly that test name and exactlygot 2 times. - 🔴
R2-1is refuted by execution. It claims the detach guard at:82"is the only mechanism that letslets output queued behind onExit reach the scrollback before the releaseobserve the release at all". M3 removes that line and all 33 tests still pass. The test observes the release through the disposable's call count (disposeData), not through the listener wiring, so the detach is not load-bearing for it. This corroborates the in-file comment at:511-512("no signal in this test depends on it any more") andchiga0's "neither depends solely on the mock's detach guard" — and it means the follow-upR2-1asks for (gating that line) would gate nothing. - ⭐ A refinement neither review had:
:514and:518are discriminating but not equally specific. The approval says "Both assertions are independently discriminating", which is true; M1 vs M2 shows they are not interchangeable. M1 (defer made synchronous) breaks exactly one test — the new one — so:514uniquely isolates the defer axis. M2 (release removed altogether) breaks three, because two pre-existing tests also assert the release happens.:514is the load-bearing pin;:518is a second gate that is redundant with existing coverage on that axis. - ⭐ The new test is not a duplicate of the existing release-path coverage. Arm D perturbs a different line of the same
releasePtyResourcescall path and fails 7 pre-existing tests while the new test passes; M1 perturbs the defer and fails only the new test. The two mutants have disjoint failure sets, so the added test occupies a sensitivity cell none of the 32 pre-existing tests covers. - Arm A independently corroborates
R1-2. At base the file has 32 tests and zero occurrences ofexit-trailing, so the new test cannot have been "confirmed red on the unmodified base" — it did not exist there. Arm B (33 passed) also matches the lane's own count, which is a fidelity check on this harness.
Boundaries of this report
- Coverage is execution of one test file under
vitest, plus a full read of the diff and ofhandleExit/releasePtyResourcesin the head blob. No browser, daemon orserveroute was exercised — the changed file is a test, so there is no production behaviour in this PR to exercise. - The mutants perturb the registry, not
node-pty.R1-3's question — whether the pinned@lydell/node-ptycan deliver queuedonDataafterexitat all — is not addressed here and is untouched by this diff (the.tscomment that asserts it ismain's code at this head). The reworded in-file comment now states a registry invariant, which M1 shows is genuinely gated. - M1's failure stops at
:514, so theoutput: 'trailing'assertion at:519-522was never reached in that arm; only:514is witnessed as load-bearing under M1.
CI at this head: 143/143 check-runs fetched (items == total_count asserted, COMPLETE=True), reduced to latest attempt per lane name over 33 distinct lanes — 0 failures and 0 unfinished anywhere. Verification lanes green: Lint & Static (ubuntu-latest, Node 22.x), Test (ubuntu-latest, Node 22.x), Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke (ubuntu-latest, Node 22.x), Desktop Shell (ubuntu-22.04), Desktop Shell (windows-2022). Structural skips: Integration Tests (CLI, No Sandbox), Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), authorize, delay-automatic-review. Caution: without the latest-attempt reduction an earlier review-pr = failure reads as a live red lane.
Conclusion: no Critical. The diff is test-only (0 production files); the test it adds is non-vacuous, is gated by an assertion that fails at :514 when the production defer is removed, and is not a restatement of the existing release-path coverage; the scaffolding change to the shared fake breaks none of the 32 pre-existing tests (arm B 33/33, arm A 32/32). Approving on that basis, as of the state read immediately before posting this comment.
中文说明
工具说明:这不是 tmux 报告,该改动面也不存在可用的 tmux 路径。 WebTerminalRegistry 全仓库只在一处被构造 —— packages/cli/src/serve/server.ts:987 —— 并由 packages/cli/src/serve/routes/terminal.ts(qwen serve 的 REST 路由)消费,Ink TUI 完全不经过它,所以 tmux 里跑 qwen 观测不到本 PR 触及的任何东西。实际使用的工具是在临时树里对 src/services/web-terminal-registry.test.ts 做真实 vitest 执行。这也正好回答了绿色 Test lane 回答不了的问题:lane 只能证明测试通过,不能证明它锁住了什么。
Arm 纯净度(实测):两个文件的 base blob(b503fadd9631、e0f48f10021b)与 main 检出的 git rev-parse HEAD:<path> 逐字节相同;head blob(86bb8d20fc99)与 GitHub 报告值一致,且以 sha1(b'blob %d\0'+data) 自行重算核对,未直接信任 API 字段。本 PR 生产代码增量为 0(PRODUCTION COUNT = 0 of 1 files),因此 base arm 在生产代码上就等于 head arm,下表各 arm 之间唯一的变量就是该行标注的变异。每个 arm 结束后生产文件都重新哈希回 e0f48f10021b…。
结果(7 个 arm):A 基线 32 通过(exit-trailing 出现 0 次,即新测试在 base 上不存在);B head 33 通过;M1(生产端 setImmediate 改为同步调用)1 失败 / 32 通过,失败的正是新测试,断言在 :514;M2(整行 setImmediate 删除)3 失败 / 30 通过(:441、:467、:518);M3(测试桩 :82 的 detach 保护删除)33 通过 / 0 失败;M4(测试桩 :83 去掉按值捕获 disposeDataSpy)1 失败 / 32 通过,失败的是 frees an exited session at exit time, not at the idle reclaim,新测试通过;D(生产端删除 session.pty.releaseHost?.(),同函数不同行)7 失败 / 26 通过,新测试不在其中。
相对本 thread 已有评审新增的信息:
chiga0静态推演的两个变异被执行证实,且落在其预测的确切行号上。 该 approve 明确写了 "Not run: vitest execution";M1 在:514失败、M2 在:518失败,两条预测都成立。- 作者的按值捕获主张被证实:M4 复现出完全相同的测试名与
got 2 times。 - 🔴
R2-1被执行推翻。 它声称:82的 detach "是该测试能够观测到 release 的唯一机制",但 M3 删掉该行后 33 个测试全部通过。该测试是通过 disposable 的调用次数(disposeData)观测 release 的,与监听器是否解绑无关。这与文件内:511-512的注释("no signal in this test depends on it any more")以及chiga0的判断一致,也说明R2-1要求的后续"给该行加保护"实际上保护不到任何东西。 - ⭐ 一个两份评审都没有的细化:
:514与:518都有判别力,但特异性不同。 M1(改为同步)只打破新测试这一个,所以:514唯一地锁住了 defer 这条轴;M2(完全不释放)打破 3 个,因为另有两个既有测试也断言 release 会发生。:514是承重钉,:518在这条轴上与既有覆盖重复。 - ⭐ 新测试不是既有 release 覆盖的重复。 arm D 扰动同一条
releasePtyResources调用路径的另一行,7 个既有测试失败而新测试通过;M1 扰动 defer,只有新测试失败。两个变异的失败集合互不相交,说明新增测试占据了一个 32 个既有测试都没有覆盖的敏感格。 - arm A 独立佐证了
R1-2:base 上该文件只有 32 个测试、exit-trailing出现 0 次,所以新测试不可能"在未修改的 base 上被确认为红"——它在 base 上根本不存在。arm B 的 33 通过也与 lane 自身的数量一致,构成本 harness 的保真校验。
报告边界:覆盖范围是在 vitest 下执行该测试文件,外加对 diff 以及 head blob 中 handleExit / releasePtyResources 的完整阅读;没有跑浏览器、daemon 或 serve 路由——本 PR 改动的是测试文件,没有生产行为可供执行。变异扰动的是 registry 而不是 node-pty,因此 R1-3 的问题(所固定的 @lydell/node-pty 是否真的会在 exit 之后投递排队的 onData)未在本报告中处理,且与本 diff 无关(断言该点的 .ts 注释在此 head 上属于 main 的代码)。M1 的失败停在 :514,所以该 arm 中 :519-522 的 output: 'trailing' 断言未被执行到;M1 下被证实承重的只有 :514。
CI:143/143 check-run 全部抓取(断言 items == total_count,COMPLETE=True),按 lane 名取最新一次尝试归约(33 条),任何位置都无失败、无未完成。校验 lane 全绿:Lint & Static、Test (ubuntu-latest, Node 22.x)、Integration Tests (no-AK, No Sandbox)、web-shell E2E Smoke、Desktop Shell (ubuntu-22.04)、Desktop Shell (windows-2022);结构性 skip:Integration Tests (CLI, No Sandbox)、Test (macos-latest, Node 22.x)、Test (windows-latest, Node 22.x)、authorize、delay-automatic-review。提醒:不做最新尝试归约会把早期尝试的 review-pr = failure 读成当前红灯。
结论:未发现 Critical。 diff 为纯测试(0 个生产文件);新增测试非空洞,其承重断言在生产端 defer 被移除时于 :514 变红,且不是既有 release 覆盖的复述;对共享桩的改动未打破 32 个既有测试中的任何一个(arm B 33/33、arm A 32/32)。据此给出 approve(以本条评论发布前即时读到的状态为准)。
qqqys
left a comment
There was a problem hiding this comment.
Approved at b1e4f307019d011af98dfc916a219a9fc7a6174e, on the executed verification in the comment above rather than on a re-read of the diff alone.
Gate, as read immediately before posting this review:
- Approval at head —
chiga0 APPROVED@14:41:36Z,qwen-code-ci-bot APPROVED@15:14:07Z andqwen-code-dev-bot APPROVED@15:24:21Z, all threecommit_id == b1e4f307019d. The bot's earlierCHANGES_REQUESTED@14:22:28Z at this same head was a PR-template gate on the body ("this stops at the template gate, not on the code"), which the author closed at 14:33:04Z, so no code finding stands. - CI at head — 143/143 check-runs fetched (
items == total_countasserted), reduced to the latest attempt per lane name over 33 distinct lanes: 0 failures, 0 unfinished. - No Critical from us — the diff is test-only:
PRODUCTION COUNT = 0 of 1 files, the single changed file beingpackages/core/src/services/web-terminal-registry.test.ts.
Why a separate verification comment on a test-only PR: a green Test lane certifies that the added test passes, not that it pins anything. So it was executed against four mutations. Making the production release synchronous fails exactly one test — the new one — at :514. Removing the fake's detach guard at :82, which R2-1 calls "the only mechanism that lets" this test observe the release, leaves all 33 green, so that thread's premise does not hold. Removing releaseHost?.() fails 7 pre-existing tests while the new one passes, so the two failure sets are disjoint and the added test is not a restatement of existing release-path coverage.
Non-blocking, and not a request: :514 and :518 are both discriminating, but only :514 is specific to the defer axis — the :518 failure mode is also caught by two pre-existing tests.
This approval is scoped to head b1e4f307019d…. A further push, or a superseding verdict at a new head, voids it.




test(core): pin the deferred exit-time PTY release against trailing output
What this PR does
Test-only changes to
web-terminal-registry.test.ts. The production exit-time release logic already landed on main via #11572; this PR pins it with tests and fixes the test scaffolding.Two changes:
Mock isolation fix: the shared
onData/onExitdescribe-scope fake'sdisposeDataspy is captured by value at registration time, so a stale exit-time release from one test can't contaminate another test's spy. The listener-identity check in the dispose closure prevents a stale deferred release from re-attaching and disposing the wrong session.New trailing-output test: pins the one-tick defer behavior — output queued behind
onExitstill reaches the scrollback before the release runs. The load-bearing regression signal is the intermediateexpect(disposeData).not.toHaveBeenCalled()beforeonData; the post-tickdisposeDataassertion verifies eventual cleanup.Why it's needed
The production fix in #11572 already landed, but the trailing-output ordering and test-fake isolation still need regression coverage. This follow-up keeps the deferred-release contract observable without changing runtime code.
Reviewer Test Plan
How to verify
cd packages/core && npx vitest run src/services/web-terminal-registry.test.ts— all 33 tests pass.The trailing-output test fails if the release is made synchronous: the intermediate no-dispose assertion catches the listener being detached before trailing output. The post-tick assertion confirms the deferred release still happens.
Evidence (Before & After)
N/A — this PR changes tests only. The reviewed head passes all 33 tests, and the added timing assertion fails if release occurs before trailing output.
Tested on
Risk & Scope
Linked Issues
Follow-up to #11572; issue #11353 was resolved by that production PR.
中文说明
这个 PR 做了什么
仅修改测试。退出时释放的生产代码已通过 #11572 合入 main;本 PR 为 trailing-output 顺序和测试 fake 隔离补充回归覆盖,在不改变运行时代码的前提下锁定延迟释放契约。
两处改动:
Mock 隔离修复:共享的
onData/onExitdescribe-scope fake 的disposeDataspy 在注册时按值捕获,避免一个测试的退出时释放污染另一个测试的 spy。dispose 闭包中的 listener 身份检查防止陈旧的延迟释放重新挂接并释放错误的会话。新增 trailing-output 测试:锁定一个 tick 的延迟行为——
onExit之后排队的输出在释放运行前仍能进入 scrollback。真正承重的回归信号是onData之前的expect(disposeData).not.toHaveBeenCalled();tick 之后的disposeData断言只用于确认最终清理仍会发生。为什么需要
生产修复已随 #11572 合入,但 trailing-output 的时序和测试 fake 的隔离仍需要回归覆盖。本跟进 PR 让延迟释放契约可被测试观察,同时不改变运行时代码。
评审者测试计划
如何验证
cd packages/core && npx vitest run src/services/web-terminal-registry.test.ts—— 33 个测试全部通过。如果释放改为同步,
onData之前的未调用断言会立即失败;tick 之后的断言则确认延迟释放最终仍会执行。证据(修复前后)
N/A —— 本 PR 仅修改测试。当前 head 下 33 个测试全部通过;新增的时序断言会在释放早于尾部输出时变红。
测试平台
风险与范围
关联 Issue
跟进 #11572;问题 #11353 已由该生产 PR 解决。