fix(test): wait for interactive PTY sessions to end during cleanup - #11001
fix(test): wait for interactive PTY sessions to end during cleanup#11001qwen-code-dev-bot wants to merge 1 commit into
Conversation
…10990) Cleanup signalled each leaked session but returned without waiting for it to go away. The CLI traps SIGHUP and exits only once runExitCleanup() has drained, a chain it bounds at 5s, so kill() returns with the child still alive and still forwarding PTY bytes into the worker's stdout — measured at 83ms for a booted session, exiting with the CLI's SIGHUP code 129. That is the window #10969 was meant to close. A full interactive leg run on the parent commit shows a CLI child reparented to init at the moment its vitest worker exited; the same run after this change orphans none, with an identical result set. The wait costs each session's real drain (35-42ms measured) and is bounded above the CLI's own 5s ceiling. The witness now pins the wait itself. Its stand-in traps SIGHUP and exits after a delay like the real CLI, and reports itself booted first: signalling a child that has not installed its handler ends it on the default action, which measured nothing. Deleting the wait turns it red at 0ms against a 750ms floor; deleting the kill turns it red on the survival poll.
E2E report — issue #10990 (Main CI failed: E2E Tests on b7815a7)What the issue names, and what each half turned out to beRun 33829764813 at
Root cause of the interactive-leg failure#10971 made The CLI installs Measured against the real bundle rather than inferred:
A full interactive-leg run on the parent commit, watching the process table, caught the consequence directly — a CLI child whose vitest worker exited underneath it and was reparented to init: Its lifetime (84s) and its sibling's (76s) match that file's two test durations in the same run (84.5s and 77.8s), so these are the sessions Why #10971's witness did not catch this: its stand-in was The change
The witness was strengthened in place rather than added alongside, so it fails on both halves of the guard: the stand-in now traps SIGHUP and exits 750ms later like the real CLI, and reports itself booted first. That second part was necessary, not decorative — signalling a child before VerificationEvery command below was actually run in this checkout ( Required checks:
Mutation probes (each guard has its own witness; the file was restored from a byte copy after each, and the restore was re-run to green):
Orphan and survivor measurement, whole-leg runs with a 2s process-table sampler:
Cost of the wait, measured by instrumenting Independently corroborated by per-file timings: Not run, and why:
Honest limits of this repairThis removes one proven source of unhandled errors on that leg, measured end to end. It is not a guarantee the leg stops reddening, for three reasons worth stating plainly:
One observation, deliberately not implemented
中文说明E2E 报告 —— issue #10990(Main CI failed: E2E Tests on b7815a7)Issue 指出的两个 job,各自的真实性质Run 33829764813(commit
交互式 leg 失败的根因#10971 让 CLI 对任何交互式会话都会注册 针对真实 bundle 实测(而非推断):
在父提交上跑一次完整的 interactive leg,同时监视进程表,直接抓到了后果 —— 一个 CLI 子进程,它的 vitest worker 在其身下退出,于是它被 reparent 给 init: 它的存活时长(84 秒)与它的同胞进程(76 秒)正好对应该文件在同一次运行中的两个测试耗时(84.5 秒和 77.8 秒),所以这些正是 #10971 的 witness 为什么没抓到:它的替身是 改动内容
witness 是就地加强的,而不是另加一个,因此它对这道守卫的两半都会失败:替身现在会像真实 CLI 一样捕获 SIGHUP 并在 750 毫秒后退出,并且先报告自己已启动。第二点不是装饰而是必需 —— 在 验证下面每条命令都在本 checkout( 必需检查:
变异探针(每道守卫都有自己的 witness;每次之后都用字节副本还原文件,并重跑到绿色):
孤儿与残留进程测量,整 leg 运行并以 2 秒间隔采样进程表:
等待的代价,通过给 并由各文件耗时独立佐证: 未运行,及原因:
对本次修复的诚实边界这移除了该 leg 上一个被端到端实测证明的 unhandled error 来源。它并不保证该 leg 不再变红,有三点需要明白写出:
一条观察,刻意未实现
🧠 Handled by Qwen Code · model/模型 |
|
|
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical. #10990 is a machine-filed main-CI failure — Direction: aligned. This is test-infrastructure health, not a product surface — no production behaviour and no public contract change. The CHANGELOG signal doesn't apply to an internal harness fix. I want to call out the honesty in the scope statement: it claims to remove one proven source of the red leg and explicitly declines to claim the leg stops reddening, and it leaves "should github-hosted Linux keep treating unhandled errors as fatal" to a maintainer instead of quietly flipping that policy while it was in the neighbourhood. Size: not applicable — no core paths are touched. Two files under Approach: the scope feels right, and strengthening the existing regression test in place rather than adding a parallel one is the correct call. The description also explains why the earlier witness missed this — the stand-in had no signal handler so it died on the default action, and the assertion polled with a ten-second timeout that was perfectly happy to let the child outlive teardown by up to ten seconds. Recording that is what stops the same fix shipping green a second time, and it's the part of this PR I'd most want future contributors to read. Risk: no elevated risk signals — neither file matches the revert-correlated path list. One thing I'm carrying into code review: the grace bound is 10s and Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题: 是已观测到的,不是理论性的。#10990 是自动创建的 main CI 失败 issue —— 方向: 对齐。这是测试基础设施的健康度,不是产品面 —— 没有生产行为改动,也没有公共契约变化。CHANGELOG 信号对内部 harness 修复不适用。这里要特别指出范围陈述的诚实:它只声称移除了一个被证明的红 leg 来源,明确不声称该 leg 从此不再变红;并且把"github-hosted Linux 是否应继续把 unhandled error 判为致命"留给维护者决定,而没有顺手在自己路过时改掉这个策略。 规模: 不适用 —— 没有触及核心路径。 方案: 范围合理,而且就地加强已有回归测试、而不是另写一个平行测试,是正确的选择。描述还解释了为什么早先那个 witness 没抓到 —— 替身没有信号 handler,所以以默认动作立刻死掉;而断言用的是一个十秒超时的 poll,这个 poll 完全乐意接受子进程比 teardown 多活最多十秒。把这一点记录下来,正是防止同一个修复第二次"绿着"上线的关键,也是这个 PR 里我最希望后来的贡献者去读的部分。 风险: 无升级风险信号 —— 两个文件都不匹配与 revert 相关的路径列表。 有一点我带进代码审查:grace 上界是 10 秒,而 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote down what I'd do before opening the diff: track an exit promise per PTY session captured at spawn time, await it in So: no correctness blockers, no security concerns, no regressions I can find. The mechanism is sound and I checked the parts that could quietly not work. What I verified
One suggestion — the grace bound collides exactly with the hook budget
Two consequences worth a look. A single child that ignores SIGHUP consumes the entire hook budget, so the The description's reasoning here addresses the CLI's ceiling ("deliberately above the CLI's own five-second shutdown ceiling") and says "hook time counts against the test timeout" — but in Vitest hooks are governed by What I could not check Whether node-pty can still deliver already-buffered Test evidence — this PR's own CII did not build or run any PR code; per the gate rules the review is static and the evidence below is this PR's own CI, read through the API at the reviewed commit. The useful signal is that the strengthened regression test really ran and really passed: the The one red check is not this PR's. The gap that CI cannot close: the job list on this commit contains no Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle this: 中文说明代码审查在读 diff 之前我先写下了自己的方案:在 spawn 时为每个 PTY 会话捕获一个 exit promise,在 所以:没有正确性阻塞项,没有安全问题,我找不到回归。机制是可靠的,我把那些"可能悄悄不生效"的地方都查了。 已验证的部分
一条建议 —— grace 上界与 hook 预算精确相撞
有两个后果值得看一眼。一个完全忽略 SIGHUP 的子进程会吃光整个 hook 预算,于是 描述里针对这一点的推理讲的是 CLI 的天花板("刻意设在高于 CLI 自身五秒关闭天花板的位置"),并说"hook 时间是计入测试超时的" —— 但在 Vitest 里 hook 由 我无法确认的部分 node-pty 是否可能在 测试证据 —— 本 PR 自己的 CI我没有构建或运行任何 PR 代码;按 gate 规则,审查是静态的,下面的证据是本 PR 自己的 CI,通过 API 在被审查的 commit 上读取的。 有用的信号是:加强后的回归测试确实跑了、也确实过了。 唯一变红的检查不是这个 PR 造成的。 CI 关不掉的那个缺口:本 commit 的检查列表里没有 (CI 表格见上,未重复翻译。) 沙箱验证可以定这件事: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — the fix is correct, cheap, and witnessed; the only things keeping it from 5 are a bound that collides exactly with the hook budget, and a leg-level claim no pull-request CI can substantiate. Stepping back: my independent proposal and this PR landed in the same place on all five points I'd have insisted on, and it beat me on one — the readiness handshake in the stand-in. I'd have written the wait, bounded it, and unref'd the timer; I'm not sure I'd have noticed that a signal delivered before the handler exists measures nothing, which is the exact reason the previous fix shipped green. That detail is the difference between a regression test that passes and one that actually pins the property. The strongest objection I can mount is that this PR does not prove the OpenTUI leg stops reddening — and the author says so first, in more places than I would have. But that's an argument about the cure, not the change. What the change does claim, I could verify: the harness really does forward every PTY byte to Six months from now I'd thank whoever wrote this. The comments carry the why that isn't recoverable from the code — the CLI's 5s shutdown ceiling as the reason for the bound's magnitude, and the reason the stand-in needs a signal handler at all. The test was strengthened in place instead of duplicated, and the description records why the earlier witness failed, which is the part that stops this regressing quietly. Two things I'd want the author or a maintainer to weigh, neither blocking:
On the approval: CI is still running (
中文说明Confidence: 4/5 —— 修复是正确、廉价且有 witness 的;让它到不了 5 分的只有两件事:一个与 hook 预算精确相撞的上界,以及一个任何 pull-request CI 都无法支撑的 leg 级主张。 退一步看:我自己独立想到的方案与这个 PR 在我会坚持的全部五点上都落在同一处,而它在一点上胜过我 —— 替身的就绪握手。我会写等待、给它设上界、把 timer unref 掉;但我不确定我会注意到"在 handler 装上之前发信号,什么都测不到",而那恰恰是上一次修复"绿着"上线的原因。这个细节,正是"一个能通过的回归测试"与"一个真正钉住性质的回归测试"之间的区别。 我能提出的最有力反对是:这个 PR 并没有证明 OpenTUI leg 不再变红 —— 而作者比我更主动地、在更多地方先说了这一点。但那是关于"疗效"的论证,不是关于"改动"的。改动所声称的部分,我都能验证:harness 确实在 六个月后我会感谢写这段代码的人。注释承载了那些无法从代码里恢复的为什么 —— CLI 的 5 秒关闭天花板是上界取值的理由,以及替身为什么必须有一个信号 handler。测试是就地加强的而不是复制一份,而描述记录了早先那个 witness 为什么失效,那正是防止这件事悄悄退化的部分。 有两点我希望作者或维护者权衡,都不构成阻塞:
关于批准: CI 仍在运行(
— Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
What this PR does
Makes the interactive test harness wait for each terminal session it ends, instead of signalling it and moving on. The rig already remembered every pseudo-terminal child it spawned and terminated the ones a test never closed; it now also blocks until each of those children has actually exited, within a bound that sits above the CLI's own shutdown ceiling so a child that refuses to die cannot hang teardown. The bound's timer leaves nothing behind that would keep the worker's event loop alive.
The regression test that covers this was strengthened in place rather than duplicated. Its stand-in for the CLI now behaves like the real one — it traps the termination signal and exits only after a delay — and it announces that it has finished booting before the harness is allowed to signal it. The test asserts on the wait itself, so it goes red when either half of the guard is removed: the signal, or the wait for it to take effect.
Why it's needed
The
E2E Interactive - OpenTUI renderer (bun)leg keeps reddeningmainwithout naming a single failing test. It did so in six of the last nine runs, and one of those six was the very commit that landed #10971 to fix it — so that repair narrowed the class but did not close it. The failing step runs just as long as a healthy one, which says the suite completes and the process dies afterwards; and the log carries no failure line at all, which is what makes the detector file per commit instead of per test. A run that passes everything and still exits non-zero is an unhandled error.#10971 correctly identified the mechanism: a session a test never closed stays alive to the end of the run, still forwarding every terminal byte into the worker's standard output because the same environment enables that verbose forwarding, and once vitest tears the worker down the reader end of that pipe is gone — the next write raises
EPIPE, which Node escalates to an uncaught exception. What it missed is that signalling a session is not the same as ending it. The CLI traps the hangup signal for any interactive session, whatever the renderer, and exits only after an asynchronous shutdown chain has drained: chat-recording flush, MCP subprocess stop, telemetry shutdown, session-usage persisting, all bounded by a five-second wall clock. So the harness returned from teardown with the child still alive and still writing, and the window #10971 set out to close stayed open.Both halves were measured rather than assumed. Against the real bundle, the child is still alive at the instant the kill call returns, and exits 83ms later with exit code 129 — the CLI's own code for a hangup it handled itself. And in a whole-leg run on the parent commit, watching the process table caught a CLI child being reparented to init at the moment its vitest worker exited, which is precisely the instant an
EPIPEis fatal rather than harmless; its lifetime and its sibling's line up with the two test durations in the file that starts sessions and never closes them. After this change the same measurement finds no orphans at all, across two whole-leg runs, with an identical set of passing tests.The reason the earlier witness did not catch this is worth recording, because it is the reason the fix shipped green once already. Its stand-in had no signal handler, so it died instantly on the default action, and it asserted through a poll with a ten-second timeout — a poll that is perfectly happy for the child to outlive teardown by up to ten seconds. The property that actually matters, "gone by the time teardown returns", was never pinned by anything.
Reviewer Test Plan
How to verify
The load-bearing claim is that no interactive session is still alive when teardown returns, and the regression test in the rig's own test file is the whole of it. On this branch it passes in about a second. Check out the parent commit, apply only the test change, and it fails reporting that teardown returned before the child exited — zero milliseconds measured against a 750ms floor. It needs no model credentials, no
bun, and no network, because the stand-in is a short script rather than the CLI.To confirm both halves of the guard are witnessed, delete each in turn and re-run that file. Removing the wait fails on the duration floor. Removing the kill fails on the survival poll, after the bound and the poll have both expired. Restoring either returns it to green.
The wider suite should be unchanged: run the interactive leg and compare against
main, expecting the same ten files collected and the same eighteen tests passing with the same two skips, and no new ones. This matters most for the sessions that end by themselves — the Ctrl+C exit case and the mid-turn quit cases — since teardown now waits on children those tests already terminated, and for those the wait resolves immediately because the exit has already been observed.It is also worth watching the process table while the leg runs, which is how the defect was caught. On
maina CLI child outlives its worker and is reparented to init; on this branch none is.The cost is small and measurable: the wait is each session's real shutdown, 35–42ms in measurement, and the one file that leaks a session grew by 38ms. Whole-leg wall clock is dominated by a live-model compression file whose individual tests swing between 71s and 107s run to run, so compare per-file timings rather than the total when judging whether this change slowed anything.
The OpenTUI leg itself is the final check and needs
bun; it could not be run where this change was prepared. Because the failure is intermittent — the leg passed at the commit immediately after the one this issue was filed against, before any of this work — a single green run proves little on its own. The meaningful signal is whether the "exit code 1, no failing test" shape stops recurring over a run of merges.Evidence (Before & After)
Non-UI change, so no screenshots. The measured before/after is the process table during a whole interactive-leg run:
ppid 2130 → 1— at the last sample of the run, i.e. at worker teardown. Whole-leg result:9 passed | 1 skipped (10)files,18 passed | 2 skipped (20)tests, exit 0.0orphans,0survivors fifteen seconds after the run. Whole-leg result identical:9 passed | 1 skipped (10)files,18 passed | 2 skipped (20)tests, exit 0.alive immediately after kill(): true, thenexited after 83ms exitCode=129 signal=0, where 129 is the CLI's own handled-hangup exit code.1 failed | 6 passed (7)—cleanup() returned before the interactive CLI child exited: expected 0 to be greater than or equal to 750. Regression test, kill removed:1 failed | 6 passed (7)—Matcher did not succeed in time. Both restored:7 passed (7).Tested on
Environment (optional)
Linux (Node 22) inside a container,
QWEN_SANDBOX=false,QWEN_E2E_RENDERER=ink, against the bundleddist/cli.js, with the runner-environment variable unset so unhandled errors stay fatal exactly as they are on the OpenTUI leg. Theopentuileg was not run:bunis unavailable there and the renderer matrix throws without it. The defect and the fix are renderer-independent — the signal handler is installed for any interactive session, gated only on interactivity — but the leg that reddens is the one that could not be executed here.Risk & Scope
bun, and thesandbox:noneshards, which need the self-hosted pool. Also deliberately untouched is whether github-hosted Linux should keep treating unhandled errors as fatal — that leg is the only Linux lane with the exemption off, so any other unhandled error is fatal there alone, and whether that is the right signal policy is a maintainer call, as fix(test): end interactive PTY sessions a test never closed #10971 also judged. This change removes one proven source; it is not a guarantee the leg stops reddening. Separately noted and not implemented: one interactive file carries its own copy of the launcher and never applies the renderer overlay, so on the OpenTUI leg it drives the CLI under node with the default renderer rather than underbunwith the pinned one, sitting outside the guarantee the renderer matrix exists to enforce. It is not implicated here — it closes its own session and waits — and moving it ontobunwould change what that file exercises. The second job named in the issue, asandbox:noneshard, was the documented transient shared-host pressure class whose one-shot retry was starved by a per-leg build thatmainhas since removed; the same shard failing afterwards is tracked by Main CI failed: E2E Tests on d4e3e4fc8747 #10994.Linked Issues
Fixes #10990
中文说明
这个 PR 做了什么
让交互式测试框架等待它结束的每一个终端会话,而不是发个信号就走。rig 本来就会记住它生成的每一个伪终端子进程,并终止那些测试没有关闭的;现在它还会阻塞等待这些子进程真正退出,上界设在高于 CLI 自身关闭天花板的位置,因此一个拒绝死掉的子进程不会把 teardown 挂死。该上界使用的 timer 不会留下任何撑住 worker 事件循环的东西。
覆盖这一点的回归测试是就地加强的,而不是另写一个。它替代 CLI 的替身现在行为与真实 CLI 一致 —— 捕获终止信号,并且只在一段延迟之后退出 —— 并且在框架被允许向它发信号之前,先宣告自己已经启动完毕。测试断言的是"等待"本身,因此移除守卫的任意一半都会让它变红:发信号,或等待信号生效。
为什么需要它
E2E Interactive - OpenTUI renderer (bun)这个 leg 一直在让main变红,却不指出任何一个失败的测试。最近九次运行里有六次如此,而这六次中有一次正是为修复它而合入 #10971 的那个 commit —— 所以那次修复收窄了这一类问题,却没有关闭它。失败步骤的耗时与健康步骤相当,说明套件是跑完了、之后进程才死掉;而日志里完全没有失败行,这正是检测器按 commit 而不是按测试来记录的原因。一个所有测试都通过却仍以非零码退出的 run,是 unhandled error。#10971 正确识别了机制:测试没有关闭的会话会一直活到 run 结束,并且因为同样的环境设置开启了冗长转发,它仍在把每一个终端字节转发进 worker 的标准输出;一旦 vitest 拆除 worker,该管道的读取端就消失了 —— 下一次写入产生
EPIPE,Node 将其升级为未捕获异常。它漏掉的是:向会话发信号并不等于结束会话。CLI 对任何交互式会话都会捕获 hangup 信号,无论使用哪个渲染器,并且只有在一条异步关闭链排空之后才退出:chat-recording flush、MCP 子进程停止、telemetry shutdown、session-usage 持久化,全部由一个五秒的墙钟上界约束。因此框架从 teardown 返回时子进程仍然活着、仍在写入,#10971 想要关闭的那个窗口依然开着。两部分都是实测得到的,不是假设。针对真实 bundle,子进程在 kill 调用返回的那一刻仍然活着,并在 83 毫秒后以退出码 129 结束 —— 那是 CLI 自己处理 hangup 时使用的退出码。而在父提交上的一次整 leg 运行中,监视进程表抓到了一个 CLI 子进程在其 vitest worker 退出的那一刻被 reparent 给 init,而那恰恰是
EPIPE致命而非无害的瞬间;它的存活时长与它同胞进程的时长,与那个"启动会话却从不关闭"的文件里两个测试的耗时对得上。改动之后,同样的测量在两次整 leg 运行中都没有发现任何孤儿进程,且通过的测试集合完全一致。早先那个 witness 为什么没抓到,值得记录下来,因为这正是一次修复已经"绿着"上线的原因。它的替身没有信号 handler,因此会以默认动作立刻死掉;而且它通过一个十秒超时的 poll 来断言 —— 这个 poll 完全乐意接受子进程比 teardown 多活最多十秒。真正要紧的性质"到 teardown 返回时已经消失",从来没有任何东西把它固定下来。
Reviewer 测试计划
如何验证
承重的主张是:teardown 返回时没有任何交互式会话仍然活着,而 rig 自己测试文件里的回归测试就是它的全部。在本分支上它大约一秒通过。切到父提交,只应用测试改动,它会失败并报告 teardown 在子进程退出之前就返回了 —— 实测 0 毫秒,对照 750 毫秒的下限。它不需要模型凭据、不需要
bun、不需要网络,因为替身是一段短脚本而不是 CLI。要确认守卫的两半都有 witness,逐个删除并重跑该文件。移除"等待"会在耗时下限上失败。移除"发信号"会在存活 poll 上失败,且是在上界与 poll 都到期之后。恢复任意一个都会回到绿色。
更大的套件应当保持不变:运行 interactive leg 并与
main对比,期望收集到同样的十个文件、通过同样的十八个测试、跳过同样的两个,且没有新增跳过。这一点对那些本应自行结束的会话最为重要 —— Ctrl+C 退出用例,以及 mid-turn 的 quit 用例 —— 因为 teardown 现在会等待这些测试已经终止过的子进程,而对它们来说等待会立刻解除,因为退出早已被观察到。也值得在该 leg 运行期间观察进程表,这正是缺陷被抓到的方式。在
main上,一个 CLI 子进程比它的 worker 活得更久并被 reparent 给 init;在本分支上一个都没有。代价很小且可测:等待就是每个会话真实的关闭耗时,实测 35–42 毫秒,而唯一泄漏会话的那个文件增长了 38 毫秒。整 leg 的墙钟时间由一个真实模型的压缩文件主导,它的单个测试在不同 run 之间会在 71 秒到 107 秒之间摆动,所以判断本改动是否拖慢了任何东西时,请对比各文件耗时而不是总时长。
OpenTUI leg 本身是最终检查,需要
bun;在准备这一改动的环境里无法运行。由于失败是间歇性的 —— 该 leg 在本 issue 所针对 commit 的下一个 commit 上、在这些工作开始之前就通过了 —— 单独一次绿色运行说明不了太多。有意义的信号是:"退出码 1、无失败测试"这个形态是否在若干次合并之后不再复现。证据(前后对比)
非 UI 改动,因此没有截图。测得的前后对比是整 interactive leg 运行期间的进程表:
ppid 2130 → 1—— 出现在整个 run 的最后一次采样,也就是 worker 拆除时。整 leg 结果:9 passed | 1 skipped (10)个文件、18 passed | 2 skipped (20)个测试、exit 0。0个孤儿,run 结束十五秒后0个残留。整 leg 结果完全相同:9 passed | 1 skipped (10)个文件、18 passed | 2 skipped (20)个测试、exit 0。alive immediately after kill(): true,随后exited after 83ms exitCode=129 signal=0,其中 129 是 CLI 自己处理 hangup 的退出码。1 failed | 6 passed (7)——cleanup() returned before the interactive CLI child exited: expected 0 to be greater than or equal to 750。回归测试,移除 kill:1 failed | 6 passed (7)——Matcher did not succeed in time。两者都恢复后:7 passed (7)。测试环境
环境(可选)
容器内的 Linux(Node 22),
QWEN_SANDBOX=false,QWEN_E2E_RENDERER=ink,针对打包后的dist/cli.js运行,并 unset runner-environment 变量,使 unhandled error 保持致命,与 OpenTUI leg 完全一致。opentuileg 未运行:该环境中没有bun,渲染器矩阵在缺少它时会抛错。缺陷与修复都与渲染器无关 —— 信号 handler 对任何交互式会话都会安装,只以"是否交互式"为条件 —— 但变红的恰恰是这里无法执行的那个 leg。风险与范围
bun下的 OpenTUI leg,以及需要 self-hosted 池的sandbox:none各 shard。同样刻意未触碰的是:github-hosted Linux 是否应继续把 unhandled error 判为致命 —— 该 leg 是唯一关闭豁免的 Linux 通道,因此任何其它 unhandled error 都只在它这里是致命的,而这是否是正确的信号策略属于维护者的决定,fix(test): end interactive PTY sessions a test never closed #10971 也做了同样判断。本改动移除了一个被证明的来源;它不保证该 leg 不再变红。另外记录但未实现:有一个交互式文件自带一份启动器副本,且从不套用渲染器 overlay,因此在 OpenTUI leg 上它是用 node 加默认渲染器驱动 CLI,而不是用bun加被钉住的渲染器,落在渲染器矩阵本要保证的范围之外。它与本次问题无关 —— 它自己关闭会话并等待 —— 而把它搬到bun上会改变该文件实际验证的内容。issue 中指出的第二个 job,一个sandbox:noneshard,属于已记录在案的共享宿主压力瞬时类别,它的一次性重试被 per-leg 构建耗尽了预算,而main此后已移除该构建;同一 shard 在此之后仍然失败,由 Main CI failed: E2E Tests on d4e3e4fc8747 #10994 跟踪。关联 Issue
Fixes #10990