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 5 commits 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 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed. Suggestions are inline.
Not reviewed: reverse audit — stopped before round 5 by the review time budget.
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
未审查:反向审计——评审时间预算不足,未能开始第 5 轮。
— qwen3.8-max via Qwen Code /review (v0.23.0)
…path (#11002) The witness d1e250d added for #11002 had two ways to stay green while certifying nothing. Both were reproduced before either was changed. It never asserted that stdout forwarding was live before `cleanup()` was asked to stop it. The first poll pins `rig._interactiveOutput`, which the `onData` handler appends unconditionally ahead of the `KEEP_OUTPUT` gate, so it proves only that the handler fired — never that the gated `process.stdout.write(data)` ran. The spy is installed after that poll and `forwarded.length = 0` discards whatever it captured, so replacing the gated write with a no-op left the case green in 735ms with forwarding entirely dead. Poll the spy buffer for the canary before cleanup, through the harness's own gate rather than a re-implemented condition; that mutant now reddens with "stdout forwarding never went live before cleanup". It also certified the producer alive only at the instant `cleanup()` returned. Removing `dispose()` and SIGKILLing the child right after that assertion left the 500ms window empty for a reason that has nothing to do with detaching — a dead child emits no bytes — and the case passed in 714ms. Re-check liveness at the end of the window with the same `isProcessAlive` helper; that mutant now reddens on the re-check. Removing only `dispose()`, with the producer alive, still reddens on the window itself with 24-25 canary chunks, so neither addition blunts the check the witness exists to make. Both are additive assertions: the intact tree stays green in 658ms.
|
🤖 Addressed the latest review feedback (round 1/10). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/10 轮)。改动内容与我反驳保留之处如下: Autofix round summary — PR #11007 (issue #11002)Commit Feedback triage[rv:5110726423] Partial-review disclosure — No actionThe review body discloses that the reviewer's reverse audit did not run inside its time budget and points at its inline suggestions. It requests no change, so there is nothing to implement; the actionable content is the two inline findings below. The undisclosed reverse-audit gap is noted for the maintainer rather than treated as clean coverage. [rc:3932168671] R1-1 — witness has no positive control → Implemented (claim reproduced first)The finding claims the witness can pass vacuously if the gated forwarding path is ever silently disabled. Reproduced on the pre-round code before touching anything:
Fix: poll the spy buffer for the canary before Acceptance re-measured against the same mutant: [rc:3932168686] R1-4 — liveness certified only at cleanup return → Implemented (claim reproduced first)The finding claims the empty-
Fix: re-check liveness at the end of the window with the same Acceptance re-measured against the same mutant: The discrimination the witness exists for was re-probed too, so neither addition blunts it: removing only Failed check:
|
| File | Change |
|---|---|
integration-tests/test-helper.test.ts |
+14: pre-cleanup positive control poll on the spy buffer; post-window isProcessAlive re-check; one short "why" comment each |
Footprint: unchanged from the PR's own — the single file touched is one the PR already touches. No CI, workflow, husky, skill, script, lockfile, or workspace-manifest area entered. No test was deleted or weakened; both changes are added assertions.
Verification
Every command below was actually run in this checkout; results are quoted from their real output.
Required checks:
npm run build— passed (exit 0)npm run typecheck— passed (TYPECHECK_EXIT=0)npm run typecheck:integration— passed (TYPECHECK_INTEGRATION_EXIT=0)npm run lint— passed (LINT_EXIT=0;eslint . --ext .ts,.tsx && eslint integration-tests, no findings)npx prettier --check integration-tests/test-helper.test.ts integration-tests/test-helper.ts— passed ("All matched files use Prettier code style!")- Focused integration-harness run,
cd integration-tests && QWEN_SANDBOX=false npx vitest run test-helper.test.ts— 8 passed (8), witness case✓ 658mson the committed tree. Re-run after the build so the green result is from the exact committed content:git show HEAD:integration-tests/test-helper.test.tsis byte-identical (md5897a32c3…) to the tree that was tested, and the pre-commit hook made no content change.
Mutation probes — each guard this round adds has its own witness, and every mutant was reverted afterwards (git status --short clean, test-helper.ts md5 5ae38b00… restored to its committed state after each probe):
| # | Mutant | Pre-round test | Post-round test |
|---|---|---|---|
| A | gated process.stdout.write(data) → no-op |
✓ 735ms (vacuous) |
× 60342ms — "stdout forwarding never went live before cleanup" |
| B1 | session.forwardOutput.dispose() removed, producer alive |
× 1956ms — 24/25 canary chunks in window |
× 2151ms — 24/24/25 chunks, discrimination preserved |
| B2 | dispose() removed and producer SIGKILLed inside the window |
✓ 714ms (vacuous) |
× 2054ms — end-of-window liveness re-check |
Not run, with reasons:
npm run bundle+ the broader integration suites — not required this round. The change is confined to assertions insidetest-helper.test.ts, and that case deliberately overridesrig.bundlePathwith a stand-in script, so it exercises the harness without the bundled CLI. The harness filetest-helper.tsis untouched this round, and CI'sIntegration Tests (no-AK, No Sandbox)was green on it at the previous commit.npm run generate:settings-schema— not required: no settings source changed.npm audit— networked package command, outside the allowed set; see the escalation above for the evidence offered in its place.
中文说明
Autofix 轮次总结 — PR #11007(issue #11002)
提交 2116973523 — test(integration): stop the PTY-forwarding witness passing on a dead path (#11002)。改动 1 个文件,新增 14 行,无删除。本轮纯增量:未改动任何生产源码。
反馈分类处理
[rv:5110726423] 部分审查披露 — 无需处理
该审查正文说明审查者的反向审计未能在其时间预算内完成,并指向其行内建议。它没有要求任何改动,因此没有可实现的内容;可执行的部分是下面的两条行内发现。未披露完成的反向审计缺口已提请维护者注意,而不被视为覆盖完整。
[rc:3932168671] R1-1 — 见证测试缺少阳性对照 → 已实现(先复现该论断)
该发现称:一旦受门控的转发路径被静默禁用,见证测试就会空洞地通过。在改动任何代码之前,先在轮次前的代码上复现:
- 变异体:在
runInteractive内部,把受门控的process.stdout.write(data)替换为空操作(void data),保留KEEP_OUTPUT门、_interactiveOutput追加以及其他一切不变。 - 轮次前测试的结果:
✓ detaches a session's output forwarding during cleanup 735ms— 绿灯,而转发路径已完全失效,没有任何 canary 字节抵达 stdout。整个套件中针对 Main CI failed: E2E Tests on 56f75adf2992 #11002 修复的唯一守卫什么都没证明。
修复:在 rig.cleanup() 之前轮询 spy 缓冲区中的 canary,因此该断言只能由真正经过 harness 自身 env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true' 门的 chunk 满足 —— 而不是本地重新实现的条件,正如该发现所要求的。forwarded.length = 0 仍在 cleanup 之后执行,所以观察窗口的语义保持不变。
针对同一变异体重新度量验收标准:× ... 60342ms,AssertionError: stdout forwarding never went live before cleanup: expected false to be true。阳性对照本身就是钉桩。
[rc:3932168686] R1-4 — 只在 cleanup 返回时刻证明存活 → 已实现(先复现该论断)
该发现称:空的 forwarded 断言无法区分"转发已被摘除"与"生产进程已死亡"。在轮次前的代码上复现:
- 变异体:从
cleanup()中移除session.forwardOutput.dispose(),并且在现有存活断言之后立即 SIGKILL 生产进程,即在 500 ms 观察窗口之内。 - 轮次前测试的结果:
✓ ... 714ms— 空洞绿灯。死掉的子进程不会发出任何字节,因此这个回归见证测试在未证明任何东西的情况下通过了。
修复:在窗口末尾使用与现有检查相同的 isProcessAlive 辅助函数复查存活,按该发现的要求保持辅助函数单一来源。
针对同一变异体重新度量验收标准:× ... 2054ms,新增复查抛出 AssertionError: expected false to be true。
同时也重新探测了这个见证测试本应具备的区分能力,以确认两处新增都没有削弱它:仅移除 dispose()、生产进程存活时,仍然在窗口断言上失败并报 cleanup() left the session forwarding PTY bytes into stdout: expected [ …(24) ] to deeply equal [](24–25 个 canary chunk,与上一轮记录的数量一致)。
失败检查:Dependency CVE audit → 升级给维护者;本 PR 无法实现
该检查是红的,而它不可能由本 PR 造成:
- 该作业(
.github/workflows/security-checks.yml)先执行npm ci --ignore-scripts,再对根 lockfile 以及每个 vendoredpackages/*/package-lock.json执行npm audit --omit=dev --audit-level=high。它的全部输入集合就是各类 manifest 与 lockfile。 - 本 PR 的
git diff origin/main...HEAD --name-only恰好是integration-tests/test-helper.test.ts与integration-tests/test-helper.ts—— 没有package.json、没有package-lock.json、没有patches/。审计的输入与基线分支逐字节相同,因此按构造其在 base 与 head 上的结论必然一致。 - 历史也印证该门是被针对已提交 lockfile 新公布的公告触发,而非被某个 PR 的源码触发:main 上有
2a428054c4 chore(deps): bump fast-uri to 3.1.7 to clear the high-severity audit gate (#10862),正是为同一原因专门落地的一次依赖版本提升。 - 该检查运行于 06:36–06:51Z,针对的是上一个 head,彼时本轮提交尚不存在。
清除它意味着提升某个依赖并重新生成 lockfile。lockfile 与 patches/ 属于本工作流不允许改动的供应链区域,而选用哪个包或哪个版本属于维护者的决定,因此升级处理而非自行实现。本地未运行 npm audit:它是联网的包管理命令,不在本轮允许的命令集合内;上文给出的静态输入集合论证即为替代证据。
变更文件
| 文件 | 改动 |
|---|---|
integration-tests/test-helper.test.ts |
+14:cleanup 前对 spy 缓冲区的阳性对照轮询;窗口结束后的 isProcessAlive 复查;各一条简短的"为什么"注释 |
Footprint:与 PR 自身的 footprint 一致 —— 唯一触及的文件正是 PR 已经触及的文件。未进入任何 CI、workflow、husky、skill、脚本、lockfile 或 workspace manifest 区域。没有删除或削弱任何测试;两处改动都是新增断言。
验证
下面每条命令都在本 checkout 中真实执行过;结果直接引自其真实输出。
必需检查:
npm run build— 通过(exit 0)npm run typecheck— 通过(TYPECHECK_EXIT=0)npm run typecheck:integration— 通过(TYPECHECK_INTEGRATION_EXIT=0)npm run lint— 通过(LINT_EXIT=0;eslint . --ext .ts,.tsx && eslint integration-tests,无任何问题)npx prettier --check integration-tests/test-helper.test.ts integration-tests/test-helper.ts— 通过("All matched files use Prettier code style!")- 定向集成 harness 运行:
cd integration-tests && QWEN_SANDBOX=false npx vitest run test-helper.test.ts— 8 passed (8),见证用例在已提交的树上为✓ 658ms。构建之后重新运行过,因此绿灯结果来自将要提交的确切内容:git show HEAD:integration-tests/test-helper.test.ts与被测树逐字节相同(md5897a32c3…),且 pre-commit 钩子未做任何内容改动。
变异探测 —— 本轮新增的每个守卫都有自己的见证,且每个变异体事后都已还原(git status --short 干净,test-helper.ts 的 md5 在每次探测后都恢复为其已提交状态 5ae38b00…):
| # | 变异体 | 轮次前测试 | 轮次后测试 |
|---|---|---|---|
| A | 受门控的 process.stdout.write(data) → 空操作 |
✓ 735ms(空洞) |
× 60342ms — "stdout forwarding never went live before cleanup" |
| B1 | 移除 session.forwardOutput.dispose(),生产进程存活 |
× 1956ms — 窗口内 24/25 个 canary chunk |
× 2151ms — 24/24/25 个 chunk,区分能力保留 |
| B2 | 移除 dispose() 并且在窗口内 SIGKILL 生产进程 |
✓ 714ms(空洞) |
× 2054ms — 窗口末尾的存活复查 |
未运行项及原因:
npm run bundle与更大范围的集成套件 —— 本轮不需要。改动仅限于test-helper.test.ts内部的断言,而该用例刻意用替身脚本覆盖rig.bundlePath,因此它在不依赖打包 CLI 的情况下检验 harness。harness 文件test-helper.ts本轮未触及,且 CI 的Integration Tests (no-AK, No Sandbox)在上一个提交时对它是绿的。npm run generate:settings-schema—— 不需要:没有 settings 源发生变化。npm audit—— 联网包管理命令,不在允许集合内;替代证据见上文的升级说明。
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。
🧵 Resolved all 2 selected review thread(s). · 已关闭全部选中的 2 条评审线程。
Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。
🧠 Handled by Qwen Code · model/模型 qwen3.8-max-2026-09-02
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Not explored to full depth (tool budget reached): "agent 3b": none — no checks were cut short.; "agent 3a": none — no check was cut short by the tool budget..
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
integration-tests/test-helper.ts:205 — [probe] D2-1 cleanup() silently freezes _interactiveOutput/promise.output capture for a child outliving cleanup; field name and comments describe only the stdout-forwarding halfintegration-tests/test-helper.test.ts:146 — [review] D2-2 witness asserts Unix-only SIGHUP-trap semantics; deterministically red on Windows local runs — guard with it.skipIf(process.platform === 'win32')
中文说明
未探索到全部深度(达到工具调用预算):"agent 3b":none — no checks were cut short.;"agent 3a":none — no check was cut short by the tool budget.。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
🔀 Base updated: red check(s) [Dependency CVE audit] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Dependency CVE audit] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped before round 4 by the review time budget.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:反向审计——评审时间预算不足,未能开始第 4 轮。
— qwen3.8-max via Qwen Code /review (v0.23.0)
The witness 2116973 added had two ways to certify nothing, plus one platform where it reddens without the behaviour under test being wrong. Both defects were reproduced before either was changed. It proved its producer alive across the 500ms no-bytes window, but alive is not delivered: node-pty reads the pty fd in the loop's poll phase, so a worker stalled across the window resumes into an already-expired sleep timer and asserts against a buffer the pending bytes have not reached. With dispose() removed and an 800ms synchronous stall started from the check phase, the case passed green while a canary landed one loop turn after the assertion. Register a second onData witness instead — node-pty returns an independent disposable per registration and fires listeners in registration order, so the harness's handler runs before the witness for the same chunk — and poll it for proven delivery before asserting the harness forwarded nothing. That mutant now reddens, as does removing dispose() alone (2 canary chunks forwarded) and removing it while SIGKILLing the child after the first liveness check (the poll times out). The last is the mutant the deleted end-of-window isProcessAlive re-check was added for, so the delivery poll subsumes it; the intact tree stays green. It also hard-asserted POSIX-only SIGHUP survival, which is deterministically red on Windows local runs: node-pty's kill() terminates the child unconditionally there and its typings document the signal argument as unsupported. Guard the case with it.skipIf(process.platform === 'win32') like its platform-dependent siblings under integration-tests/interactive/.
|
🤖 Addressed the latest review feedback (round 2/10). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/10 轮)。改动内容与我反驳保留之处如下: Autofix round summary — PR #11007 (issue #11002)Outcome: one additive commit, Both actionable findings were reproduced before anything was changed, and every fix was mutation-probed afterwards. Feedback points and decisions[rc:3934185242] R3-1 — Critical: witness hard-asserts POSIX-only SIGHUP survival → ImplementedClaim (checkable): on Windows the stand-in child cannot survive Verification. Windows is not executable on this runner, so the claim was verified against the pinned dependency and the suite's own conventions rather than guessed at:
Fix. Guarded the case the same way, with a one-line comment naming why the premise is POSIX-only. The sibling case above it ( Probe note, reported honestly. Stubbing [rc:3934185254] R3-2 — Suggestion: the 500ms window proves liveness, not delivery → Reproduced, implemented (with one correction to the suggested snippet)Reproduction. The finding claims the window can pass green with forwarding still attached. Reproduced on this runner at the reviewed commit, with
The ordering the finding names is what happens: when the expired Fix. Register a second Correction to the suggested snippet. As written it registers Subtractive part of the change. The [rv:5113204380] CHANGES_REQUESTED — "Partially reviewed — gaps disclosed" → No code actionThe body's only content is a disclosed coverage gap ("reverse audit — stopped before round 4 by the review time budget"), not a finding. Its two ledger findings are the inline comments above, both now addressed. Nothing to implement for the gap itself; the reverse audit simply has not run. [rv:5111974045] COMMENTED — downgrade over two failing checks → One now green, one deferred
VerificationCommands actually run in this checkout, in order:
Mutation probes (each temporarily applied, run, then restored;
Two probes could not be run and are reported rather than assumed: the win32 redness R3-1 names (no Windows runner, and the platform stub breaks node-pty's module load before collection), and the One environment note, not a code issue: this checkout had no git identity, so the commit initially failed with 中文说明Autofix 本轮总结 — PR #11007(issue #11002)结果: 一个增量提交 两条需要处理的发现都先复现、后修改,改完后又都做了变异探针验证。 各条反馈与处置[rc:3934185242] R3-1 — Critical:见证测试硬断言仅 POSIX 的 SIGHUP 存活语义 → 已实现主张(可检验): 在 Windows 上替身子进程无法在 验证。 本 runner 无法执行 Windows,所以该主张是对照被钉住的依赖和套件自身惯例验证的,而不是凭空猜测:
修复。 按同样方式守卫该用例,并加一行注释说明为什么该前提只在 POSIX 成立。它上面那个兄弟用例( 探针说明(如实报告)。 在 worker setup 文件里把 [rc:3934185254] R3-2 — Suggestion:500ms 窗口证明的是「存活」而非「已送达」 → 已复现并实现(对建议代码片段做了一处修正)复现。 该发现主张:即使转发仍挂着,窗口也可能绿灯通过。已在被审查的那个提交上、在本 runner 复现——把
发生的正是该发现指出的顺序:当过期的 修复。 在 对建议片段的修正。 建议代码在 本次改动中「做减法」的部分。 [rv:5113204380] CHANGES_REQUESTED —「仅部分审查,缺口已披露」→ 无需改代码该审查正文的唯一内容是披露的覆盖缺口(「反向审计——在第 4 轮之前被审查时间预算打断」),不是发现。它账本里的两条发现就是上面那两条行内评论,均已处理。缺口本身无可实现之物;只是反向审计尚未运行。 [rv:5111974045] COMMENTED — 因两个失败检查而降级 → 一个已转绿,一个已转入延后队列
验证(Verification)本 checkout 中实际按序运行的命令:
变异探针(每一个都是临时施加、运行、随后还原;每次还原后
有两个探针无法运行,在此如实报告而非假设:R3-1 所指的 Windows 红灯(没有 Windows runner,且平台 stub 会在收集之前破坏 node-pty 的模块加载),以及 一个环境说明(非代码问题):本 checkout 没有配置 git 身份,因此提交最初以 🦷 Gate advisory — this round resolves a Critical/Request-changes finding with test-only changes (machine-measured): the bite check cannot verify a test-side fix, so the resolution rests on the round summary alone. · 本轮以纯测试改动解决 Critical/Request-changes 反馈(门自动测量):bite 检查无法验证测试侧修复,该解决仅以轮次摘要为凭。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧵 Resolved all 2 selected review thread(s). · 已关闭全部选中的 2 条评审线程。 Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
Convergence: round 4 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: integration-tests/test-helper.test.ts (findings in round 3; 2 more now). The rate of new findings is not falling. 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. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. 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.)
中文说明
已审查。 建议见行内评论。
收敛情况:第 4 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:integration-tests/test-helper.test.ts(第 3 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
| rig.bundlePath = rig.createFile( | ||
| 'slow-exit-cli.js', |
There was a problem hiding this comment.
[Suggestion] R4-1: Under the harness's supported INTEGRATION_TEST_USE_INSTALLED_GEMINI=true mode (the npm-bundle verification mode documented in _getCommandAndArgs's JSDoc), rig.bundlePath is silently dropped from argv — runInteractive spawns the installed qwen CLI instead of this stand-in script, which never emits the canary. A release-validation run of the suite in that mode then burns the full 20s poll (×3 with retry: 2) and fails with the stand-in CLI never produced output — a diagnostic that points at the stand-in file that was never spawned, sending whoever debugs it to a file that is not in the failure path. No lane in this repo sets the variable today, so nothing automated goes red; the cost lands on whoever runs the suite in installed-release mode.
Witness:
Probe (scratch tree at 8340b5ff0, real installed qwen):
DEFAULT arm ✓ test-helper.test.ts (8 tests | 7 skipped) 158ms
BUG arm (INTEGRATION_TEST_USE_INSTALLED_GEMINI=true)
× detaches a session's output forwarding during cleanup 20011ms
AssertionError: the stand-in CLI never produced output:
expected false to be true
FIX arm (skipIf extended, installed mode) Tests 8 skipped (8)
FIX arm (skipIf extended, default mode) 1 passed | 7 skipped (8)
Extend the existing skip to cover that mode:
it.skipIf(
process.platform === 'win32' ||
process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true',
)(The guard must key on the exact switch runInteractive honors: process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true' (integration-tests/test-helper.ts:943-944; same string at :279).
中文说明
在脚手架自身支持的 INTEGRATION_TEST_USE_INSTALLED_GEMINI=true 模式(_getCommandAndArgs 的 JSDoc 所载的 npm 包验证模式)下,rig.bundlePath 会被静默地从 argv 中丢弃——runInteractive 会改为派生已安装的 qwen CLI,而不是这个替身脚本,于是 canary 永远不会出现。在该模式下运行发布验证时,测试会耗满整个 20 秒轮询(retry: 2 下重复约 3 次),并以 the stand-in CLI never produced output 失败——这条诊断指向一个从未被派生的替身文件,会把排查者引向失败路径之外的文件。目前仓库里没有任何检查项设置该变量,因此自动化流程不会变红;代价落在以已安装发布模式运行套件的人身上。
修复:扩展现有的 skip 以覆盖该模式(见上方代码块)。守卫必须绑定 runInteractive 实际读取的开关:process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'(integration-tests/test-helper.ts:943-944;:279 处为同一字符串)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
|
||
| // Skipped on Windows: node-pty's kill() terminates the child unconditionally | ||
| // there, so it cannot outlive cleanup() and the premise below does not hold. | ||
| it.skipIf(process.platform === 'win32')( |
There was a problem hiding this comment.
[Suggestion] R4-2: Disclosure rather than a code defect: this file sits outside every npm workspace, so this review run executed zero tests against the diff — build-test ran zero suites and the efficacy probe ran 0 revert probes, 0 mutants and 0 hunk probes (harnessValidated: null). The only collector of this file is the CI Integration Tests (no-AK, No Sandbox) check (root script test:integration:no-ak:sandbox:none, ci.yml integration_no_ak job), which was still pending on this PR's head at review time. If the new test itself is defective — assertions pass for the wrong reason, or it hangs or flakes under CI's KEEP_OUTPUT environment — nothing in this review run would surface it; land the PR only after that check completes green on the current head, since it is the only witness the #11002 fix has.
Witness:
build-test report: ok:true, buildSet:[], test:[], testScope.workspaces: []
caveat: "1 changed file(s) sit outside every workspace and are not inert
(e.g. integration-tests/test-helper.test.ts)"
gh pr checks at review time: "Integration Tests (no-AK, No Sandbox)" pending
No code change required.
中文说明
这是披露而非代码缺陷:该文件位于所有 npm workspace 之外,因此本轮审查没有对该 diff 执行任何测试——build-test 运行了 0 个套件,有效性探针运行了 0 个还原探针、0 个变异体、0 个 hunk 探针(harnessValidated: null)。该文件唯一的收集者是 CI 的 Integration Tests (no-AK, No Sandbox) 检查项(根脚本 test:integration:no-ak:sandbox:none,ci.yml 的 integration_no_ak 作业),而在审查时它在本 PR 的 head 上仍处于 pending 状态。如果新测试本身有缺陷——断言因错误的原因通过,或在 CI 的 KEEP_OUTPUT 环境下挂起或抖动——本轮审查无法发现;请等该检查项在当前 head 上变绿后再合并,因为它是 #11002 修复的唯一见证。无需代码改动。
— qwen3.8-max via Qwen Code /review (v0.23.0)
There was a problem hiding this comment.
Disclosure acknowledged — it asks for no code change and none was made. Recording the evidence that closes its ask, and leaving the thread open because that ask is a merge-time condition no commit in this round can discharge.
The named collector did run green on the reviewed head. Integration Tests (no-AK, No Sandbox) (root script test:integration:no-ak:sandbox:none, the only collector of integration-tests/test-helper.test.ts) completed SUCCESS at 2026-09-04T16:16:59Z, having started at 15:46:29Z — after head d1d375bed4 was committed at 15:45:58Z. So the sole witness for the #11002 fix exists and passed on the head this review read.
Local witness for the same file, this round. npx vitest run --root ./integration-tests ./test-helper.test.ts (default mode, repo config including retry: 2) → 8 passed (8), 290ms. In installed-release mode after R4-1's fix → 7 passed | 1 skipped, 79ms.
The disclosed gap is by design, not missing coverage. Integration Tests (CLI, No Sandbox) is merge_group-only and shows SKIPPED on every pull request; ci.yml records that the separate no-AK check was given its own name precisely so a skipped CLI lane is not read as "the changed integration test never ran" (#9895 round 15).
Why this stays open. This round pushes a new head, so "green on the head that lands" has to be re-met by the no-AK check on that commit — a merge-time gate for the maintainer.
中文说明
已知悉该披露——它未要求任何代码改动,本轮也未做代码改动。此处记录可以满足其诉求的证据;该线程保持未解决,因为它的诉求是一个合并时点条件,本轮任何提交都无法代为完成。
它所点名的收集检查项已在被审查的 head 上跑绿。 Integration Tests (no-AK, No Sandbox)(根脚本 test:integration:no-ak:sandbox:none,是 integration-tests/test-helper.test.ts 的唯一收集者)于 2026-09-04T16:16:59Z 以 SUCCESS 完成,开始于 15:46:29Z——晚于 head d1d375bed4 的提交时间 15:45:58Z。因此 #11002 修复的唯一见证确实存在,并且在本次审查所读取的 head 上通过了。
本轮针对同一文件的本地见证。 npx vitest run --root ./integration-tests ./test-helper.test.ts(默认模式,沿用仓库配置,含 retry: 2)→ 8 passed (8),290ms。在按 R4-1 修复后的已安装发布模式下 → 7 passed | 1 skipped,79ms。
该披露的审查缺口是设计使然,并非覆盖缺失。 Integration Tests (CLI, No Sandbox) 仅在 merge_group 触发,在每个 pull request 上都显示 SKIPPED;ci.yml 中记载,单独设立 no-AK 检查项并赋予其独立名称,正是为了避免把 CLI 检查项的 skip 读成"改动的集成测试从未运行"(#9895 第 15 轮)。
为何保持未解决。 本轮会推送新的 head,因此"在最终合入的 head 上跑绿"这一条件需要由 no-AK 检查项在该提交上重新满足——这是留给维护者的合并时点关卡。
|
🔀 Base updated: red check(s) [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:
integration-tests/test-helper.test.ts:111 — [review] R4-1: in INTEGRATION_TEST_USE_INSTALLED_GEMINI=true mode the witness stand-in is silently dropped from argv; the test burns the 20s poll and fails with a misleading diagnosticintegration-tests/test-helper.test.ts:97 — [test] R4-2: disclosure — file sits outside every npm workspace; this review's build-test ran zero suites against it; the CI no-AK gate is its only collector
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
Closing as a misdiagnosis of run 33834473606 — replaced by #11037. This PR's premise is that run 33834473606 reddened "without naming a failing test" via a post-cleanup broken pipe. The job log contradicts that:
The actual race: the OpenTUI input prompt mounts before any |
|
🤖 Addressed the latest review feedback (round 3/10). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/10 轮)。改动内容与我反驳保留之处如下: Round 5 — review feedback addressedBoth actionable findings this round are [rc:3935531189] R4-1 — installed-release mode silently drops the stand-in CLI → ImplementedReproduced before changing anythingRun on the pre-round tree ( Exactly the reported failure, including the misleading diagnostic. The installed CLI was a 10-line stand-in placed first on No The fix
/** npm-bundle verification mode: spawns the installed CLI, drops bundlePath. */
const INSTALLED_RELEASE_MODE =
process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';Same exact Why the shape differs from the suggested snippetThe inline condition makes the Root-cause triage (answering the convergence observation)The review observed that findings keep returning to this file and suggested triaging a shared root cause before fixing instances. Triage result: the shared premise is "a test substitutes
So there is no further shared fix to make in this file and no sibling finding should follow from this cause. Here the instance is the root cause: the defect is "this test's oracle depends on a script that installed-release mode never spawns", which is a property of this test alone. Widening the skip to the pre-existing liveness test would have removed real coverage in that mode for no gain. Mutation probe (the guard has its own witness)Guard clause removed from the committed test, installed-release arm re-run, then restored: Restore verified byte-exact ( [rc:3935531200] R4-2 — disclosure: no collector ran against this file in the review → Answered with evidence; thread left openNo code change (the finding asks for none). Its ask is a merge-time condition, and the evidence now satisfies it for the reviewed head:
Review-body items (no thread to reply on)
Scope and boundary notes
VerificationCommands actually run this round, with results:
中文说明第 5 轮——评审反馈处理结果本轮两条可执行发现均来自自动化评审的 [rc:3935531189] R4-1——已安装发布模式会静默丢弃替身 CLI → 已实现先复现,再改动在本轮开始前的树( 与所报告的失败完全一致,包括那条误导性的诊断信息。这里的"已安装 CLI"是一个放在 argv 中没有 修复
/** npm-bundle verification mode: spawns the installed CLI, drops bundlePath. */
const INSTALLED_RELEASE_MODE =
process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';与 为什么形态与建议代码片段略有不同内联写法会让 根因定位(回应收敛观察)评审观察到发现反复回到该文件,并建议在逐个修复实例之前先定位共同根因。定位结果是:共同前提是"测试用替身脚本替换
因此该文件中没有更多可做的共同修复,这一根因也不应再产生兄弟发现。此处"实例"本身就是根因:缺陷是"这条测试的判定依据依赖一个已安装发布模式永不派生的脚本",而这只是这条测试自身的属性。把 skip 扩大到既有的存活状态测试,只会在该模式下白白移除真实覆盖。 变异探针(守卫拥有自己的见证)从已提交的测试中移除守卫子句,重跑已安装发布模式分支,然后恢复: 恢复经过逐字节校验( [rc:3935531200] R4-2——披露:评审运行未对该文件执行任何收集器 → 以证据作答;线程保持未解决无代码改动(该发现本身不要求改动)。它的诉求是一个合并时点条件,而现有证据已针对被审查的 head 满足了它:
评审正文条目(无线程可回复)
范围与边界说明
验证(Verification)本轮实际执行的命令及结果:
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。
Re-review when you have a moment. After round 10 this bot stops and leaves the PR for a human. · 有空请复审;第 10 轮后本 bot 停止并将 PR 交给人工。 🧠 Handled by Qwen Code · model/模型 |
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 分片迁往了自托管机器池。