fix(test): stop forwarding a cleaned-up PTY session into stdout (#11002) - #11007
fix(test): stop forwarding a cleaned-up PTY session into stdout (#11002)#11007qwen-code-dev-bot wants to merge 1 commit into
Conversation
The `E2E Interactive - OpenTUI renderer (bun)` leg reddened run 33834473606 without printing a single `FAIL` line, so the main-CI detector had no test to dedupe on and filed it per commit. The same SHA passed the same leg nine minutes later (run 33836390526, its step 301s against the failing step's 306s), which rules out a deterministic defect at HEAD and matches the class #10969 described: every test passes, then an unhandled error exits the run red. It is fatal on this leg alone because `dangerouslyIgnoreUnhandledErrors` is off for github-hosted Linux, and the OpenTUI job is the only Linux lane that is github-hosted — the shards moved to the self-hosted pool (#10085) and macOS is exempt by platform. #10969 closed one source of that class by killing the interactive children `cleanup()` inherits, but killing does not close the window. node-pty's signal-less `kill()` sends SIGHUP, and the CLI traps SIGHUP into an asynchronous graceful shutdown (`beginExit` -> `runExitCleanup()` -> `exit`), so the child keeps rendering after `cleanup()` returns while the rig's `onData` handler keeps forwarding every byte into `process.stdout` under this leg's `KEEP_OUTPUT`/`VERBOSE`. One such write after vitest destroys the worker's stdout pipe raises EPIPE, and with no listener Node escalates it to an uncaught exception. Detach the forwarding listener in `cleanup()` rather than relying on the child to die first: a byte that never reaches `process.stdout` cannot EPIPE it, however long the shutdown takes. Measured — a stand-in child that swallows SIGHUP survives `kill()` for over a second, while a child that does not dies within 500ms; and `dispose()` on node-pty's `onData` removes only that listener, leaving the child and the master descriptor alone. The witness pins both halves. It asserts the child is still alive after `cleanup()`, so the forwarding check cannot pass vacuously on a child that died instantly, and then that no canary byte reaches stdout. Removing the `dispose()` call reddens it with roughly 25 canary writes in the 500ms window. Out of scope: this leg's other no-`FAIL`-line source, vitest's 60s worker-to-main `onTaskUpdate` RPC timeout under load, is exempted for self-hosted and off-Linux lanes but not for this github-hosted one. Widening that exemption would silence the very signal this fix preserves, so it is left to a maintainer.
Autofix E2E Report — Issue #11002Issue: Main CI failed: E2E Tests on 56f75adf2992 What failedThe leg exited non-zero without printing a single Evidence gatheredThe job log text itself is not retrievable from here —
The same-SHA green run is the decisive fact, and the two legs' steps started 7.8 minutes apart. It excludes both candidate signatures that a code change at HEAD would have had to explain: the What remains is exactly the class Root cause
Whether the race is lost depends on whether the child happens to emit a byte in the narrow window between worker teardown and its own exit — which is why the same SHA passes when the leg runs again. Two probes confirmed the mechanism instead of assuming it:
The fix
Two files changed, 80 insertions and 6 deletions, both inside the integration harness. No product code, no CI workflow, no vitest configuration, and no test was deleted or weakened. Witness test
Out of scopeThis leg has a second, distinct source of no- That was deliberately not changed. Widening VerificationCommands actually run in this checkout (GitHub-hosted runner image, Node v22.23.2, npm 10.9.8):
Checks that could not be run here
One correction to the commit messageThe commit message says the same SHA passed the same leg "nine minutes later". The measured gap between the two legs' step starts is 7.8 minutes (04:20:40 vs 04:28:28 UTC); the two runs were created 32.3 minutes apart. The substantive claim is unaffected — the same SHA passed the same leg shortly afterwards — but the figure is rounded up. This report states the precise timestamps. The branch history is additive-only under this skill's rules, so the message was not amended to correct it. 中文说明Autofix E2E 报告 — Issue #11002Issue: Main CI failed: E2E Tests on 56f75adf2992 失败的是什么该检查项以非零状态退出,但没有打印任何一行 已收集的证据job 日志正文在此处无法获取 ——
「同一 SHA 变绿」是决定性的事实,而且两次检查项的步骤开始时间相差 7.8 分钟。它排除了两个本来必须由 HEAD 上的代码改动来解释的候选特征:一是提交 剩下的恰好就是 根因
是否会输掉这个竞态,取决于子进程是否恰好在「worker 被拆除」与「它自己退出」之间的那个狭窄窗口里发出了一个字节 —— 这也正是为什么该检查项再次运行时,同一个 SHA 就能通过。 有两个探针证实了该机制,而不是靠假设:
修复方案
改动了两个文件,80 行新增、6 行删除,全部位于集成测试脚手架内。没有改动产品代码、CI workflow、vitest 配置,也没有删除或弱化任何测试。 见证测试(Witness test)
范围之外该检查项还有第二个、彼此独立的「无 这一点被刻意没有改动。把 验证在本次检出中实际执行过的命令(GitHub 托管 runner 镜像,Node v22.23.2,npm 10.9.8):
在此处无法运行的检查
对提交信息的一处更正提交信息中写的是同一个 SHA 在「九分钟后」通过了同一个检查项。实测两次检查项步骤开始时间的间隔是 7.8 分钟(04:20:40 对 04:28:28 UTC);两个运行的创建时间则相差 32.3 分钟。其实质性主张不受影响 —— 同一个 SHA 在不久之后通过了同一个检查项 —— 但该数字被向上取整了。本报告给出的是精确时间戳。按照本 skill 的规则,分支历史只能是增量式的,因此没有通过 amend 去更正该提交信息。 🧠 Handled by Qwen Code · model/模型 |
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical — and the evidence holds up under independent checking. I pulled both runs cited in the description rather than taking them on faith:
Same head SHA I also checked the two load-bearing premises in the codebase rather than trusting the prose:
Direction: aligned. This is CI reliability for the repo's own harness, and it fixes a failure mode that costs maintainer attention every time it fires (one auto-filed issue per commit, with nothing to dedupe on). Worth noting the repo already learned this lesson once in a different corner — Size: not applicable — no core paths touched. For the record: 86 changed lines total, 23 in Approach: the scope feels right, and I want to be explicit about the alternative I considered first, because it is the obvious one and it does not work. My initial instinct was "kill harder" — send The one thing I'd ask you to sit with, not as a blocker: a narrower variant exists that keeps the listener attached and just guards the write with a No unrelated changes, no drive-by refactors: the Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. The residual risk is the one you already documented (a future test expecting readable output after cleanup), and it is bounded: I checked every consumer, see the code review for that. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ **问题:**是已观测到的问题,不是理论性加固——而且证据经得起独立核查。我没有直接采信描述里的数字,而是自己拉取了所引用的两次运行:
同一个 head SHA 描述里两个关键前提我也在代码库中核实过,而不是只看文字:
**方向:**对齐。这是仓库自身脚手架的 CI 可靠性问题,而它修掉的失败模式每次发生都要消耗维护者的注意力(检测器无从去重,只能按提交逐个建 issue)。值得一提的是,仓库在另一个角落已经吃过一次同样的亏—— **规模:**不适用——没有触及核心路径。作为记录:共改动 86 行,其中 **方案:**范围合理。我想明确说明我最初考虑的替代方案,因为那是最直觉的一个,而它行不通。我一开始的想法是「杀得更狠」——像 有一点想请你考虑,但不是阻塞项:存在一个更窄的变体——保留监听器,只用一个 没有夹带无关改动,也没有顺手重构: **风险:**无升级风险信号——两个改动文件都不匹配与 revert 相关的路径清单。剩余风险就是你已经写下的那一条(未来某个测试期望清理之后仍能读到输出),而且是有界的:我核查了所有消费方,详见代码审查。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote down my own fix before opening the diff, from the title and the "Why it's needed" section alone: stop the write path rather than try to win the race against the child's exit — either keep the No critical blockers, and no AGENTS.md violations. What follows is what I checked and the two nits I'd leave to your judgement. The pattern is already the repo's own. The rename is complete. Disposing the data listener doesn't break the exit path. This was my main correctness worry going in — The frozen-buffer risk is real but bounded — I checked every consumer. One correction to the description, not to the code: it says cleanup is "the last statement of the teardown hook" in every interactive spec. In The witness is well built. It asserts the child is still alive before asserting nothing was forwarded, which is what stops it passing vacuously on an already-dead child — the failure mode that would make the whole test decorative. The Two nits, neither blocking:
The window being closedsequenceDiagram
participant P1 as TestRig cleanup
participant P2 as PTY child (CLI)
participant P3 as Worker stdout pipe
participant P4 as Vitest worker
P1->>P3: dispose the onData listener (this PR)
P1->>P2: kill with no signal, sends SIGHUP
P2->>P2: trap SIGHUP, begin async graceful exit
P4->>P3: tear down worker, read end gone
P2->>P1: still rendering, emits bytes
Note over P1,P3: no subscriber left, bytes dropped, no write, no EPIPE
P2->>P2: exits on its own schedule
Before this PR, the step after "still rendering, emits bytes" was a Test evidenceThis is an unattended CI run, so per the gate rules I did not build or execute anything from this PR. Everything below is the PR's own CI, read through the API for the reviewed commit. The important one is green, and it's green on the specific new test rather than just the file: — from The one red check is not yours. twice, at 06:43:58 and 06:51:03. That's the npm registry's audit endpoint being unavailable, and I classified it as infra from the diff and the check identity rather than from anything the log claims about itself: this PR touches two Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Two coverage gaps I want to state plainly rather than let a green table imply otherwise: The specs that actually consume the changed cleanup path don't run on this PR at all. Not verified: that the witness actually fails with the dispose line removed. Your step 2 mutation probe is the only evidence for that, and it's your claim, not something CI reproduces — a suite that passes identically with and without the fix is green and worthless, and nothing in the check table distinguishes those two cases. Also not verified: that a child which writes still exits normally after its data listener is disposed. The pre-existing witness proves kill-after-dispose terminates a child, but its stand-in is idle, and your new stand-in is SIGKILLed in the Sandboxed verification would settle both: 中文说明代码审查我在打开 diff 之前,只凭标题和「为什么需要它」写下了自己的修法:不要去和子进程的退出抢时间,而是直接切断写入路径——要么保留 没有阻塞性问题,也没有违反 AGENTS.md。下面是我核查过的内容,以及两个留给你判断的小点。 这个模式本来就是仓库自己的。 重命名是完整的。 **dispose 数据监听器不会破坏退出路径。**这是我进来时主要的正确性顾虑—— 缓冲区冻结的风险是真实的,但有界——我核查了每一个消费方。 对描述(不是对代码)的一处更正:它说在每个交互式 spec 里 cleanup 都是「teardown 钩子的最后一条语句」。在 **见证测试写得扎实。**它先断言子进程仍然存活、再断言没有任何东西被转发,这正是防止它在一个早已死掉的子进程上空洞通过的关键——那种失效会让整个测试变成装饰品。 两个小点,都不阻塞:
被关上的那个时间窗口上方的时序图展示了本 PR 之后的路径:清理先 dispose 掉 改动之前,「仍在渲染、发出字节」之后的那一步是往一个读端已消失的管道里做 测试证据这是一次无人值守的 CI 运行,因此按门禁规则我没有构建或执行本 PR 的任何代码。下面全部内容都是本 PR 自己的 CI,通过 API 针对被审查的那个提交读取。 关键的那一项是绿的,而且是绿在具体那个新测试上、不只是文件级别: 唯一的红灯不是你的。 上方的 CI 表格已用机器可读的区域标记包裹,CI 跑完之后 finalize 任务会就地更新它。 有两个覆盖缺口我想直接说明,而不是让一张绿色的表暗示出不存在的结论: 真正消费这条被改动清理路径的那些 spec,在本 PR 上根本没有运行。 未验证:把 dispose 那一行删掉之后见证测试是否真的会失败。你的第 2 步变异探针是这一点唯一的证据,而它是你的说法,CI 并不会复现它——一个「有改动」和「没改动」都同样通过的套件既是绿的、也是没有价值的,而检查表里没有任何东西能区分这两种情况。同样未验证:一个会写输出的子进程在其数据监听器被 dispose 之后是否仍能正常退出。已有的见证测试证明了「先 dispose 再 kill」能终止子进程,但它的替身是空闲的;而你的新替身是在 沙箱验证可以把这两点都定下来: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 3/5 — the change itself is clean and I would merge it on its own merits; what I can't decide is which of two competing open PRs should own these lines. ⏸️ Deferring to a maintainer rather than approving. Not because of anything wrong in this diff — because approving here would silently settle a choice that isn't mine to make. The thing that stopped meI ran the "is this part of a pattern?" check the gate asks for, and it found something. This PR's author has eleven PRs open, three of them
They rewrite the same three sites — the
And neither PR mentions the other. This one's Related list names #10969, #10112, #10325 and #10085 — not #11001, not #10990. The two linked issues are the same failure: both name workflow This bot already triaged #11001: Confidence 4/5, with a deferred approval that was then withheld when its CI didn't settle. So both PRs currently sit at "the bot liked this one", and they cannot both land. The substantive part of the choiceThis isn't just merge mechanics — the two strategies differ in a way that matters for this specific leg, and it's the reason I'd want a human to pick rather than let merge order decide. #11001 makes But this PR's own Risk section names the leg's second independent source of red runs with no named test: "the test runner's fixed 60-second worker-to-main RPC budget, which a stall under load can exceed", while the leg runs "up to four parallel forks of pseudo-terminal-heavy tests". Adding up to ten seconds of awaited teardown per session, in the most PTY-heavy suite, on the most loaded lane, pushes directly against that budget. So on the evidence in this PR's description, #11001's strategy risks feeding the failure mode this PR deliberately declined to widen the exemption for. Detaching costs zero teardown latency and closes the window unconditionally — a byte that never reaches stdout cannot break a pipe however long the graceful shutdown takes — at the price of freezing My read: this PR's strategy is the better of the two for this leg. If a maintainer wants both, they compose well, and the ordering matters: dispose first, then await. Once nothing can EPIPE, the await is no longer safety-critical, so its grace can drop well below ten seconds — you'd get #11001's guarantee that no child leaks past teardown without paying its latency in the RPC budget. But that's a design call with a tradeoff attached, and it isn't one an approval on either PR should settle by accident. Why I'm not approvingNot the code. The code is minimal, correct, mirrors I'm deferring because the merge decision is contested and I'd be breaking the tie silently. What I'd ask a maintainer to do:
One small corroboration for the red check: No @mention, deliberately. I ran the deterministic owner resolver rather than eyeballing one: 中文说明Confidence: 3/5 —— 改动本身是干净的,单看它我会合并;我无法判断的是「这两个互相竞争的开放 PR,哪一个才该拥有这几行代码」。 ⏸️ **转交维护者,而不是批准。**不是因为这份 diff 有任何问题——而是因为在这里批准,等于悄悄替别人做了一个不该由我做的选择。 让我停下来的那件事我执行了门禁要求的「这是否属于某种模式」检查,结果发现了问题。本 PR 的作者有 11 个开放 PR,其中三个是最近两小时内的
它们改写了同样的三处——第 203 行的
而且两个 PR 都没有提到对方。本 PR 的「相关」清单点了 #10969、#10112、#10325、#10085——没有 #11001,也没有 #10990。 这两个关联 issue 是同一次失效:都指向 workflow 本 bot 已经对 #11001 做过 triage:Confidence 4/5,随后因其 CI 未收敛而撤回了延迟批准。所以两个 PR 目前都停在「bot 认为这个不错」的状态,而它们不可能同时落地。 这个选择里真正有实质内容的部分这不只是合并机制问题——两种策略的差异对这个具体检查项是有影响的,这也是我希望由人来挑、而不是让合并顺序来定的原因。 #11001 让 但本 PR 自己的「风险与范围」点出了该检查项第二个彼此独立的「无测试名红灯」来源:「测试运行器固定的 60 秒 worker→main RPC 预算,负载下的停顿可能超出它」,而该检查项会「以最多四个并行 fork 运行伪终端密集型测试」。在最重伪终端的套件里、在最拥挤的通道上,按会话增加最多十秒的 await teardown,正是直接顶着那个预算。所以按本 PR 描述里的证据,#11001 的策略有喂养「本 PR 刻意拒绝为其扩大豁免」那个失效模式的风险。 摘除转发的 teardown 延迟是零,并且无条件关上窗口——一个从未到达 stdout 的字节不可能打破管道,无论优雅退出耗时多久——代价是冻结 **我的判断:就这个检查项而言,本 PR 的策略是两者中更好的一个。**如果维护者想要两者,它们可以很好地组合,而且顺序很重要:先 dispose,再 await。一旦不可能 EPIPE,那个 await 就不再是安全关键的,它的宽限时间就可以远低于十秒——你既能得到 #11001 的「没有子进程泄漏到 teardown 之后」的保证,又不必在 RPC 预算里付出它的延迟。但这是一个带着取舍的设计决定,不该由对任一 PR 的一次批准来意外地拍板。 为什么我不批准不是因为代码。代码是最小化的、正确的、与 我之所以转交,是因为合并决定存在竞争,而我会在无人察觉的情况下打破这个平局。我希望维护者做的:
关于那个红灯检查项的一点旁证: **刻意不做 @mention。**我运行的是确定性的 owner 解析器,而不是凭眼力挑一个: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
What this PR does
The integration test harness now detaches an interactive terminal session's output forwarding as part of cleaning up, rather than only signalling the spawned CLI to exit. Each session the harness spawns is recorded together with the handle that lets its byte-forwarding listener be removed, and cleanup removes that listener before signalling the child. After cleanup returns, no byte from that session can reach the test worker's own standard output.
A witness test pins the new behaviour with a stand-in child that traps the hangup signal exactly the way the CLI does and keeps printing a unique marker, asserting both that the child is genuinely still alive once cleanup has returned and that none of its output reaches standard output afterwards.
Why it's needed
The
E2E Interactive - OpenTUI renderer (bun)leg has been reddening main intermittently without naming a failing test, which leaves the main-CI detector nothing to dedupe on and files one issue per commit. Run 33834473606 is the latest such red, and the same commit passed the same leg in run 33836390526, whose step started 7.8 minutes after the failing one and took 301 seconds against the failing step's 306 — so the suite ran to completion and the failure is not a deterministic defect in the code under test.The earlier fix for this class made cleanup kill the sessions a test never closed, but killing does not close the window. A signal-less kill through the pseudo-terminal library sends a hangup, and the CLI deliberately traps that into an asynchronous graceful shutdown, so the child is still running and still rendering after cleanup has returned. The harness was still forwarding every byte that child produced into the worker's standard output, because this leg enables verbose output keeping. Once the test runner tears the worker down, the reader end of that pipe is gone, so the next forwarded write raises a broken-pipe error that nothing listens for, and Node escalates it to an uncaught exception — the run exits non-zero having printed no test failure at all.
This is fatal on that leg alone. The integration configuration deliberately keeps unhandled errors fatal on GitHub-hosted Linux so local and nightly runs retain the signal, and that leg is the only Linux lane which is GitHub-hosted, since the shards moved to the self-hosted pool and macOS is exempt by platform. So the same race is swallowed everywhere else and only ever reddens this one job.
Whether the race is lost depends on whether the child happens to emit a byte in the narrow window between worker teardown and its own exit, which is exactly why the same commit can pass minutes later. Detaching the forwarding removes the dependency on winning that race: a byte that never reaches standard output cannot break the pipe, however long the child's graceful shutdown takes.
Reviewer Test Plan
How to verify
The behaviour under test is the harness itself, so it is verifiable without bun, without the OpenTUI renderer, and without any model access:
cd integration-tests && npx vitest run test-helper.test.ts. Expect 8 passing, including the new case that a cleaned-up session stops forwarding output. It completes in well under a second of test time.npx vitest run test-helper.test.ts -t "detaches" --retry=0. Expect it to fail, reporting roughly 25 marker chunks still written to standard output in the 500ms after cleanup. Restore the line and the file goes green again.cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts— 4 files, 33 passing.Nothing in the interactive suite reads accumulated session output after cleanup — in every interactive spec the cleanup call is the last statement of the teardown hook — so detaching the listener at that point cannot hide output an assertion still needs.
Evidence (Before & After)
N/A — no user-visible or TUI change. The observable difference is in CI: before, this leg could exit non-zero with every test passing and no
FAILline, which is what produced the per-commit issue; after, a session that outlives cleanup can no longer write into the worker's standard output.The mutation probe is the concrete before/after, and step 2 of the test plan above is how to reproduce it: with the disposal removed the witness fails showing roughly 25 marker chunks forwarded after cleanup, and with it in place the witness passes showing zero.
Tested on
Environment (optional)
GitHub-hosted Linux runner image, Node v22.23.2, npm 10.9.8. The exact failing leg could not be reproduced here: the OpenTUI renderer path requires bun, which is not installed on this runner, and the original job log is only readable with admin rights. The change is in renderer-independent harness code and the witness runs on the default ink/node path, which exercises the same spawn-then-cleanup code that leg uses. Run, job and annotation metadata from the public API was used to establish the failure signature instead of the log text.
Risk & Scope
Linked Issues
Fixes #11002
Related, for context on the failure class rather than as a closing reference: #10969 introduced the cleanup-time kill this PR completes, #10112 and #10325 established the unhandled-error exemption policy that makes this leg the only exposed Linux lane, and #10085 moved the other Linux shards to the self-hosted pool.
中文说明
这个 PR 做了什么
集成测试脚手架现在会在清理阶段摘除某个交互式终端会话的输出转发,而不仅仅是向派生的 CLI 发送退出信号。脚手架派生的每个会话都会连同「可移除其字节转发监听器的句柄」一起被记录下来,清理时会先移除该监听器、再向子进程发信号。清理返回之后,该会话的任何字节都无法再到达测试 worker 自身的标准输出。
一个见证测试(witness test)用一个替身子进程钉住了这一新行为:该子进程像 CLI 一样捕获挂断信号(hangup),并持续打印一个唯一标记;测试同时断言「清理返回后子进程确实仍然存活」以及「此后它的输出不会到达标准输出」。
为什么需要它
E2E Interactive - OpenTUI renderer (bun)这个检查项一直在间歇性地把 main 弄红,却不指出任何失败的测试,这使得 main-CI 检测器无从去重,只能按提交逐个建 issue。运行 33834473606 就是最近这样一次红灯,而同一个提交在运行 33836390526 中通过了同一个检查项;后者的步骤比失败的那次晚开始 7.8 分钟,耗时 301 秒对失败步骤的 306 秒 —— 说明测试套件是跑完的,失败并不是被测代码里的确定性缺陷。针对这一类问题的先前修复让清理阶段去杀掉测试从未关闭的会话,但杀掉进程并没有关闭这个时间窗口。通过伪终端库发送的不带信号的 kill 会发出一个挂断信号,而 CLI 有意把它捕获为一次异步的优雅退出,因此子进程在清理已经返回之后仍在运行、仍在渲染。而脚手架仍在把该子进程产生的每个字节转发进 worker 的标准输出,因为这个检查项开启了输出保留(verbose output keeping)。一旦测试运行器拆除 worker,该管道的读端就消失了,于是下一次转发写入会抛出一个无人监听的 broken-pipe 错误,Node 随即将其升级为 uncaughtException —— 整个运行以非零退出,却没有打印任何测试失败。
这只在那一个检查项上是致命的。集成测试配置有意在 GitHub 托管的 Linux 上保持「未处理错误即致命」,以便本地运行和 nightly 运行保留该信号;而该检查项是唯一由 GitHub 托管的 Linux 通道,因为分片已迁往自托管机器池,macOS 则因平台而被豁免。所以同一个竞态在其他地方都被吞掉,只会把这一个 job 弄红。
是否输掉这个竞态,取决于子进程是否恰好在「worker 被拆除」与「它自己退出」之间的狭窄窗口里发出了一个字节 —— 这正是为什么同一个提交几分钟后就能通过。摘除转发消除了对「赢得该竞态」的依赖:一个从未到达标准输出的字节不可能打破管道,无论子进程的优雅退出耗时多久。
审阅者测试计划
如何验证
被测对象是脚手架本身,因此无需 bun、无需 OpenTUI 渲染器、也无需任何模型访问权限即可验证:
cd integration-tests && npx vitest run test-helper.test.ts。预期 8 个通过,其中包含新增的「已清理的会话停止转发输出」用例。测试耗时远低于一秒。npx vitest run test-helper.test.ts -t "detaches" --retry=0重跑。预期它会失败,并报告在清理之后的 500 毫秒内仍有约 25 个标记数据块被写入标准输出。恢复该行后文件重新变绿。cd integration-tests && npx vitest run test-helper.test.ts renderer-matrix.test.ts globalSetup.test.ts fake-openai-server.test.ts—— 4 个文件,33 个通过。交互式套件中没有任何测试会在清理之后读取累积的会话输出 —— 在每个交互式规格文件中,清理调用都是 teardown 钩子的最后一条语句 —— 所以在该时点摘除监听器不会隐藏任何断言仍需要的输出。
证据(改动前与改动后)
N/A —— 没有用户可见或 TUI 的变化。可观察的差异体现在 CI 上:改动前,该检查项可能在所有测试都通过、且没有任何
FAIL行的情况下以非零退出,这正是产生按提交归档 issue 的原因;改动后,一个比清理活得更久的会话已无法再写入 worker 的标准输出。变异探针(mutation probe)就是具体的前后对比,上方测试计划的第 2 步即其复现方式:移除 disposal 后见证测试失败并显示清理之后仍转发了约 25 个标记数据块;保留它则见证测试通过并显示为零。
测试环境
环境(可选)
GitHub 托管的 Linux runner 镜像,Node v22.23.2,npm 10.9.8。此处无法复现完全一致的失败检查项:OpenTUI 渲染器路径需要 bun,而本 runner 上没有安装;原始 job 日志也只有管理员权限才能读取。改动位于与渲染器无关的脚手架代码中,且见证测试运行在默认的 ink/node 路径上,执行的正是该检查项所使用的同一套「派生—清理」代码。已改用公开 API 的运行、job 与注解元数据来确立失败特征,以替代日志正文。
风险与范围
关联 Issue
Fixes #11002
相关(用于说明该类失败问题的背景,而非作为关闭引用):#10969 引入了本 PR 所完善的「清理时杀进程」;#10112 与 #10325 确立了未处理错误的豁免策略,正是该策略使本检查项成为唯一暴露的 Linux 通道;#10085 把其他 Linux 分片迁往了自托管机器池。