fix(core): free an exited web terminal's PTY resources at exit time - #11572
Conversation
WebTerminalRegistry released PTY resources only in release(). handleExit set `exited`/`exitCode` and notified the exit listeners, touching nothing else, and the browser route deliberately keeps an exited session alive for scrollback replay (finishExited leaves releaseAfterReplay at its false default). The client will not release earlier either: a live exit closes the socket with 4000, which is non-retryable, so only a tab close, a workspace drain, dispose() or the 15-minute idle reclaim ever freed the PTY. So every exited web terminal held node-pty's conout worker - and, upstream, its conhost.exe - for up to IDLE_RECLAIM_MS. Exited sessions are also excluded from the admission cap on purpose, so accumulation inside that window was unbounded. Extract the PTY-resource half of release() into releasePtyResources() and call it from handleExit, deferred one setImmediate so the trailing onData callbacks node-pty may still have queued reach the buffer first - the same race shellExecutionService drains before finalizing. The helper keeps the session's map entry and its buffer, so readSnapshot() replay and the route's releaseAfterReplay path are unaffected, and it never signals the pid: the shell is gone and its pid may be recycled, which is why #11313 added releaseHost instead of reusing kill(). A per-session flag keeps a later release() from disposing anything twice. release() now routes both arms through the helper. killPtyTree stays on the live arm only and still runs first, so releaseHost keeps seeing the close the wrapper noted. Everything between the old detach site and the new one is synchronous (spawnSync, process.kill, pty.kill), so no onData callback can interleave and the live path's observable behaviour is unchanged. Adds three tests: resources freed at exit time rather than at the reclaim, scrollback still replayable afterwards, and no double free when release() follows. Verified at the mechanism level on Linux with os.platform() mocked to win32; actual conout-worker and conhost.exe reclamation on a real Windows ConPTY is not verified here, and #11352's upstream close defect is untouched. Fixes #11353 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Patrol-Run: qwen-issue-patrol/jmtvhcstvvk
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ — every required heading is present, and the Chinese mirror tracks the English section for section. Problem: observed, not theoretical. I checked the base rather than taking the description's word for it: Direction: aligned. Freeing a dead shell's PTY at exit instead of 15 minutes later is the obvious direction, and the reference CHANGELOG fixes this class repeatedly — orphaned Size: core paths ( Approach: the scope feels right, and I arrived at the same shape independently before reading the diff — tear the PTY-side resources down at exit, keep the session object and its buffer in the map for replay, share one helper with Two things I'd flag rather than block on. First, Risk: no Stage 1e high-risk path match. The elevated part is platform coverage, not code shape: the effect is Windows-only and Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必需标题都在,中文部分与英文逐段对应。 问题: 是已观测到的问题,不是理论性加固。我没有只信 PR 描述,而是核对了 base: 方向: 对齐。把已死 shell 的 PTY 在退出时就释放、而不是等 15 分钟,是显而易见的方向;参考产品的 CHANGELOG 也反复修这一类问题——守护进程退出后空转的 规模: 触及核心路径( 方案: 范围合理。我在看 diff 之前独立想到的也是同一个形状——退出时拆掉 PTY 侧资源、把 session 对象和 buffer 留在 map 里供回放、与 有两点我想指出但不作为阻塞。第一, 风险: Stage 1e 的高风险路径没有命中。需要关注的不是代码形态而是平台覆盖:效果仅限 Windows,而该 commit 上 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote my own proposal before opening the diff — free the PTY-side resources in No blockers found. The things I went looking for, and what I found: Use-after-release of Exactly-once, across all six ways The deferred callback captures the session object, not the The reordering in Throw safety, now that the call sits in a The exited-and-never-ready case. Downstream consumers, named. sequenceDiagram
participant P1 as node-pty (shell exit)
participant P2 as handleExit
participant P3 as releasePtyResources
participant P4 as session map and buffer
participant P5 as route terminal.ts
P1->>P2: onExit (exitCode)
P2->>P4: mark exited, notify listeners
P2->>P3: setImmediate (one turn later)
P3->>P3: set flag, detach listeners, releaseHost
P3-->>P4: entry and buffer kept for replay
P5->>P4: reconnect, readSnapshot (replay)
P5->>P3: release after replay
P3-->>P5: flag already set, no second dispose
On the tests. The three new ones have teeth, and I checked the assertions rather than trusting the counts. Test evidenceThis was an unattended CI run, so I did not build, run, or execute any PR-derived code — the evidence below is the PR's own CI read through the API for commit 70 check-runs on the head SHA. No failures, so there is no log excerpt to quote. The Linux unit suite, lint/static, and integration tests were still in progress at review time — I did not poll for them, so their outcome is unknown to me and the table says so. Two rows matter for judging this PR: Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 What the suite can and cannot settle: the Linux tests mock Sandboxed verification would settle the one claim that is currently reasoning rather than measurement: Real-scenario tmux testing: N/A — unattended CI run, and no user-visible surface changed. 中文说明代码审查我在打开 diff 之前先写了自己的方案——在 未发现阻塞问题。 我特意去找的几处,以及结果: 释放后仍被使用的 "恰好一次",覆盖 延后回调捕获的是 session 对象,不是
抛错安全性,现在这个调用位于 "已退出且从未 ready"的情况。 下游消费者,逐一点名。 (时序图见上方,中文不重复。) 关于测试。 三个新测试是有牙的,我核对的是断言本身而不是数字。 测试证据这是一次无人值守的 CI 运行,所以我没有构建、运行或执行任何来自 PR 的代码——下面的证据是通过 API 读取的、commit head SHA 上共 70 个 check-run。没有失败,因此没有日志片段可引。Linux 单元测试、lint/静态检查、集成测试在审查时仍在进行中——我没有轮询等待,所以它们的结果我并不知道,表格里如实标注。有两行对判断这个 PR 很重要: (CI 表格见上方机器可读区域,中文不重复。) 套件能与不能证明的:Linux 测试把 沙箱验证可以定下目前仍属推理而非实测的那一条: 真实场景 tmux 测试:N/A —— 无人值守 CI 运行,且没有用户可见界面变化。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the mechanism is verified and the design matches what I'd have written; the one point I'm withholding is that nobody can measure the actual Windows reclamation from here, and that gap is real even though it isn't this PR's fault. Stepping back. My independent proposal and this diff converged on the same shape, which is usually a sign the problem was framed correctly rather than that I anchored on the description — I wrote mine from the title and the "Why it's needed" section before opening the diff. The change is smaller than the analysis behind it, which is the right ratio: one helper, one flag, one deferred call, and a comment that moved because the code it describes moved. The question I had to settle before approving was whether I'm comfortable signing off on a fix whose effect I cannot observe. I am, because the claim decomposes cleanly into two parts and only one of them is unobservable. Whether Two residuals, neither blocking, both worth having on the record: The first is the trailing-output drain, which Stage 2 goes into. The author disclosed it, it is strictly narrower than the zero-delay dispose The second is about what On pattern: this is the third PR in a coherent series on one real leak (#11313 merged, #11352 open and blocked upstream on microsoft/node-pty#965, this one), each narrowly scoped and cross-referenced, and this one was deliberately split out of a review so it could stay small. That's the opposite of volume-farming, and I evaluated it on its merits without the series counting for or against it. Verdict: approve — deferred. The PR's own CI was still running on this commit at review time (unit suite, lint/static, and integration tests all in progress), and I'm not going to attest to a result that doesn't exist yet. Approval is deferred until CI lands green on 中文说明信心:4/5 —— 机制已核实,设计与我自己会写的方案一致;扣掉的一分是因为在这里没人能实测 Windows 上的真实资源回收,这个缺口是真实存在的,尽管它不是本 PR 的过错。 退一步看。我独立提出的方案与这个 diff 收敛到了同一个形状,这通常说明问题本身被正确地界定了,而不是说明我被描述带偏了——我的方案是在打开 diff 之前,仅凭标题和"为什么需要"一节写下的。这个改动比它背后的分析要小,而这是正确的比例:一个辅助函数、一个标志、一次延后调用,外加一段因为它所描述的代码移动而移动的注释。 在批准之前我必须想清楚的一个问题是:对一个我无法观测其效果的修复签字,我是否安心。我安心,因为这个主张可以干净地拆成两部分,而只有其中一部分不可观测。 两点残留,都不阻塞,但都值得记录在案: 第一是尾部输出排空,Stage 2 已经展开。作者主动披露了它;它的范围严格小于 第二关系到 关于"是否成串":这是围绕同一个真实泄漏的第三个 PR(#11313 已合并,#11352 处于 open 且上游阻塞于 microsoft/node-pty#965,以及本 PR),每个都范围收窄并相互引用,而这一个是从一次 review 里特意拆出来的,为的是保持小。这与刷量恰恰相反;我按它本身的价值评估,这一系列既不加分也不减分。 结论:批准——延后。 审查时该 commit 上 PR 自己的 CI 仍在运行(单元测试套件、lint/静态检查、集成测试均在进行中),而我不打算为一个尚不存在的结果背书。批准延后至 CI 在 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed. Suggestions are inline.
Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI (gated to merge_group/schedule/workflow_dispatch, never pull_request) and its suite could not run locally: this change's releaseHost path is win32-only (conpty-host.ts returns early off win32) and the review ran on Linux.
Test Plan (not a blocker): src/services/web-terminal-registry.test.ts — no such file or directory; src/services/web-terminal-registry.test.ts:428:27 — no such file or directory; src/services/web-terminal-registry.test.ts:454:27 — no such file or directory; 30 passed — this review observed 24916, 2013, 30339, 300, 1842, 519, 7221 passed; 31 passed — this review observed 24916, 2013, 30339, 300, 1842, 519, 7221 passed; and 1 more.
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) was skipped in CI (gated to merge_group/schedule/workflow_dispatch, never pull_request) and its suite could not run locally: this change's releaseHost path is win32-only (conpty-host.ts returns early off win32) and the review ran on Linux.
Test Plan(非阻断):src/services/web-terminal-registry.test.ts — no such file or directory; src/services/web-terminal-registry.test.ts:428:27 — no such file or directory; src/services/web-terminal-registry.test.ts:454:27 — no such file or directory; 30 passed — this review observed 24916, 2013, 30339, 300, 1842, 519, 7221 passed; 31 passed — this review observed 24916, 2013, 30339, 300, 1842, 519, 7221 passed; and 1 more。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| // synchronous, so one turn is enough — there is no chain to flush. | ||
| setImmediate(() => this.releasePtyResources(session)); |
There was a problem hiding this comment.
[Suggestion] Nothing in the suite distinguishes this deferred release from an inline one, so the one-turn deferral that the six-line comment above it exists to justify is unenforced. Replacing this line with a direct this.releasePtyResources(session); leaves all 32 tests passing, which means a later simplification pass can inline the async hop — the shorter, more obvious code — and silently drop the trailing-output protection that comment argues for, with the suite, the typecheck and lint all staying green. No other suite can catch it either: web-terminal-registry.test.ts is the only test that exercises the real registry, because packages/cli/src/serve/routes/terminal.test.ts:51-60 builds a stub (} as unknown as WebTerminalRegistry;).
Witness:
INTACT (setImmediate(() => this.releasePtyResources(session));) : Tests 32 passed (32)
MUTANT (this.releasePtyResources(session);) : Tests 32 passed (32)
flip-check, against a harness whose disposable really detaches:
MUTANT: x late data still reaches readSnapshot()
- "output": "boot\r\nLATE-TRAILING\r\n"
+ "output": "boot\r\n"
INTACT: 2 passed (tree restored, diff -q-identical to 88718ce9)
The fix is a test that emits data after onExit and asserts it still reaches readSnapshot(...).output. It only pins anything if the harness's data disposable actually detaches — today disposeData is a bare vi.fn() (web-terminal-registry.test.ts:53, :72), so onData(...) still reaches handleData after "disposal" and the assertion passes on both the intact and the inlined arm:
onData: vi.fn((listener) => {
onData = listener;
return {
dispose: () => {
onData = () => {};
disposeData();
},
};
}),Then assert on the buffered output rather than on a spy call count.
That new case must go red when setImmediate(() => this.releasePtyResources(session)); is replaced by a direct call — please confirm the mutation when you add it, because with the shipped non-detaching harness the same assertion passes on both arms and pins nothing.
中文说明
套件里没有任何测试能区分「延后一轮释放」与「同步立即释放」,因此上方那段六行注释所要论证的延后本身并没有被钉住。把这一行换成直接调用 this.releasePtyResources(session);,32 个测试依然全部通过;这意味着后来一次「简化」改动完全可以把这个异步跳转内联掉(那是更短、更直观的写法),并悄无声息地移除该注释所论证的尾部输出保护,而测试套件、typecheck 与 lint 全绿。也没有别的套件能抓到它:web-terminal-registry.test.ts 是唯一真正驱动 registry 的测试,因为 packages/cli/src/serve/routes/terminal.test.ts:51-60 构造的是桩对象(} as unknown as WebTerminalRegistry;)。
证据(本次评审实测):
原始代码 (setImmediate(() => this.releasePtyResources(session));) : Tests 32 passed (32)
变异体 (this.releasePtyResources(session);) : Tests 32 passed (32)
翻转校验,使用真正会摘除监听器的脚手架:
变异体: x 退出后投递的数据仍能进入 readSnapshot()
- "output": "boot\r\nLATE-TRAILING\r\n"
+ "output": "boot\r\n"
原始代码: 2 passed (文件已还原,diff -q 与 88718ce9 一致)
修复方式是补一个测试:在 onExit 之后投递数据,并断言它仍然出现在 readSnapshot(...).output 里。但只有在测试脚手架的 data disposable 真正摘除监听器时它才有约束力——目前 disposeData 只是一个裸 vi.fn()(web-terminal-registry.test.ts:53、:72),所以「dispose」之后 onData(...) 仍会进入 handleData,该断言在原始代码和内联变异体上都会通过。请让脚手架建模真实的摘除行为,然后断言缓冲区里的输出,而不是断言 spy 的调用次数。
新测试必须在把 setImmediate(() => this.releasePtyResources(session)); 换成直接调用时变红——添加时请顺手验证这个变异,因为在现有不摘除监听器的脚手架下,同一断言两边都通过,什么也钉不住。
— qwen3.8-max via Qwen Code /review (v0.23.2)
| * Called from `handleExit` (deferred one turn, so an exited web terminal | ||
| * stops holding the worker for the whole idle-reclaim window — #11353) and | ||
| * from `release()` on both of its arms, where the flag keeps a release that |
There was a problem hiding this comment.
[Suggestion] Collapsing release()'s two arms into one unconditional helper call deletes the else arm that the releaseHost call-site inventory in packages/core/src/services/conpty-host.ts still names as "the primary web-terminal path for #11303" — and that inventory is exactly the checklist conpty-host.ts:19-20 tells a node-pty bump to re-walk ("A bump therefore has to be re-checked against src/win/conpty.cc, not only against the JS shape"). The next maintainer bumping @lydell/node-pty walks that list, looks for release()'s else arm in this file, cannot find it, and then either treats the paragraph as describing dead code or never evaluates the site that now handles every natural exit — the one caller that reaches releaseHost's _isReady === false branch with no queued kill() behind it. So a native-semantics change on the post-onExit path, such as the std::erase_if baton change conpty-host.ts:18-21 already flags, ships unreviewed for the web terminal's main path.
Witness:
sweep: 7 sites across 3 files describe release()'s arm shape; 2 outright falsified
oracle: post-diff release() read at web-terminal-registry.ts:488-499
if (!session.exited) { ... killPtyTree(session.pty); }
this.releasePtyResources(session); <- unconditional, no else
vs the base if/else at :456-484
conpty-host.ts:135 "`else` arm" deleted construct
web-terminal-registry.test.ts:367 "the whole point of the else branch" deleted construct
conpty-host.ts:136 "the primary web-terminal path for #11303" caller mislabelled
web-terminal-registry.test.ts:321 "release() calls ...releaseHost?.() bare" now indirect
web-terminal-registry.ts:332-334 "reaches it from BOTH arms" vocabulary drift
conpty-host.ts:144 "from the live arm AND the exited arm" vocabulary drift
web-terminal-registry.ts:537 "from release() on both of its arms" drift (this line)
Please retarget these in the same change. In conpty-host.ts:134-136 and :142-146, replace "release()'s else arm" / "the exited arm" with the exit-time path (handleExit → deferred releasePtyResources → releaseHost), keeping release() listed as the secondary caller. At web-terminal-registry.ts:332-334, say releaseHost is reached from releasePtyResources, which handleExit (deferred) and release() both call under the ptyResourcesReleased flag. And drop the release()-arm references at web-terminal-registry.test.ts:355 and :367. This JSDoc already names handleExit as a caller, so only its "both of its arms" phrasing needs the same touch.
One constraint on the rewrite: the new exit-time site must stay in conpty-host.ts's native no-op group rather than its close group, per conpty-host.ts:128-131 — "In src/win/conpty.cc the native exit-watcher thread erases the pty baton before it delivers the JS onExit, and PtyKill skips ClosePseudoConsole when get_pty_baton returns null" — and the exit-time release runs strictly after onExit. The rewritten enumeration must therefore keep conpty-host.ts:147-148 ("The inbox conhost half of #11303 is therefore not fixed by this function on the natural-exit path") true of it.
中文说明
把 release() 的两个分支收敛成一次无条件的辅助函数调用,删掉了那个 else 分支;而 packages/core/src/services/conpty-host.ts 里的 releaseHost 调用点清单至今仍把它称作「#11303 的 web-terminal 主路径」——而那份清单正是 conpty-host.ts:19-20 要求升级 node-pty 时必须重新走一遍的检查表(「A bump therefore has to be re-checked against src/win/conpty.cc, not only against the JS shape」)。下一位升级 @lydell/node-pty 的维护者照着那份清单来找本文件里 release() 的 else 分支,找不到,于是要么把那段话当成在描述已死的代码,要么根本没有评估那个如今承接每一次自然退出的调用点——也就是唯一一个在身后没有排队 kill() 的情况下进入 releaseHost 的 _isReady === false 分支的调用者。这样一来,onExit 之后那条路径上的原生语义变化(例如 conpty-host.ts:18-21 已经点出的 std::erase_if baton 改动)就会在 web terminal 的主路径上未经复核地随升级发布。
证据(逐行读取核对,未采信引用):
扫描:3 个文件中共 7 处描述 release() 的分支形态;其中 2 处被彻底证伪
判据:改动后的 release(),读自 web-terminal-registry.ts:488-499
if (!session.exited) { ... killPtyTree(session.pty); }
this.releasePtyResources(session); <- 无条件调用,没有 else
对比 base 上 :456-484 的 if/else
conpty-host.ts:135 「`else` arm」 已删除的结构
web-terminal-registry.test.ts:367 「the whole point of the else branch」 已删除的结构
conpty-host.ts:136 「the primary web-terminal path for #11303」调用者标注错误
web-terminal-registry.test.ts:321 「release() calls ...releaseHost?.() bare」 现已是间接调用
web-terminal-registry.ts:332-334 「reaches it from BOTH arms」 措辞过时
conpty-host.ts:144 「from the live arm AND the exited arm」 措辞过时
web-terminal-registry.ts:537 「from release() on both of its arms」 措辞过时(即本行)
请在同一次改动里把这些引用改到当前的控制流上。在 conpty-host.ts:134-136 与 :142-146,把「release() 的 else 分支」/「exited 分支」替换为退出时路径(handleExit → 延后的 releasePtyResources → releaseHost),并把 release() 保留为次要调用者。在 web-terminal-registry.ts:332-334,说明 releaseHost 是由 releasePtyResources 到达的,而 handleExit(延后)与 release() 都在 ptyResourcesReleased 标志保护下调用它。同时删去 web-terminal-registry.test.ts:355 与 :367 对 release() 分支的引用。本段 JSDoc 已经点名 handleExit 是调用者,因此只有「both of its arms」这一措辞需要同样处理。
改写时有一条约束:这个新的退出时调用点必须留在 conpty-host.ts 的原生空操作那一组,而不是关闭那一组,依据是 conpty-host.ts:128-131——「In src/win/conpty.cc the native exit-watcher thread erases the pty baton before it delivers the JS onExit, and PtyKill skips ClosePseudoConsole when get_pty_baton returns null」——而退出时释放严格发生在 onExit 之后。因此改写后的清单必须让 conpty-host.ts:147-148(「The inbox conhost half of #11303 is therefore not fixed by this function on the natural-exit path」)对它依然成立。
— qwen3.8-max via Qwen Code /review (v0.23.2)
doudouOUC
left a comment
There was a problem hiding this comment.
Review at head 88718ce9a6ad57ba3099a8a3f0c9721c92a5c206 — verdict: no Criticals, 3 Suggestions. But this looks superseded by #11585.
Superseded
#11585 is the same author, same file, functionally the same fix, opened about an hour later, and strictly better: it adds the regression test that pins the deferral, adds .unref?.() on the timer, and makes the flag optional. Your own triage comment on #11585 says as much. The only argument for landing this one instead is that it is already approved and green — and that costs the regression test.
Suggestions
packages/core/src/services/web-terminal-registry.ts:286—setImmediate(() => this.releasePtyResources(session));without.unref?.(), so the timer can briefly hold the event loop open at shutdown. #11585 has the.unref?.().- Same single-tick-vs-two-turn-drain gap as #11585:
packages/core/src/services/shellExecutionService.ts:1927-1935drains twice (flushChain().then(drain).then(drain)) precisely because one tick can leave queued PTY bytes unflushed, so a tail of scrollback can still be dropped. packages/core/src/services/conpty-host.ts— thereleaseHostcall-site inventory comment still callsrelease()'selsearm "the primary web-terminal path for #11303", the arm this PR deletes. Stale comment.
Re-check of the existing C=0 APPROVE
Both of its inline Suggestions still stand at this head: neither touches conpty-host.ts, and the deferral is still untested here. Neither is Critical-severity.
Basis for the no-Critical verdict
The helper is byte-identical to #11585's apart from the flag name (ptyResourcesReleased, non-optional), with the same single-writer/single-reader read-site result — not a dead switch. Same verification for double-free, recycled-pid safety, listener-clear ordering, replay/admission/idle-reclaim invariants, and the write()/resize() short-circuits.
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.
Maintainer runtime verification — built a real environment for this oneVerdict: the change does what it claims, and I could not break it. One small test-coverage gap worth closing before merge (2 lines, patch below); nothing blocking. The PR is honest that it is a Windows resource fix proved only through a mocked PTY, so I did not re-run its unit tests and stop there. I built an A/B harness that drives the real compiled registry against a real node-pty, a real How the harness works (why an OS-level measurement is possible on Linux)
1. Does it actually free the PTY at exit time? Yes — measured, not assertedNothing calls
Real browser, real The accumulation argument in the PR body holds up too. The admission cap is 8, exited sessions are excluded from it, so churning terminals piles up without bound inside the window:
2. Does it break scrollback replay? NoThe real risk here is the one the PR flags as reasoned-but-unmeasured, so I measured it.
On the trailing-output drain specifically. node-pty's Reviewer warning about a flake you will hit. ~2 runs in 24 (both arms, uncorrelated) end with a short stream — node-pty itself delivered fewer bytes, its 200 ms 3. The
|
| BEFORE | AFTER | |
|---|---|---|
| call order | dataDispose → exitDispose → ptyKill |
ptyKill → dataDispose → exitDispose |
| shell pid alive afterwards | no | no |
releaseHost → native close / conout dispose |
0 / 0 (releasedHosts short-circuit) |
0 / 0 |
| session removed from the map | yes | yes |
no onExit interleaved between kill and dispose |
— | confirmed (every step in between is synchronous) |
Also audited every remaining use of the released handle: write() (:438) and resize() (:450) both short-circuit on session.exited, and killPtyTree (:497) only runs on the live arm. There is no use-after-release path.
4. Test teeth — an 8-mutant sweep, and the one gap
Red/green reproduces exactly as claimed: base source + this PR's tests → 2 failed | 30 passed; with the fix → 32 passed.
| Mutant | Result |
|---|---|
drop if (session.ptyResourcesReleased) return; |
1 failed ✅ (author's claim confirmed) |
setImmediate(...) → inline call |
32 passed ❌ — not covered |
releasePtyResources before killPtyTree |
3 failed ✅ |
drop dataDisposable.dispose() |
4 failed ✅ |
drop exitDisposable.dispose() |
4 failed ✅ |
drop pty.releaseHost() |
7 failed ✅ |
ptyResourcesReleased: true at creation |
8 failed ✅ |
setImmediate → setTimeout(…, IDLE_RECLAIM_MS) |
2 failed ✅ |
Only one hole: the deferral itself — the PR's whole risk mitigation — is not pinned by any test. A future "simplification" to an inline call would be green. Two lines in the first new test fix it, and I verified they have teeth (unmutated 32 passed; with the inline mutant 1 failed | 31 passed, expected "spy" to not be called at all, but actually been called 1 times):
onExit({ exitCode: 0 });
+ // The release is deferred one turn on purpose, so late PTY data still
+ // reaches `buffer` before the data listener is detached.
+ expect(conoutDispose).not.toHaveBeenCalled();
+ expect(disposeData).not.toHaveBeenCalled();
// One turn of the event loop, on real timers: the 15-minute idle reclaimWorth noting for the record that the third new test (does not free an exited session twice…) is vacuously green on the base SHA — it guards the new flag rather than proving the fix, which is correct, just not part of the red/green evidence.
5. Static checks
tsc --noEmit produces an identical error set on both arms (61, all pre-existing @types/node drift in unrelated test files, none in the two changed files) → this PR adds zero type errors. prettier --check clean. eslint reports 3 errors in the test file, but the same 3 at the same code on the base SHA (line numbers shifted by the +77 additions) — pre-existing, not introduced here.
6. Scope notes — not defects, just what a reader should not assume is fixed
- The JS-side accumulation is unchanged: 40 exited sessions stay in the map with their buffers (up to 4 MB each) for the full 15 minutes, and the
!session.exitedcap filter means that is still unbounded. This PR closes the native-handle feeder only, which is what it says it does; the memory half of [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 remains. - On the live release arm the conout worker is deliberately not disposed (
kill()records the note →releaseConPtyHostearly-returns) — that is fix(core): release node-pty's conout worker after every PTY on Windows #11313's "the queuedkill()stays the single closer" design, unchanged here, and my BEFORE/AFTER numbers match on it. Don't readworkers aliveon the live arm as a leak. - Everything above is mechanism-level. Actual
conhost.exereclamation on a real Windows ConPTY still needs a Windows run, and PR CI skips Windows. What I can say is that the release now happens at exit, exactly once, at the correct point in the sequence, and that the paths reachingreleaseConPtyHost/disposeConoutWorkerare the ones the PR describes.
LGTM for merge once the two-line deferral assertion is added (or explicitly declined).
中文版
维护者运行时验证 —— 为此专门搭了一套真实环境
结论:改动确实做到了它声称的事,我没能把它弄坏。合并前建议补一个很小的测试覆盖缺口(2 行,见下方补丁);不是阻塞项。
PR 本身很坦诚地说明这是一个 Windows 资源修复、只能通过 mock PTY 来证明,所以我没有停留在重跑它的单测上。我搭了一套 A/B 环境,用真实编译产物中的 registry,配真实 node-pty、真实 serve /terminal WebSocket 路由、以及真实浏览器里跑真实的 web-shell TerminalPanel,并且把 vi.fn() 的 conout worker 换成了真实的操作系统线程——这样"PTY 到底被释放了没有"就从一个 spy 断言变成了一次实测。
环境是怎么搭的(为什么在 Linux 上也能做 OS 级别的测量)
- 同一个 worktree 出两份 dist:
AFTER=88718ce9a6,BEFORE= 同一棵树上只把web-terminal-registry.ts回退到ac1edef97。两臂之间只有packages/core/dist/src/services/web-terminal-registry.js不同,其余(harness、主机、node-pty、shell)逐字节相同。 - 不对产品代码做任何插桩。只在导入 dist 之前打了两处「被测代码之外」的补丁:
require('node:os').platform → () => 'win32',让真实的conpty-host.tswin32 分支(releaseConPtyHost/disposeConoutWorker)真正执行。killPtyTree与resolveWebTerminalShell读的是process.platform,所以仍走 POSIX,shell 是真实的bash。- 包装
@lydell/node-pty的spawn,挂上一个 Windows 形状的_agent,其_conoutSocketWorker.dispose()会终止一个真实的worker_threads.Worker;同时接管真实的onData/onExitdisposable(node-pty 的 disposable 是真的会摘除监听器的,测试里的 fake 不会)。
- 因此「仍存活的 conout worker 数」是从真实的
worker.on('exit')统计出来的,并与/proc/self/task交叉核对。 - 路由用的是
packages/cli/src/serve/routes/terminal.ts里真实的createTerminalWsHandler(它没有任何运行时 import,可以独立 esbuild 打包),挂在真实的wsserver 上;浏览器页面通过 Vite 挂载真实的TerminalPanel.tsx,只把useWorkspace()换成指向 harness 的桩。Chromium 1228 + Playwright。
1. 真的在退出时释放了 PTY 吗?是 —— 实测,而非断言
所有这些跑法里都没有任何一处调用 release(),也没有推进任何时钟,所以 15 分钟的空闲回收可证明不可能触发。
| 场景(Windows 形状的 agent + 真实 worker 线程) | BEFORE ac1edef97 |
AFTER(本 PR) |
|---|---|---|
| 8 个终端自然退出 → 仍存活的 conout worker | 8 | 0 |
…进程线程数(/proc/self/task),基线 7 |
19 | 11 |
…从 onExit 到 worker 被释放的耗时 |
从不释放 | 0.30 – 0.37 ms |
| …之后 snapshot 仍可回放 | 8 / 8 | 8 / 8 |
shell 在首个输出字节之前就退出(_isReady === false) |
worker 存活,0 次 dispose | 经 disposeConoutWorker 释放,且正确跳过 native close |
退出后再对已释放会话执行关闭标签页的 release() |
0 → 1 次 dispose | 1 → 1(恰好一次;_ptyNative.kill 同样保持 1) |
真实浏览器、真实 TerminalPanel、真实路由 —— exit 3 之后、任何释放动作之前的那一刻:
PR 里关于「累积」的论证也站得住:准入上限是 8,已退出会话被排除在外,所以反复开关终端会在窗口内无上限累积:
RSS 那一组展示的是泄漏的形状,不是对 Windows 的预测——Node worker 比 node-pty 真实的 conout worker 重得多。有意义的数字是 worker 数与线程数。
2. 会破坏 scrollback 回放吗?不会
这里真正的风险正是 PR 标注为「靠推理、未实测」的那一条,所以我把它测了。
| 检查项 | BEFORE | AFTER |
|---|---|---|
真实 PTY,320 KB 输出后 exit 3,在 退出时 / +1 轮 / +50 ms / +500 ms / +2 s 的 snapshot 字节数 |
各阶段均 360136 | 各阶段均 360136 |
回放的 snapshot 中存在 __PAYLOAD_END_MARKER__ |
是 | 是 |
释放后的 write() / resize() |
unavailable / false |
unavailable / false |
路由 E2E:标签页 A 收到 {type:'exit',exitCode:3} 后以 4000 关闭 |
是 | 是 |
| 路由 E2E:标签页 B 重连到已退出的 id → 回放与 A 所见逐字节一致 | 是 | 是,随后 releaseAfterReplay 丢弃会话 |
| 路由 E2E:标签页 C 用同一 id 拉起全新 shell(新 pid) | 是 | 是 |
关于尾部输出 drain。 node-pty 的 UnixTerminal 是在 socket close 处理函数里才 emit exit 的(// XXX Sometimes a data event is emitted after exit. Wait til socket is destroyed.),所以在 Linux 上「onExit 之后还有 data 回调」在结构上不可能发生——24 次运行全部确认:lastDataAt > exitAt 从未成立,且两臂的每一次运行中 registry 的 buffer 都等于 node-pty 实际投递的字节数。因此 setImmediate 在 Linux 上是个 no-op,它的价值确实只在 Windows,与 PR 的说法一致。参照 shellExecutionService 先例的这个判断是对的,只是在这里无法被真正触发。
给评审者的一个 flake 提醒。 24 次里约有 2 次(两臂都出现过,且不相关)末尾会缺一截——是 node-pty 自己少投递了字节,其 200 ms 的 DESTROY_SOCKET_TIMEOUT_MS 在流未读完时 destroy 了 socket。这是既有问题、且在本改动的上游;判别方法是看 registry buffer 字节数 == node-pty 投递字节数,这一条 24/24 成立。不要把它误判成本 PR 引入的回归。
3. release() 的顺序调整在可观测层面是中性的
本 PR 把 disposable 的摘除从 killPtyTree 之前挪到了之后。对一个正在跑 sleep 300 的真实 pty 做实时关闭标签页:
| BEFORE | AFTER | |
|---|---|---|
| 调用顺序 | dataDispose → exitDispose → ptyKill |
ptyKill → dataDispose → exitDispose |
| 之后 shell pid 是否存活 | 否 | 否 |
releaseHost → native close / conout dispose |
0 / 0(releasedHosts 短路) |
0 / 0 |
| 会话从 map 中移除 | 是 | 是 |
kill 与 dispose 之间没有 onExit 插入 |
— | 已确认(其间每一步都是同步的) |
另外我审计了释放后句柄的所有剩余使用点:write()(:438)与 resize()(:450)都对 session.exited 短路,killPtyTree(:497)只在实时分支上运行。不存在 use-after-release 路径。
4. 测试的牙齿 —— 8 个变异体的扫描,以及唯一的缺口
红/绿完全复现了 PR 的说法:基线源码 + 本 PR 的测试 → 2 failed | 30 passed;带上修复 → 32 passed。
| 变异体 | 结果 |
|---|---|
删掉 if (session.ptyResourcesReleased) return; |
1 failed ✅(作者的说法成立) |
setImmediate(...) → 直接内联调用 |
32 passed ❌ —— 未被覆盖 |
releasePtyResources 移到 killPtyTree 之前 |
3 failed ✅ |
删掉 dataDisposable.dispose() |
4 failed ✅ |
删掉 exitDisposable.dispose() |
4 failed ✅ |
删掉 pty.releaseHost() |
7 failed ✅ |
创建时 ptyResourcesReleased: true |
8 failed ✅ |
setImmediate → setTimeout(…, IDLE_RECLAIM_MS) |
2 failed ✅ |
只有一个洞:延后本身——也就是本 PR 全部的风险缓解手段——没有被任何测试钉住。 将来有人把它「简化」成内联调用,测试依然全绿。在第一个新测试里加两行即可,并且我验证过它是有牙齿的(不变异时 32 passed;配内联变异体 1 failed | 31 passed,expected "spy" to not be called at all, but actually been called 1 times):
onExit({ exitCode: 0 });
+ // The release is deferred one turn on purpose, so late PTY data still
+ // reaches `buffer` before the data listener is detached.
+ expect(conoutDispose).not.toHaveBeenCalled();
+ expect(disposeData).not.toHaveBeenCalled();
// One turn of the event loop, on real timers: the 15-minute idle reclaim另外记录一点:第三个新测试(does not free an exited session twice…)在基线 SHA 上是空转全绿的——它守的是新标志,而不是证明修复本身。这没有问题,只是它不属于红/绿证据的一部分。
5. 静态检查
tsc --noEmit 在两臂上产生完全相同的错误集合(61 个,全部是无关测试文件里既有的 @types/node 漂移,两个被改文件里一个都没有)→ 本 PR 新增零个类型错误。prettier --check 通过。eslint 在测试文件里报 3 个错误,但基线 SHA 上同样的代码同样报这 3 个(行号因 +77 行新增而位移)——既有问题,不是本 PR 引入的。
6. 范围说明 —— 不是缺陷,只是提醒读者别误以为这些也被修了
- JS 侧的累积没有变化:40 个已退出会话连同各自最多 4 MB 的 buffer 会在 map 里留满 15 分钟,而
!session.exited的上限过滤意味着这一侧仍然无上限。本 PR 只关掉了原生句柄这一个来源,这也正是它自己说的;[Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 里内存的那一半仍在。 - 在实时释放分支上,conout worker 是被刻意不释放的(
kill()记下 note →releaseConPtyHost提前返回)——这是 fix(core): release node-pty's conout worker after every PTY on Windows #11313 的「让排队中的kill()做唯一的关闭者」设计,本 PR 未做改动,我的 BEFORE/AFTER 数据在这一点上也完全一致。不要把实时分支上的workers alive读成泄漏。 - 以上全部是机制层面的验证。真实 Windows ConPTY 上
conhost.exe的实际回收仍需要一次 Windows 运行,而 PR 上的 Windows CI 是跳过的。我能给出的结论是:释放现在确实发生在退出时、恰好一次、且处在序列中正确的位置,并且真正走到releaseConPtyHost/disposeConoutWorker的路径与 PR 描述一致。
同意合并,前提是补上那两行延后断言(或者明确说明不加的理由)。
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



What this PR does
Frees an exited web terminal's PTY resources at exit time instead of waiting for
release().WebTerminalRegistry.handleExitnow releases the PTY through a new privatereleasePtyResources()helper, deferred onesetImmediate. The session object and its scrollbackbufferstay in the map, so replay is untouched.release()routes both of its arms through the same helper, guarded by a per-sessionptyResourcesReleasedflag so nothing is disposed twice when a tab close, a workspace drain,dispose()or the idle reclaim runs later on an already-freed session.Why it's needed
releaseHostexisted in exactly two places,web-terminal-registry.ts:474and:482, both insiderelease().handleExit(:264-273on the base SHA) only setexited/exitCodeand notified the exit listeners. Nothing else released the PTY on a natural exit: the browser route keeps an exited session alive for scrollback replay (finishExitedleavesreleaseAfterReplayat itsfalsedefault), and the client will not trigger an earlier release either, because a live exit closes the socket with4000and4000is inNON_RETRYABLE_CLOSE_CODES. So an exited web terminal held node-pty's conout worker — and, once microsoft/node-pty#965 is fixed upstream, itsconhost.exe— for up toIDLE_RECLAIM_MS, 15 minutes. Exited sessions are also excluded from the admission cap on purpose (filter((session) => !session.exited)), so a user opening and exiting terminals faster than they are reclaimed accumulates without bound inside that window. This is one feeder into the wider leak in #11303 (347conhost.exe/ ~2.8 GB after 12h).Nothing needs the PTY once the shell is gone:
write()andresize()already short-circuit onsession.exited, andreadSnapshot()replays the JS-sidesession.buffer, not the console.Reviewer Test Plan
How to verify
This was previously written off as not reproducible off Windows. #11313 changed that: the
web-terminal-registry.test.tsscaffolding it added exposes_agent._conoutSocketWorker.disposeplus theonData/onExitdisposables through a fake PTY, which makes "was the PTY freed at exit?" directly assertable without a Windows machine. That is the reason this PR exists now rather than being parked, not a lowering of the bar — the release timing is observable on Linux even though the actual Windows resource reclamation is not.Red on the base SHA (
ac1edef97), with the three new tests added and no source change:Nothing in that test calls
release()and no clock is advanced — real timers, a singlesetImmediatetick — so the 15-minute reclaim provably cannot have fired.Green with the fix:
Test Files 1 passed (1) / Tests 32 passed (32). The 29 pre-existing tests are unchanged and still pass; the test-file diff is purely additive (+77 / -0), and no existing assertion was relaxed. In particularexpect(nativeKill/conoutDispose/disposeData/disposeExit).toHaveBeenCalledOnce()afteronExit→releaseand the deferred-arm counts still hold as written, which is what theptyResourcesReleasedflag is for.The flag was mutation-checked rather than assumed: commenting out the
if (session.ptyResourcesReleased) return;guard turnsdoes not free an exited session twice when release followsred withexpected "spy" to be called once, but got 2 times(1 failed | 31 passed), so that test has teeth. Worth noting the two pre-existingtoHaveBeenCalledOnce()tests do not catch the double release — they assert synchronously afterrelease(), before the deferred turn runs — which is exactly why the new test flushes the immediate first.Commands:
No change to the live-release path's observable behaviour, and this is provable rather than merely asserted:
release()now detaches the data/exit listeners afterkillPtyTreeinstead of before, but everything in between is synchronous (spawnSync,process.kill,pty.kill()), so noonDatacallback can interleave.killPtyTreestill runs beforereleaseHoston the live arm, which the pre-existingdoes not double-close a live session whose kill already closed ittest pins through its call counts.Evidence (Before & After)
No user-visible surface changed — this is resource-release timing inside
packages/core, with no pixel-level or TUI delta, so there are no screenshots. The evidence is the red/green test output above: before,conoutDisposeis called 0 times after an exit; after, exactly once, with the session and its buffer still replayable. The third new test asserts scrollback replay still returnsoutput: 'boot\r\n'plusexited: true, exitCode: 3after the exit-time release, which is what keeps the route'sreleaseAfterReplayreconnect path working.Tested on
os.platform()mocked towin32)Environment (optional)
Unit tests only, on a headless Linux host, node_modules reused via hardlink from an existing checkout.
packages/corewas built once to satisfy the vitest global-setup prerequisite guard.Risk & Scope
onExitcan arrive slightly before late PTY data is processed, the same raceshellExecutionServicedrains before finalizing ("Give any last onData callbacks a chance to run before finalizing"), and disposing the data listener inline insidehandleExitcould truncate the tail of the very scrollbackreadSnapshot()replays.setImmediateruns after the poll-phase callbacks already queued this tick, andhandleDatais fully synchronous, so one turn is enough — there is no processing chain to flush the wayshellExecutionServicehas.conpty-host.tsreturns early whenos.platform() !== 'win32', so what this PR proves is the release timing and its exactly-once property; actual conout-worker andconhost.exereclamation on a real Windows ConPTY is unverified and needs a Windows run. There is deliberately no test asserting that trailing output survives the release: the existing fake'sdisposeis a barevi.fn()that does not detach the listener, sohandleDatastill appends to the buffer after "dispose" and such an assertion would pass with or without the fix. Making it real would mean reworking the fake fix(core): release node-pty's conout worker after every PTY on Windows #11313 just landed, which is not this PR's business. Windows CI is skipped on PRs, so this also will not be covered by the pipeline here.packages/cli/src/serve/routes/terminal.tsneeds no edit —finishExitedonly callsregistry.release()whenreleaseAfterReplayis true (the reconnect-to-an-exited-session arm), and that arm reads the snapshot before releasing, which still works because the session and buffer survive. NokillPtyTreeon the exited path: the shell is gone and its pid may be recycled, which is why fix(core): release node-pty's conout worker after every PTY on Windows #11313 addedreleaseHostrather than reusingkill(). The admission cap's!session.exitedfilter is intentional and untouched. [Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352's upstream ConPTY close defect is not addressed here, and [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303's wider leak surface is not — this only closes one feeder.releasePtyResourcesis private; no public signature changed. Relationship to the related work: fix(core): release node-pty's conout worker after every PTY on Windows #11313 (merged asd8baa873) made the release effective, this makes it timely; [Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 is the upstream close defect; [Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 is the aggregate leak. All three are complementary and none is duplicated here.Linked Issues
Fixes #11353
Related, non-closing: #11313 (merged — introduced
releaseHostand the test scaffolding this builds on), #11352 (upstream microsoft/node-pty#965,status/blocked), #11303 (aggregate Windows PTY leak).中文说明
这个 PR 做了什么
在 shell 退出时就释放已退出 web terminal 的 PTY 资源,而不是等到
release()。WebTerminalRegistry.handleExit现在通过新的私有辅助函数releasePtyResources()释放 PTY,并延后一个setImmediate。session 对象及其 scrollbackbuffer保留在 map 中,因此回放不受影响。release()的两个分支都改为走同一个辅助函数,并用每会话的ptyResourcesReleased标志保护,使得之后的关闭标签页、workspace 排空、dispose()或空闲回收在已释放的会话上不会二次 dispose。为什么需要
releaseHost此前只出现在两个地方,web-terminal-registry.ts:474与:482,都在release()内部。handleExit(基线 SHA 上为:264-273)只设置exited/exitCode并通知退出监听器。自然退出时没有其它任何路径会释放 PTY:浏览器路由为了 scrollback 回放刻意保留已退出的会话(finishExited让releaseAfterReplay保持默认的false),客户端也不会更早触发释放,因为实时退出会以4000关闭连接,而4000在NON_RETRYABLE_CLOSE_CODES里。于是已退出的 web terminal 会持有 node-pty 的 conout worker 最长IDLE_RECLAIM_MS,即 15 分钟;在上游 microsoft/node-pty#965 修好之后,还会持有它的conhost.exe。已退出会话又被有意排除在准入上限之外(filter((session) => !session.exited)),所以用户在这个窗口内开得比回收得快时,累积没有上限。这是 #11303(12 小时后 347 个conhost.exe/ 约 2.8 GB)那个更大泄漏的一个来源。shell 退出后没有任何东西还需要 PTY:
write()与resize()已经对session.exited短路,而readSnapshot()回放的是 JS 侧的session.buffer,不是 console。评审测试计划
如何验证
这一件此前被判定为"非 Windows 不可复现"。#11313 改变了这一点:它新增的
web-terminal-registry.test.ts脚手架通过 fake PTY 暴露了_agent._conoutSocketWorker.dispose以及onData/onExit的 disposable,这让"退出时 PTY 是否被释放"在没有 Windows 机器的情况下也能直接断言。这才是本 PR 现在能提出来的原因,而不是放低了门槛——释放时机在 Linux 上可观测,尽管 Windows 上真实的资源回收并不可观测。在基线 SHA(
ac1edef97)上的红态:新增三个测试、不改任何源码时,该测试没有任何一处调用
release(),也没有推进任何时钟——使用真实定时器、只等一个setImmediate——所以 15 分钟的空闲回收可证明不可能已经触发。带上修复后的绿态:
Test Files 1 passed (1) / Tests 32 passed (32)。29 个既有测试未作修改且仍然通过;测试文件的 diff 是纯新增(+77 / -0),没有放宽任何既有断言。特别是onExit→release之后的expect(nativeKill/conoutDispose/disposeData/disposeExit).toHaveBeenCalledOnce(),以及 deferred 分支的调用计数,都按原样成立——这正是ptyResourcesReleased标志的作用。该标志做了变异验证而非想当然:把
if (session.ptyResourcesReleased) return;这行守卫注释掉后,does not free an exited session twice when release follows会以expected "spy" to be called once, but got 2 times变红(1 failed | 31 passed),说明这个测试是有牙齿的。值得一提的是,两个既有的toHaveBeenCalledOnce()测试并不能抓到这次重复释放——它们在release()之后同步断言,那时延后的那一轮还没跑——这恰恰是新测试要先 flush immediate 的原因。命令:
实时释放路径的可观测行为没有变化,而且这一点是可证明的、不只是口头断言:
release()现在在killPtyTree之后才摘除 data/exit 监听器,而不是之前,但两者之间的所有操作都是同步的(spawnSync、process.kill、pty.kill()),因此没有onData回调能插入其间。实时分支上killPtyTree仍然先于releaseHost,既有的does not double-close a live session whose kill already closed it测试通过调用计数钉住了这一点。证据(Before & After)
没有用户可见的界面变化——这是
packages/core内部的资源释放时机,没有像素级或 TUI 差异,因此没有截图。证据是上面的红/绿测试输出:修复前,退出之后conoutDispose被调用 0 次;修复后恰好 1 次,且会话与其 buffer 仍可回放。第三个新测试断言退出时释放之后,scrollback 回放仍返回output: 'boot\r\n'以及exited: true, exitCode: 3,这正是保证路由releaseAfterReplay重连路径可用的部分。测试环境
os.platform()mock 成win32)环境(可选)
仅单元测试,在一台 headless Linux 主机上,node_modules 通过硬链接复用已有 checkout。
packages/core构建过一次,用于满足 vitest global-setup 的前置检查。风险与范围
onExit可能略早于最后的 PTY 数据被处理,这与shellExecutionService在 finalize 前 drain 的是同一个竞态("Give any last onData callbacks a chance to run before finalizing"),而在handleExit内同步 dispose data 监听器可能截断readSnapshot()本该回放的那段尾部输出。setImmediate会在本轮已经排队的 poll 阶段回调之后运行,而handleData是完全同步的,所以一个轮次就够——不像shellExecutionService那样还有一条处理链需要 flush。conpty-host.ts在os.platform() !== 'win32'时直接提前返回,所以本 PR 证明的是释放时机及其"恰好一次"的性质;真实 Windows ConPTY 上 conout worker 与conhost.exe的实际回收未经验证,需要在 Windows 上跑一次。这里刻意没有添加"尾部输出在释放后仍存在"的断言:既有 fake 的dispose只是一个裸vi.fn(),并不会真正摘除监听器,所以"dispose"之后handleData仍会往 buffer 里追加,这样的断言在修与不修的情况下都会通过。要让它变成真断言就得改造 fix(core): release node-pty's conout worker after every PTY on Windows #11313 刚落地的那套 fake,那不属于本 PR 的职责。此外 PR 上的 Windows CI 是被跳过的,所以流水线也不会覆盖到这一点。packages/cli/src/serve/routes/terminal.ts无需修改——finishExited只在releaseAfterReplay为 true 时(即重连到已退出会话的那一分支)才调用registry.release(),而该分支在释放之前先读 snapshot,这在会话与 buffer 存活的前提下依然可用。已退出路径上不加killPtyTree:shell 已经没了、pid 可能被复用,这正是 fix(core): release node-pty's conout worker after every PTY on Windows #11313 新增releaseHost而不复用kill()的原因。准入上限的!session.exited过滤是有意设计,未作改动。[Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 的上游 ConPTY 关闭缺陷不在本 PR 处理,[Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 的更大泄漏面也不在——这里只关掉其中一个来源。releasePtyResources是私有的,没有公开签名变化。与相关工作的关系:fix(core): release node-pty's conout worker after every PTY on Windows #11313(已合并为d8baa873)让释放有效,本 PR 让释放及时;[Windows] web-terminal PTYs leak conhost.exe on natural exit — inbox backend; shell fixed by #11497 #11352 是上游关闭缺陷;[Windows] qwen-cli (VS Code Companion) leaks headless conhost.exe ConPTY processes - 347 processes / ~2.8 GB after ~12h uptime #11303 是整体泄漏。三者互补,本 PR 不重复其中任何一个。关联 Issue
Fixes #11353
相关但不关闭:#11313(已合并——引入了本 PR 依赖的
releaseHost与测试脚手架)、#11352(上游 microsoft/node-pty#965,status/blocked)、#11303(Windows PTY 整体泄漏)。