Repository navigation
fix(cli): quiesce the fire-and-forget serve handler across tests - #11417
Conversation
) The 'forwards --token and --allow-origin' serve test returned as soon as runQwenServe had been called while its handler kept running in the background. Under load the handler's Local Control pairing phase was still in flight when the next test installed its one-shot throwing QR mock: the leaked handler consumed it, failed into the serve catch, and hit the mocked process.exit as an unhandled rejection, while the victim test's own handler got the default mock and parked in blockForever until its timeout. On Linux an unhandled rejection fails the whole vitest run without attributing a failure line to any test — the signature behind the per-commit main CI failure #11414 (the same mechanism diagnosed on #11346 and #11363, whose fixes never landed on main). Wait out the pairing phase before the test returns, the same quiescence the neighbouring Local Control tests already perform. Reproduced with a forced race window (a delayed enable): unfixed, the run dies with an unhandled 'process.exit(1) called' rejection; fixed, the file is green. Fixes #11414
Autofix E2E Report — Issue #11414What failedMain-branch CI run 34289483217 on 422929b failed the Root cause
The same mechanism was diagnosed twice before for the same step and the same per-commit signature — issues #11346 and #11363 — but main never received the fix, so the hazard was still live at 422929b and at current HEAD. Both earlier fixes are still open as PRs #11362 and #11376 and carry the identical three-line quiescence; whichever PR lands first resolves the hazard and turns the others into no-ops, and this PR keeps the fix tracked against #11414. Two neighbouring Reproduction (forced race window)The wild race is load-dependent (the leak must survive the inter-test boundary), so I forced the window open deterministically in a temporary probe: delay the first test's
FixOne change in Verification
Integration tests were not run: the change is a unit-test-only quiescence wait and exercises no bundled-CLI or integration-harness behaviour. CI remains the final verification gate. 中文说明故障现象主分支 CI 运行 34289483217(提交 422929b)在 根因
同一机制此前已被诊断过两次,同样是该步骤、同样是按提交签名——issue #11346 和 #11363——但 main 从未合入该修复,因此该隐患在 422929b 和当前 HEAD 上仍然存在。此前的两个修复仍以 PR #11362 和 #11376 的形式处于 open 状态,且携带完全相同的三行静默改动;任意一个先合入即可消除该隐患,其余的随即成为空改动,本 PR 则让该修复同时被 #11414 追踪。同一文件中相邻的两个 复现(强制竞态窗口)野外竞态依赖负载(泄漏必须跨越测试边界存活),因此我用一个临时探针确定性地撑开了窗口:把第一个测试的
修复内容仅改动 验证
未运行集成测试:本次改动仅为单元测试的静默等待,不涉及任何打包 CLI 或集成测试框架的行为。CI 仍是最终验证关卡。 🧠 Handled by Qwen Code · model/模型 |
Local verification report — PR #11417I built a real two-arm environment for this PR, ran it, and then went back to the CI evidence behind the linked issue. Short version: the three added lines are correct and measurably buy something — I reproduced the leak they close, twice, including once with no source mutation at all. But the issue they are attributed to has a different, deterministic root cause that is already fixed on main. My recommendation is to merge the change with a corrected description, not to merge it as the fix for #11414. Environment
1. What the change buys — measured1.1 Forced-race A/BI applied the same mutation to both arms (Local Control
Same outcome under CI's 1.2 It also reproduces with no source mutation at allUnmodified tree in both arms, under CPU contention:
One natural hit in 70 BASE runs, zero in 70 HEAD runs. Rare — but the failing run is the forced-race signature exactly: victim-test timeout and 1.3 Baseline and static gates
1.4 The added wait cannot hangOn this path 2. Where the PR's reasoning does not hold2.1 Run 34289483217 did attribute its failureThe archived job log (job Running the repo's own extractor over that log names both tests: The per-commit fallback came from a log-download race, not from an unattributable failure. The issue-filer job ( That web-shell 2.2 None of the four cited runs is this leak
All four issue-filer runs logged the same 2.3 This leak cannot produce a "no
|
| 主机 | Linux 6.12.63 x86_64,16 核;Node v22.22.2、npm 10.9.7、vitest 3.2.7(锁文件版本) |
| BASE 臂 | origin/main @ 3a75f37ef5 |
| HEAD 臂 | 3a75f37ef5 + cherry-pick a2420452fd → f3282fcf38 |
| 隔离 | 两个独立 git worktree,各自的 node_modules,各自构建 acp-bridge、web-shell、web-templates 与全部九个 channel 包的 dist/ |
packages/cli/src/commands/serve.test.ts 与 packages/cli/src/commands/serve.ts 在本 PR 的 merge base d670d47efe 与当前 main 之间逐字节一致,因此两臂只差 PR 的这三行。
1. 这个改动买到了什么——实测
1.1 强制竞态 A/B
我把同一份变异同时打到两臂(forwards --token and --allow-origin … 的 Local Control enable() 延迟 100 ms 解析,closes the daemon when pairing output fails 延迟 1000 ms),在 packages/cli 下跑 npx vitest run src/commands/serve.test.ts:
| 臂 | 退出码 | 结果 |
|---|---|---|
| BASE | 1 | closes the daemon when pairing output fails 停在 blockForever,15 s 测试超时失败;另有 Unhandled Rejection: Error: process.exit(1) called,栈经过 handler src/commands/serve.ts:982 |
| HEAD | 0 | 70 通过,无未处理错误 |
加上 CI 的 --retry=2 结论相同:BASE 三次尝试全失败,HEAD 通过。
1.2 不做任何源码变异也能复现
两臂均为未改动的代码树,在 CPU 争用下:
| 争用配置 | BASE | HEAD |
|---|---|---|
每臂 30 次,taskset -c 0,1 + 同核 6 个忙等竞争者 |
29 通过,1 失败(第 9 次) | 30 通过,0 失败 |
| 每臂 40 次,各占一个被争用的核 + 2 个竞争者 | 40 通过,0 失败 | 40 通过,0 失败 |
BASE 70 次中自然命中 1 次,HEAD 70 次中 0 次。概率低——但失败那次的签名与强制竞态完全一致:受害测试超时加上 Unhandled Rejection: process.exit(1) called。所以这个隐患在今天的 main 上是活的,不只是理论上的,本 PR 确实把它堵住了。
1.3 基线与静态门
- 无争用时两臂均 70/70。
- 对改动文件
eslint --max-warnings 0通过;prettier --check通过。 - 两臂做差分
tsc -p packages/cli --noEmit:错误集合完全一致(各 2023 行;其中 1442 行在src/ui/opentui/*,来自我本地依赖农场缺少的@opentui类型,另有 1 条@qwen-code/channel-dws的TS2307与 2 条未构建audio-capture的TS6305——都是环境产物)。没有一条涉及serve.test.ts。
1.4 新增的等待不会挂住
这条路径上 startLocalControl 必然走到 qrcode.generate——mock 的 handle 是 webShellMounted: true,getLocalControl().enable() 解析出带 url 的状态——所以被等待的调用是无条件发生的,何况 vi.waitFor 本身有上限。相邻的两个 Local Control 测试早已带着同一行。
2. PR 的推理在哪里不成立
2.1 run 34289483217 其实是有归属的
那次失败作业(job 102272630674)的归档日志现在可以下载,里面有 6 条 FAIL 行,全部是 App.test.tsx > App session callbacks > does not rerender App for other split sessions,根因是 App.test.tsx:28940 的 ReferenceError: mockUseDaemonActivePromptBridge is not defined。该未定义符号在 422929b3a7 上确实还在。整份日志里 process.exit(1) called 出现 0 次。
用仓库自己的提取器跑这份日志,两个测试都能识别出来:
$ node .github/scripts/ci/main-failure-signature.mjs analyze \
--workflow "Qwen Code CI" --jobs failed-jobs.tsv ci-job.log | jq -r '.tests[].id'
App.test.tsx > App session callbacks > does not rerender App for other split sessions (outer pending: false)
App.test.tsx > App session callbacks > does not rerender App for other split sessions (outer pending: true)
按提交追踪的兜底并不是因为失败无法归属,而是因为日志下载抢跑。开 issue 的那个作业(102277172988)在运行结束 36 秒后记录了:
Failed jobs: 1
##[warning]Could not download the log of job 102272630674
Failing tests identified: 0
那个 web-shell 的 ReferenceError 已由 #11406 修复,合入为 3a75f37ef5——也就是当前 main 的 HEAD。所以 #11414 已经在 main 上被另一个改动修好了,本 PR 正文里的 Fixes #11414 会把它按错误的提交自动关闭。
2.2 被引用的四次运行没有一次是这个泄漏
| issue | run | 实际失败的是什么 |
|---|---|---|
| #11346 | 34197708856 | cli 的 1017 个测试文件全部通过;运行死于负载约 160 时的 Error: [vitest-worker]: Timeout calling "onTaskUpdate" |
| #11363 | 34207166214 | src/serve/capabilities-docs-contract.test.ts > … keeps the daemon index capability counts in sync(确定性失败;该测试在今天的代码树上已通过) |
| #11404 | 34257817936 | 同一个 App.test.tsx ReferenceError |
| #11414 | 34289483217 | 同一个 App.test.tsx ReferenceError |
四份作业日志里 process.exit(1) called 均出现 0 次。
四次开 issue 的作业都记录了同样的 ##[warning]Could not download the log of job … 以及随后的 Failing tests identified: 0(作业 102277172988、102182712398,以及 filer run 34201070808 / 34209871251 上的两个)。其中三次的归档日志确实指名了失败测试;只有 #11346 那次运行本来就没有 FAIL 行——因为没有任何测试失败。
2.3 这个泄漏不可能产生"没有 FAIL 行"的运行
它一旦触发,受害测试的 handler 永远不会 settle(它走到 blockForever),因此该测试会以 15 s 测试超时失败,vitest 必然打印一条指名它的 FAIL 行——我做的每一次复现都是如此,加不加 --retry=2 都一样。所以"日志里没有 FAIL 行 ⟹ 就是这个泄漏"这条推理两个方向都不成立,PR 的因果链需要换成 §1 里那套实测论据。
3. 非阻塞观察
N1 —— 本 PR 自己的 CI Test 作业正是挂在真实根因上。 run 34296692250、job 102294835954:同样那两个 App.test.tsx 失败。分支从 d670d47efe 分出,早于 #11406。rebase 到当前 main 应该就绿了。
N2 —— 同一类的第二个 handler 仍未静默。 applies authenticated open before the yargs path starts the daemon 在其 handler 正要进入 maybeOpenWebShellBrowser 时就返回了,而紧接着的下一个测试断言 expect(mockOpenBrowserSecurely).not.toHaveBeenCalled()。只把该 handler 的 runtimeReady 延迟 50 ms(下一个测试延迟 200 ms),两臂都会以 expected "spy" to not be called at all, but actually been called 1 times 同样失败。逐点加等待可行,但结构性的做法大概是让 startServeHandlerWithArgs 保留 handler 的 promise 并在 afterEach 里收尾,这样以后的测试就不必"记得"了。
N3 —— 这道守卫的预算就是 vi.waitFor 的 1 s 默认值。 在 HEAD 臂上加大注入的配对延迟:500 ms、900 ms 通过;到 1100 ms 时等待本身失败(expected "spy" to be called at least once),并且原来的泄漏又回来了。这不算回归——相邻两个测试同样是 1 s 预算,而且被等待的是 mock 模块上的微任务——但显式写 { timeout: 5000 } 可以把这个悬崖去掉。
N4 —— 现在有三个 open PR 带着逐字节相同的 hunk。 #11362、#11376 和本 PR。#11376 里那处 capability 文档计数的 hunk 已不在其 diff 中(该契约测试在今天的代码树上通过),所以三者等价:合一个,另外两个按重复关掉。
建议
代码可以合——泄漏是真的,这个改动确实堵住了它。合入前请:
- rebase 到当前 main(修掉红掉的 Test 作业,见 N1);
- 把
Fixes #11414改成普通引用,并把"没有FAIL行"那套论据换成实测论据——泄漏不需要任何变异就能复现,只是它并不是 Main CI failed: Qwen Code CI on 422929b3a7df #11414 / Main CI failed: Qwen Code CI on 0d1e0fbfa6f3 #11346 / Main CI failed: Qwen Code CI on cffc40495a34 #11363 / Main CI failed: Qwen Code CI on 70cf3633950b #11404 所记录的那件事; - 关闭 Main CI failed: Qwen Code CI on 422929b3a7df #11414 并指向
3a75f37ef5,同时把 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 / fix(cli): quiesce serve test leak and sync capability doc count (#11363) #11376 作为重复关掉。Main CI failed: Qwen Code CI on 0d1e0fbfa6f3 #11346 是另一码事——那次运行死于负载下的 vitest-worker RPC 超时,本 hunk 和 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 的 hunk 都不是它的修复。
另外,main-ci-failure-issue.yml 的维护者值得看一眼:抓取作业日志会与日志归档抢跑——这四次运行全部中招——从而把一次完全可归属的失败悄悄降级成按提交追踪的 issue。就这一行 warning,衍生出了 4 个 issue 和至少 3 个 autofix PR。在 gh api …/logs 调用外面加一层短重试/退避,大部分都能避免。
Verified locally with Claude Code (Opus 5, 1M context). All numbers above come from runs on the machine described in the Environment table; screenshots are unedited renderings of the captured terminal output.
…11417) The 'applies authenticated open before the yargs path starts the daemon' test returned as soon as runQwenServe had been called while its fire-and-forget handler continued into maybeOpenWebShellBrowser. A slow runtimeReady lets that handler's openBrowserSecurely call land after the next test's clearAllMocks, failing its not.toHaveBeenCalled assertion. Wait out the browser-open phase, the same quiescence the neighbouring Local Control tests already perform. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
🤖 Addressed the latest review feedback (round 1/10). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/10 轮)。改动内容与我反驳保留之处如下: Autofix round summary — PR #11417What changedOne commit, Feedback dispositions[rv:5149104112] / [rc:3964084627] — Critical R1-1:
|
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round — PR #11417: no actionOutcomeNo code change this round; nothing committed. The only feedback newer than the last evaluation (2026-09-09T04:04:31Z) is an automated review-infrastructure timeout notice, which carries no actionable code finding. Feedback triage1. Issue-level comment
|
|
@qwen-code /triage |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round — PR #11417: no actionOutcomeNo code change this round; nothing committed. The only feedback newer than the last evaluation (2026-09-09T06:50:45Z) is an automated "sandboxed verification is running" status notice, and the one still-red check is the already-triaged review-runner timeout. Neither carries an actionable code finding. Feedback triage1. Issue-level comment
|
Local runtime verificationI built a real A/B environment for this instead of reading the diff, and drove the two hazards it fixes until they fired. Verdict: the code change is right — merge it, but drop Harness
1. Both leaks are real — and the second one is not in the PR bodyThe mechanism reproduces verbatim, down to the stack frame: Using the PR's own recipe (leaker's
The second row is the head commit 2. The fair version of the experiment, and a sharp thresholdHand-picking a leaker/victim pair proves the mechanism but not the exposure, so I re-ran it as a uniform load model: every one of the 7
The flip is at exactly 50 ms, which is At 3. Why it is rare and unattributable in CI — measured, not assumed40 busy-loop processes on 16 vCPU, 1-minute load average 45–52 (the failing CI job ran at load 33.6), no artificial delay, 8 consecutive runs per arm: 0/8 failures on both arms, 0 unhandled errors. CPU pressure alone cannot do it. With every dependency stubbed by Cost of the fix on the unmodified file, 5 consecutive runs per arm: BEFORE 7.7–8.1 s, AFTER 7.7–8.0 s, 70/70 every time. No measurable cost. 4. Complete for its class, and neither wait can hang22 tests use the fire-and-forget starter. After Two properties the new waits depend on, checked against vitest 3.2.7 rather than assumed:
Blast radius is exactly one test, as the body says: an unconsumed CI on this PR is green where it matters — 5.
|
| 配对 | BEFORE | AFTER |
|---|---|---|
QR / mockQr.generate 一次性 mock |
exit 1 —— 1 failed,1 个未处理错误:process.exit(1) called |
exit 0 —— 70 passed |
浏览器打开 / mockOpenBrowserSecurely |
exit 1 —— expected "spy" to not be called at all, but actually been called 1 times → openBrowserSecurely("http://127.0.0.1:4170/#token=generated-token") |
exit 0 —— 70 passed |
第二行对应 head commit e7e2ecdf20("quiesce the authenticated-open serve handler"),而 PR 描述从未提到它 —— 描述里仍然写着"增加一处静默等待"。同一类缺陷,同样真实,值得写进描述。
2. 更公平的实验版本,以及一个非常锐利的阈值
手工挑一对"泄漏方/受害方"能证明机制,但证明不了暴露面。所以我把它改成统一的负载模型:文件里 7 处 runtimeReady: Promise.resolve() 全部替换为 delayedPromise(D) —— 每个被 mock 的 daemon 都要 D 毫秒才 ready,不针对任何一个测试。
| D (ms) | 30 | 40 | 50 | 55 | 60 | 70 | 150 | 400 | 900 | 1200 |
|---|---|---|---|---|---|---|---|---|---|---|
| BEFORE | 通过 | 通过 | 1 failed | 2 failed | 2 failed +1 未处理 | 2 failed | 2 failed +1 未处理 | — | — | — |
| AFTER | — | — | 通过 | 通过 | 通过 | — | 通过 | 通过 | 通过 | 6 failed |
翻转点正好在 50 ms,也就是 vi.waitFor 的轮询间隔(vitest/dist/chunks/vi.bdSIJ99Y.js:3709:interval = 50, timeout = 1e3)。startServeHandlerWithArgs 等的是 mockRunQwenServe,它的第一次同步检查必然失败,于是 handler 白得约 50 ms 的领先;只有当 handler 自己在 runQwenServe 之后的那条链超出这个窗口,泄漏才变得可达。BEFORE 上失败的测试,恰好且仅仅是本 PR 静默的那两个,从 50 ms 到 150 ms 都是如此。
在 D = 1200 时 AFTER 臂有 6 个测试失败,全部是有界的 vi.waitFor 1000 ms 超时,其中两个是本 PR 之前就存在的等待。所以这 1 秒预算是既有模式的固有属性,不是本 PR 引入的;该修复对这个窗口内的停顿有约 900 ms 余量。
3. 为什么它在 CI 里罕见且难以归因 —— 实测而非推断
16 vCPU 上开 40 个忙等进程,1 分钟负载 45–52(失败的那次 CI job 负载为 33.6),不加任何人工延迟,每臂连跑 8 次:两臂都 0/8 失败,0 个未处理错误。单靠 CPU 压力做不到。当所有依赖都被 mockResolvedValue() 打桩后,handler 剩下的链全是微任务,总是在 50 ms 领先窗口内跑完。在 CI 里,这个间隙必须来自那个特定窗口内的真实停顿 —— GC 暂停、worker 被饿死、一次冷启动的 await import('qrcode-terminal')。这与"偶发且难以归因"的表现一致,也正是即使我在野外抓不到它、也值得把这个口子关掉的理由。
修复在未改动文件上的开销,每臂连跑 5 次:BEFORE 7.7–8.1 秒,AFTER 7.7–8.0 秒,每次都 70/70。无可测量开销。
4. 对这一类问题是完备的,且两处等待都不会挂死
22 个测试使用这个 fire-and-forget starter。runQwenServe 返回之后,只有当参数走到 startLocalControl(--local-control)或 maybeOpenWebShellBrowser(--open/--open-with-auth)时,handler 才会再碰共享 mock;其余 17 个(--no-web)直接落进 blockForever(),什么都不碰。共有 5 个 starter 留下了会碰 mock 的后续工作,本 PR 之后这 5 个全部静默 —— 其中 3 个原本就有等待,这 2 个是缺口。动态扫描也印证了这一点:文件里其他任何测试在两臂上都从未失败。grep -rn "void handler(" --include=*.test.ts packages/ 只有 1 个文件 2 处命中,说明这个模式在仓库里别无他处。
两处新等待所依赖的前提条件,我是对着 vitest 3.2.7 实测的,而不是假设:
status.url存在时,startLocalControl必然调用mockQr.generate(serve.ts:104),而 mock 总会给出一个 URL —— QR 那处等待不会挂死。mockOpenBrowserSecurely需要shouldLaunchBrowser() === true。后面有个测试用mockReturnValue把它翻成false,而vi.clearAllMocks()不会撤销这个实现 —— 但afterEach里的vi.restoreAllMocks()会把vi.fn(impl)重置回impl(我用独立探针验证过:下一个测试看到的是true)。所以这处等待与测试顺序无关地成立。- 万一将来有改动破坏了上述前提,最坏结果也只是一次有界、报错清晰的 1 秒失败,绝不会挂死 —— 这在
D = 1200时观测到了。
影响半径确实就是一个测试,与描述一致:未被消费的 mockImplementationOnce 单靠 vi.clearAllMocks() 是会存活的,但会被这个文件同样在 afterEach 里执行的 vi.restoreAllMocks() 清掉(两者我都直接探测过)。泄漏的 handler 只能污染紧随其后的那一个测试,污染不到更后面的。
本 PR 的 CI 在关键项上是绿的 —— Test (ubuntu-latest, Node 22.x) 通过、Lint & Static 通过 —— 所以 build/typecheck/lint 的说法成立。
5. Fixes #11414 是错的 —— 日志指向的是另一个失败
我用 gh api repos/QwenLM/qwen-code/actions/jobs/102272630674/logs 取到了那份 2.8 MB 的 job 日志:
✓ src/commands/serve.test.ts (70 tests) 3094ms—— 在 Main CI failed: Qwen Code CI on 422929b3a7df #11414 所对应的那次运行里,这个文件是通过的,整个packages/cli也是:1022 个文件、29272 个测试、0 失败。- 整份日志里唯一的
Failed Tests区块在packages/web-shell:App.test.tsx > App session callbacks > does not rerender App for other split sessions,ReferenceError: mockUseDaemonActivePromptBridge is not defined,位置App.test.tsx:28940。2 failed / 6710 passed。 grep -c "Unhandled Rejection"= 0;grep -c "process.exit(1) called"= 0。日志里不存在任何"无归属失败"—— 那次运行明确指出了文件、测试与错误。- 在
422929b3a7上,packages/web-shell/client/App.test.tsx使用mockUseDaemonActivePromptBridge三次、声明零次:这是一个确定性的缺失声明,不是竞态。git log -S显示它被3a75f37ef5移除 —— 即 fix(web-shell): Replace undefined mock in split rerender tests (#11404) #11406,"Replace undefined mock in split rerender tests (Main CI failed: Qwen Code CI on 70cf3633950b #11404)",合并于 2026-09-09T00:10:49Z,比本 PR 开出早 40 分钟。它在main上已经不存在了。
所以描述里的前提("主分支 CI 反复失败……且没有任何测试被标记为失败")并不描述运行 34289483217。issue 正文那句"在任何测试结果被报告前就失败"是提单机器人的通用逐提交模板,而不是对这份日志的解读 —— 错误的根因大概就是这样被挂上去的。
合并前我建议改的
- 去掉
Fixes #11414(想保留可以只作为背景引用)。按现在的写法,合并会自动关闭一个本 PR 并未修复的 issue,并把 fix(web-shell): Replace undefined mock in split rerender tests (#11404) #11406 的功劳记到它头上。Main CI failed: Qwen Code CI on 422929b3a7df #11414 应该指向 fix(web-shell): Replace undefined mock in split rerender tests (#11404) #11406 来关闭。 - 更新描述:现在写的是"增加一处静默等待……不涉及生产代码改动",而 head 实际加了两处;其中浏览器打开那处更容易被触发(它先炸,在 D = 50 ms)。值得补一句,并把标题改到真实主题上。
- 把 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 与 fix(cli): quiesce serve test leak and sync capability doc count (#11363) #11376 作为被涵盖项关闭 —— 两者都只带 QR 那一处,本 PR 是它们各自的严格超集。
- 后续项,不阻塞合并:这已经是同一隐患的第三个 PR,每次都手工修一个调用点,因此下一个漏加等待的 starter 会把口子重新打开。把静默逻辑收进 helper —— 让
startServeHandlerWithArgs接收它必须等到的可观测量,或者保留返回的 promise 并加一个通用afterEach断言"测试体返回后不再有共享 mock 被调用" —— 就能让第四次在结构上不可能发生。上面那个审计用的就是这个afterEach探针,可用。 - 小问题:仅测试的改动用了
fix(cli):标题;head commit 本身用的是test(cli):。
未验证部分
macOS 与 Windows(本 PR 上这两个 Test job 都是 skipping,而且 dangerouslyIgnoreUnhandledErrors 意味着这里"未处理 rejection"那一半在这两个平台上本来就不会失败);真实 CI 条件下的野外竞态(16 次高负载运行中都没有触发 —— 这里所有复现都用了显式异步延迟);以及描述中列为超出范围的 coverage/.tmp ENOENT flake,我没有触碰。
|
🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
The linked issue #11414 is now closed as resolved by #11406. Run 34289483217 did not have an unattributed serve teardown failure: its only failed tests were the two Web Shell split-session cases with |
|
@qwen-code /triage |
|
🕐 Review received — an automatic review of the current head is still running, so this round is held until it lands (a push now would cancel it and discard its work, #8888). Your feedback stays queued for the next eligible round. 中文说明🕐 已收到评审 —— 当前 head 上仍有一轮自动 review 在运行,本轮暂缓(现在推送会取消该 review 并丢弃其工作,#8888)。反馈保持排队,等待下一次可运行的轮次处理。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round — no code changeOutcome: no change, no commit. The diff under review is exactly the 6 added lines in Feedback dispositions
Failed check:
|
|
🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round: no action requiredAll feedback newer than the last evaluation (2026-09-09T12:55:44Z) was triaged: one issue-level comment and one failed check. Neither is actionable in this PR's code, so the working tree is unchanged and no commit was created. Feedback triage
VerificationNo code was changed, so no build, typecheck, lint, or test commands were run this round. Tree state was confirmed by inspection only:
中文说明Autofix 本轮:无需改动已 triage 上次评估(2026-09-09T12:55:44Z)之后的全部反馈:一条 issue 级评论和一个失败的检查。两者都不需要修改本 PR 的代码,因此工作区保持不变,也未创建新的提交。 反馈 triage
验证本轮未修改任何代码,因此未运行 build、typecheck、lint 或测试命令。仅通过检查确认仓库状态:
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round: no actionWhat was triagedFeedback newer than the last evaluation (2026-09-09T20:49:45Z) contains no actionable code findings:
Why no code changeThe diff is three lines in one test file ( There is therefore nothing to fix, decline, or defer in code. The branch is left exactly as it was; no commit was created. Verification (sanity check of current branch state)
中文说明Autofix 本轮:无需改动分类结果上次评估(2026-09-09T20:49:45Z)之后的新反馈中没有任何可执行的代码意见:
为什么不改动代码本 PR 的 diff 只有一个测试文件中的三行( 因此,代码中没有需要修复、拒绝或暂缓的事项。分支保持原样,未创建任何提交。 验证(对当前分支状态的健全性检查)
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
Local re-verification at the current head — PR #11417 (round 3)My two earlier reports on this thread tested heads For the merge decision:
What changed since my last report
Environment
1. The PR's own Reviewer Test Plan no longer tells the arms apartI applied the body's recipe to both arms: the leaker's Local Control
The hazard the body describes is exactly the one #11362 already closed. Following the body's instructions today cannot validate this PR. 2. The hunk that would actually merge: forced-race A/B and mutants
To force the race, test A's
A probe log confirms the timeline. On BASE, test A returns with 0 calls and the call lands inside test B. On PR, the call lands inside test A (≈104 ms in), and A returns with 1 call. Both mutants wait on a side effect that happens before 3. Can it fire without an injected delay? Not on the current file
So this is defensive hardening that matches the neighbouring pattern. It becomes load-bearing the moment that window gains a real async step: a production 4. CI at this headEvery non-skipped check on Merge reference
Not validated
中文说明当前 head 的本地复验 —— PR #11417(第 3 轮)我在本线程之前的两份报告测的是 head 供合并决策参考:
自上次报告以来的变化
环境
1. PR 自己的 Reviewer Test Plan 已无法区分两臂我把描述里的方法施加到两臂:泄漏方的 Local Control
描述里写的隐患,正是 #11362 已经关掉的那个。今天照描述去做,无法验证本 PR。 2. 真正会被合入的那处 hunk:强制竞态 A/B 与变异体
为强制触发竞态,所有臂都让测试 A 的
探针日志印证了时间线。BASE 上,测试 A 返回时调用次数为 0,调用落在测试 B 内。PR 上,调用落在测试 A 内(约第 104 ms),A 返回时调用次数为 1。两个变异体等的都是发生在 3. 不注入延迟能触发吗?在当前文件上不能
所以这是与相邻测试一致的防御性加固。一旦那个窗口里出现真正的异步步骤,它就会变成承重件:比如生产代码在打开浏览器前多一个 4. 当前 head 的 CI
合并参考
未验证部分
|
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: 🤖 AutoFix round report — no code change; the one actionable item (rewriting the PR body) is a GitHub-side edit this loop cannot perform, so it is escalated here with a ready-to-paste replacement. Feedback triaged
Dispositions
Suggested replacement PR title
Suggested replacement PR body (ready to paste)## What this PR does
Adds a quiescence wait to the "applies authenticated open before the yargs path starts the daemon" serve-command test. That test's fire-and-forget handler still has the runtime-ready await and the secure browser-open call ahead of it when the test body returns, and the next test ends by asserting the browser-open mock was never called — so a delayed browser-open call from the first test would land directly on the second test's final assertion. The new wait blocks the first test on the handler's final side effect (the browser-open call itself) before it returns, closing the cross-test leak window. Mutant testing showed that waiting on either earlier side effect (the auth-application mock or the browser-launch-decision mock) still leaks; only the final effect closes the window.
## Why it's needed
Defensive hardening against cross-test mock leakage, matching the quiescence pattern the neighbouring tests already use for the QR/pairing phase. On the current file the whole chain from the serve invocation to the browser open completes within a single microtask drain, so the leak cannot fire today without a code change in that window — but the moment the window gains a real async step (a production await before the browser open, or a genuinely asynchronous runtime-ready mock), the leaked call would flake the next test's never-called assertion.
## Reviewer Test Plan
### How to verify
Force the race in packages/cli/src/commands/serve.test.ts on both arms (main, and main plus this PR): make the first test's runtime-ready promise resolve after ≥ ~70 ms (e.g. 100 ms) and the next test's after 400 ms, then run npx vitest run src/commands/serve.test.ts --coverage.enabled=false from packages/cli. Expected: on BASE the next test fails with "expected spy to not be called at all, but actually been called 1 times", because the leaked browser-open call lands inside it; on the PR arm all 70 tests pass, because the call lands inside the first test before it returns. Unmodified, both arms pass 70/70, and --sequence.shuffle with seeds 1–8 passes on both arms, confirming the new wait does not hang when test order changes.
### Evidence (Before & After)
N/A — test-only change; the forced-race recipe above is the evidence.
### Tested on
| OS | Status |
| :--------: | :--------------------------------: |
| 🍏 macOS | ✅ tested (70/70 pass) |
| 🪟 Windows | ⚠️ not tested |
| 🐧 Linux | ✅ CI Test (ubuntu-latest, 22.x) |
### Environment (optional)
N/A — unit tests only (vitest).
## Risk & Scope
- Main risk or tradeoff: none identified; test-only, and the wait targets an unconditional side effect on this path, so it cannot hang (vitest waitFor would time out loudly if the path regressed).
- Not validated / out of scope: local Windows run; the coverage/.tmp ENOENT flake is unrelated and unchanged.
- Breaking changes / migration notes: none.
## Linked Issues
Refs #11362 (sibling QR/pairing quiescence wait, already merged). Originally opened for #11414; that issue was closed for a different root cause, so this PR intentionally does not claim to fix it.
<details>
<summary>中文说明</summary>
## 本 PR 做了什么
在 "applies authenticated open before the yargs path starts the daemon" 这个 serve 命令测试中新增一处静默等待。该测试的 fire-and-forget handler 在测试体返回时,仍有 runtime-ready 的 await 和安全打开浏览器的调用未执行,而下一个测试以断言浏览器打开 mock 从未被调用结尾——因此第一个测试里延迟的浏览器打开调用会直接落在第二个测试的最后一个断言上。新等待让第一个测试在返回前阻塞在 handler 的最后一个副作用(浏览器打开调用本身)上,关闭跨测试泄漏窗口。变异体测试表明,等待两个更早副作用(鉴权应用 mock 或浏览器启动决策 mock)中的任何一个仍然会泄漏;只有最后一个副作用能关闭窗口。
## 为什么需要
这是针对跨测试 mock 泄漏的防御性加固,与相邻测试用于 QR/配对阶段的静默等待模式一致。以当前文件而言,从 serve 调用到打开浏览器的整条链在一次微任务清空内即可完成,所以今天不在该窗口内改动代码就不会触发泄漏——但一旦该窗口出现真正的异步步骤(生产代码在打开浏览器前多一个 await,或 runtime-ready mock 变为真正异步),泄漏的调用就会使下一个测试的"从未调用"断言抖动。
## 评审者测试计划
### 如何验证
在两臂(main,以及 main 加本 PR)的 packages/cli/src/commands/serve.test.ts 中强制触发竞态:让第一个测试的 runtime-ready promise 在 ≥ 约 70 ms(例如 100 ms)后解析、下一个测试的在 400 ms 后解析,然后在 packages/cli 下运行 npx vitest run src/commands/serve.test.ts --coverage.enabled=false。预期:BASE 上后一个测试失败,报 "expected spy to not be called at all, but actually been called 1 times",因为泄漏的浏览器打开调用落入了该测试;PR 臂上 70 个测试全部通过,因为该调用在第一个测试返回前已落入其中。不做改动时两臂均为 70/70 通过,且 --sequence.shuffle 种子 1–8 在两臂都通过,说明新等待在测试顺序变化时不会挂死。
### 证据(前后对比)
N/A —— 仅测试改动;上述强制竞态方法即证据。
### 测试平台
| 操作系统 | 状态 |
| :--------: | :--------------------------------: |
| 🍏 macOS | ✅ 已测试(70/70 通过) |
| 🪟 Windows | ⚠️ 未测试 |
| 🐧 Linux | ✅ CI Test(ubuntu-latest,22.x) |
### 环境(可选)
N/A —— 仅单元测试(vitest)。
## 风险与范围
- 主要风险或取舍:未发现;仅测试改动,且等待目标在该路径上是无条件副作用,因此不会挂死(若该路径回归,vitest waitFor 会明显超时)。
- 未验证 / 超出范围:本地 Windows 运行;coverage/.tmp ENOENT flake 与本 PR 无关且未改动。
- 破坏性变更 / 迁移说明:无。
## 关联 Issue
Refs #11362(同源的 QR/配对静默等待,已合入)。本 PR 最初因 #11414 而开;该 issue 已因另一根因关闭,因此本 PR 有意不声称修复它。
</details>Verification
中文说明🤖 AutoFix 本轮报告 —— 无代码改动;唯一可执行项(重写 PR 描述)是本循环无权执行的 GitHub 侧编辑,因此在此升级移交,并附上可直接粘贴的替换文本。 已分诊的反馈
各项处置
建议的替换 PR 标题
建议的替换 PR 正文(可直接粘贴)见上方英文区块中的 markdown 代码块——该草案本身已按仓库模板双语齐备(内含完整中文折叠块),可直接整体粘贴。 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
@qwen-code /triage |
1 similar comment
|
@qwen-code /triage |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round — no action neededNo actionable feedback arrived since the last evaluation (2026-09-10T20:02:46Z). Feedback triage:
Sanity check performed: the PR's net diff vs
No commits were made and the working tree remains clean. 中文说明Autofix 本轮 —— 无需处理自上次评估(2026-09-10T20:02:46Z)以来没有收到可处理的反馈。 反馈分类:
已执行的健全性检查: 该 PR 相对
本轮未创建任何提交,工作区保持干净。 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
Maintainer verification — real local A/B on this branchI built a real environment for this PR (worktree at Verdict: the code change is correct, well-targeted and safe to merge. The PR body, however, describes a different change than the one on the branch, and What the branch actually contributesThe three-dot diff against The branch has two functional commits:
Findings1. (Important, docs) The body describes a change that is no longer in the diffThe body says "the That matters for review, because the Reviewer Test Plan is a recipe for the merged #11362 change, not for this one. I ran the body's recipe verbatim (Local Control
So a reviewer who follows the stated plan verifies 2. (Important)
|
| gate | result |
|---|---|
npx vitest run src/commands/serve.test.ts (with PR) |
70/70, 8/8 consecutive runs |
npx vitest run src/commands/serve.test.ts (without PR) |
70/70 |
npm run typecheck |
exit 0 |
npm run lint |
exit 0 |
npm run build |
exit 0 |
npx vitest run src/commands (whole directory, with PR) |
170 files, 7098 passed | 19 skipped |
| PR CI | green |
Recommendation
Merge, after fixing the description. The change itself is a correct, bounded, well-scoped piece of test hardening with no measurable cost. But the record should match the code:
- rewrite "What this PR does" / "Why it's needed" to describe the
--open-with-auth→openBrowserSecurelywait; - replace the Reviewer Test Plan repro with one that exercises this diff (delay test A's
runtimeReady, not Local Control'senable()); - drop
Fixes #11414to a plain reference — Main CI failed: Qwen Code CI on 422929b3a7df #11414's mechanism is already closed by fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362, and leaving the trailer attributes that fix to the wrong commit, which will mislead whoever investigates the next recurrence of this CI signature.
All terminal captures, both reports and the harness notes live on wenshao/qwen-code@assets-pr11417.
中文版(点击展开)
维护者验证 —— 在本分支上做了真实的本地 A/B
我为这个 PR 搭了真实环境(worktree 检出到 6ca85e0b62,完整 npm ci(含 postinstall 的包构建),Node v22.22.2,vitest 3,Linux / 16 核),并通过在原位增删这三行来做 A/B 对比。
结论:代码改动本身正确、定位准确、可以安全合并。但 PR 描述写的是另一个改动,而且 Fixes #11414 并不由这份 diff 兑现。 请在合并前先更新描述。
本分支实际贡献了什么
相对 main 的三点 diff 是 applies authenticated open before the yargs path starts the daemon(packages/cli/src/commands/serve.test.ts:429)中的 3 行,等待的是 mockOpenBrowserSecurely。
分支上有两个实质提交:
| 提交 | 改动 | 相对 main 的净效果 |
|---|---|---|
a2420452fd |
在 forwards --token and --allow-origin 上等待 mockQr.generate |
无 —— main 已经通过已合并的 #11362(10895031e2,2026-09-09)拥有它 |
e7e2ecdf20 |
在 --open-with-auth 上等待 mockOpenBrowserSecurely |
这才是真正随本 PR 发布的改动 |
发现
1.(重要,文档问题)描述写的是已经不在 diff 里的改动
描述说「forwards --token and --allow-origin 这个 Local Control 测试现在会等待其 handler 的配对阶段」。而 diff 等待的是 --open-with-auth 测试的打开浏览器阶段。测试不同、mock 不同、阶段也不同。
这对评审有实际影响,因为 「审查者测试计划」是已合并的 #11362 的复现配方,不是本 PR 的。我逐字执行了描述里的配方(第一个测试的 Local Control enable() 延迟约 100 ms,下一个测试延迟约 1000 ms):
- 移除已合并的 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 等待后 →
Unhandled Rejection: Error: process.exit(1) called,1 failed | 69 passed,Errors 1 error—— Main CI failed: Qwen Code CI on 422929b3a7df #11414 的失败签名与描述完全一致地复现; - 保留已合并的 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 等待(即当前
main,也是本分支的状态)→ 70/70 全绿、零未处理错误,无论本 PR 的三行是否存在。
也就是说,按照描述里的步骤去验证,验证的是 main,不是这个 PR。
2.(重要)Fixes #11414 夸大了这份 diff 的作用
#11414 的签名是由未处理的 process.exit(1) 导致的、无归属的整轮运行失败。本 diff 所封堵的泄漏不可能产生该签名:它唯一的出口是 openBrowserSecurely,而这个调用的所有失败路径都被 maybeOpenWebShellBrowser 自己的 try/catch 接住了。我直接验证过 —— 让泄漏的调用去消费下一个测试安装的一次性 mockOpenBrowserSecurely.mockRejectedValueOnce(new Error('leak-boom')),运行结果是:
qwen serve: failed to open browser: leak-boom. Please open this URL manually: http://127.0.0.1:4170/#token=generated-token
Tests 1 failed | 69 passed (70) <- 受害测试被点名,没有 "Errors" 行
没有未处理 rejection,也没有 process.exit。这个泄漏最坏只会造成一个被点名的测试失败。#11414 真正指向的隐患已经由 #11362 在 main 上关闭,而且 #11414 本身已于 2026-09-09 被手动关闭,所以这条 Fixes 既不准确也已失效。
3.(小问题)在当前的 mock 下,这个等待是空操作 —— 它是保险,不是让 CI 转绿的那一环
startServeHandlerWithArgs 的锚点是「mockRunQwenServe 已被调用」。vi.waitFor 的第一次检查是同步的、在这里必然失败(handler 在调用它之前 await 了 import('../serve/run-qwen-serve.js')),之后的每次检查都是 50 ms 的 setInterval 宏任务。等到锚点被观察到时,下游链路 —— runQwenServe → runtimeReady: Promise.resolve() → openBrowserSecurely —— 早已跑完,因为它全是微任务。
在未打补丁的树上实测:在测试 A 走到断言的那一刻,openBrowserSecurely 25/25 次运行都已经被调用过(15 次空载 + 10 次 CPU 超额订阅、16 核上负载约 38)。套件耗时没有变化(tests 2.70s vs 2.72s)。
4. 这个等待确实做到了它声称的事,范围也划得对
在锚点与可观测点之间强行制造一个真正的宏任务窗口(把测试 A 的 runtimeReady 延迟 300 ms),泄漏就真实发生,而这三行把它封住了 —— 打的是文件里已有的断言,不是我造的断言:
- 不带本 PR:泄漏的
openBrowserSecurely落进prints the authenticated manual URL on the yargs headless path,打破它的expect(mockOpenBrowserSecurely).not.toHaveBeenCalled()→ 1 failed | 69 passed; - 带本 PR:70/70。
两项风险探针都干净:
- 会不会挂住? 不会。把被等待的调用变成不可达(强制测试 A 走 headless 路径)后,
vi.waitFor在其 1000 ms 默认超时处放弃,并给出一个被点名的断言失败。有界且有归属,最坏多花 1 秒。 - 是否漏掉了同类的兄弟测试? 没有。唯一结构相似的地方 ——
keeps Local Control pairing separate from the temporary primary token,同样是--open-with-auth、同样只锚在mockQr.generate上 —— 在同样的强制竞态下依然通过,因为startLocalControl在测试所锚定的 QR 调用之前就await了runtimeReady,因此没有任何可观测行为能活过那个锚点。本文件不需要再补静默等待。
5.(清理)#11376 现在是空的
描述中提到的另一个兄弟 PR #11376,相对 main 已是空 diff(compare main...autofix/issue-11363 → 0 个文件改动),可以关闭。
本地门禁
| 门禁 | 结果 |
|---|---|
npx vitest run src/commands/serve.test.ts(带 PR) |
70/70,连续 8/8 次 |
npx vitest run src/commands/serve.test.ts(不带 PR) |
70/70 |
npm run typecheck |
exit 0 |
npm run lint |
exit 0 |
npm run build |
exit 0 |
npx vitest run src/commands(整个目录,带 PR) |
170 个文件,7098 passed | 19 skipped |
| PR CI | 全绿 |
建议
修好描述后合并。 改动本身是一处正确、有界、范围准确的测试加固,没有可测量的成本。但记录应当与代码一致:
- 重写「本 PR 做什么 / 为什么需要」,改为描述
--open-with-auth→openBrowserSecurely的等待; - 把「审查者测试计划」的复现步骤换成能真正触发本 diff 的那一个(延迟测试 A 的
runtimeReady,而不是 Local Control 的enable()); - 把
Fixes #11414降级为普通引用 —— Main CI failed: Qwen Code CI on 422929b3a7df #11414 的机制已由 fix(cli): quiesce a fire-and-forget serve handler across tests (#11346) #11362 关闭,保留这条 trailer 会把那次修复归到错误的提交上,误导下一个排查同类 CI 签名的人。
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: AutoFix round — no code change (PR-body update requested, needs a maintainer)VerdictNo code change this round. The only actionable feedback — @wenshao's maintainer verification ( Feedback dispositions
Verified facts (this round)
Paste-ready replacement PR bodyApplies recommendations 1–3 verbatim: describes the ## What this PR does
The serve command's test for authenticated open (`--open-with-auth`) now waits for its fire-and-forget handler's browser-open phase to finish before the test returns, so a trailing browser-open call can no longer leak into the next test, which asserts the browser was never opened.
## Why it's needed
The serve handler is fire-and-forget: the test's anchor resolves as soon as the daemon entry point is invoked, but the handler continues downstream — awaiting the runtime-ready promise and then opening the browser — on work the test never awaited. Under a real scheduling delay (e.g. an oversubscribed CI host) that browser-open can execute after its test has returned and fail the following test's assertion. #11362 quiesced the Local Control pairing phase of this same handler; this PR closes the remaining browser-open phase of the authenticated-open path. (See #11414 for the CI signature that motivated the original investigation — its mechanism was already closed on main by #11362.)
## Reviewer Test Plan
### How to verify
Force a real macrotask window between the test's anchor and the browser-open: in the first `--open-with-auth` test, make the mocked daemon result's runtime-ready promise resolve ~300 ms late, e.g. `runtimeReady: new Promise((resolve) => setTimeout(resolve, 300))`. Then run `cd packages/cli && npx vitest run src/commands/serve.test.ts`:
- With this PR's wait removed: the delayed browser-open lands in the next test and breaks its assertion that the browser was never opened — 1 failed | 69 passed.
- With this PR as-is: 70/70 passed.
On unmodified mocks the wait is a no-op — the downstream chain is microtasks only and has always drained by the time the anchor is observed (measured 25/25 runs, including under CPU oversubscription) — so this is scheduling insurance, not a fix for a currently-red signature. Suite cost is unchanged. If the awaited browser-open ever became unreachable, the wait gives up at its 1000 ms default with a named, attributed assertion failure — bounded, never a hang.
### Evidence (Before & After)
N/A — test-only hardening; no user-visible behavior change.
### Tested on
| OS | Status |
| :--------: | :--------------: |
| 🍏 macOS | ⚠️ not tested |
| 🪟 Windows | ⚠️ not tested |
| 🐧 Linux | ✅ tested (70/70) |
### Environment (optional)
N/A — unit tests only.
## Risk & Scope
- Main risk or tradeoff: worst case is a bounded, attributed 1 s wait-timeout failure if the awaited call ever became unreachable; never a hang.
- Not validated / out of scope: no other test in this file needs the same wait — the one structurally similar sibling awaits the runtime-ready promise before its anchor point, so nothing observable outlives its anchor.
- Breaking changes / migration notes: none.
## Linked Issues
Related to #11414 (mechanism already closed on main by #11362) and #11362.
<details>
<summary>中文说明</summary>
## 本 PR 做了什么
serve 命令的认证打开(`--open-with-auth`)测试现在会等待其 fire-and-forget handler 的打开浏览器阶段结束后才返回,因此滞后的打开浏览器调用不会再泄漏到下一个(断言浏览器从未被打开的)测试中。
## 为什么需要
serve handler 是 fire-and-forget 的:测试的锚点在 daemon 入口被调用时即完成,但 handler 仍会继续向下游执行 —— await runtime-ready promise,然后打开浏览器 —— 这些工作测试从未等待。在真实的调度延迟下(例如超负荷的 CI 主机),这次打开浏览器可能在其所属测试返回后才执行,从而使紧随其后那个测试的断言失败。#11362 已经静默了同一 handler 的 Local Control 配对阶段;本 PR 关闭认证打开路径剩余的打开浏览器阶段。(最初促成此项排查的 CI 失败签名见 #11414 —— 其机制已由 #11362 在 main 上关闭。)
## 审查者测试计划
### 如何验证
在第一个 `--open-with-auth` 测试中,让 mock 的 daemon 结果里的 runtime-ready promise 延迟约 300 ms 解决,在锚点与打开浏览器之间制造真实的宏任务窗口,例如 `runtimeReady: new Promise((resolve) => setTimeout(resolve, 300))`。然后运行 `cd packages/cli && npx vitest run src/commands/serve.test.ts`:
- 去掉本 PR 的等待:被延迟的打开浏览器落进下一个测试,打破其「浏览器从未被打开」的断言 —— 1 failed | 69 passed。
- 保持本 PR 原样:70/70 通过。
在未改动的 mock 下该等待是空操作 —— 下游链路全是微任务,在锚点被观察到时总是已执行完毕(实测 25/25 次运行,包括 CPU 超额订阅场景)—— 因此这是针对调度延迟的保险,而不是对当前红色签名的修复。套件耗时不变。若被等待的打开浏览器调用变得不可达,等待会在 1000 ms 默认超时处放弃并给出有归属的具名断言失败 —— 有界,绝不悬挂。
### 证据(前后对比)
N/A —— 仅测试加固;无用户可见行为变化。
### 测试平台
| 操作系统 | 状态 |
| :--------------: | :--------------: |
| 🍏 macOS | ⚠️ 未测试 |
| 🪟 Windows | ⚠️ 未测试 |
| 🐧 Linux | ✅ 已测试(70/70)|
### 环境(可选)
N/A —— 仅单元测试。
## 风险与范围
- 主要风险或取舍:最坏情况是被等待的调用变得不可达时,等待在 1 秒超时处给出一个有归属的有界失败;绝不悬挂。
- 未验证 / 范围之外:本文件没有其他测试需要同样的等待 —— 唯一结构相似的兄弟测试在其锚点之前就 await 了 runtime-ready promise,因此没有任何可观测行为能活过其锚点。
- 破坏性变更 / 迁移说明:无。
## 关联 Issue
关联 #11414(其机制已由 #11362 在 main 上关闭)与 #11362。
</details>Verification
中文说明AutoFix 本轮 —— 无代码改动(请求更新 PR 描述,需要维护者操作)结论本轮无代码改动。唯一可行动的反馈 —— @wenshao 的维护者验证( 反馈处置
本轮核实的事实
可直接粘贴的替换 PR 描述(英文正文中的代码块即完整替换文本,逐条落实建议 1–3:改为描述 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review at head 6ca85e0b. Not approving: the one blocking finding on this PR still stands at this head, and it is about the claim the PR makes rather than the code it ships. The code itself I found clean.
R1-1 still stands — the body certifies a fix the diff does not contain
Read against the live PR body and the live issue at this head, not the thread flag:
- The body still says
Fixes #11414— in both language halves — and its "Why it's needed" section still names run34289483217/ commit422929b3a7as the failure this change repairs, and still offers that run's signature (an unattributed failure inTest (ubuntu-latest, Node 22.x), stepRun tests and generate reports) as the mechanism being fixed. - Issue #11414 is a different failure and is already closed. It is
CLOSED / completedsince 2026-09-09T11:42:46Z, and its body records exactly one observable: theTest (ubuntu-latest, Node 22.x)job of run34289483217failing at commit422929b3a7. @yiliang114 ruled on the thread at 2026-09-09T11:42:49Z that the issue was resolved by #11406, that the run had no unattributed serve teardown failure — its only failed tests were the two web-shell split-session cases raisingReferenceError: mockUseDaemonActivePromptBridge is not defined— and that this change "should not be presented as the fix for #11414". - The whole diff at this head is three lines in
packages/cli/src/commands/serve.test.ts: oneawait vi.waitFor(...)plus its two-line comment. Nothing inpackages/web-shell, no production code. - The author-side loop has confirmed the finding is correct and cannot remedy it: the autofix agent holds no GitHub credentials and the PR body is workflow-owned metadata, so it escalated the body edit as a maintainer action and left the thread open on purpose.
reviewDecisionisCHANGES_REQUESTEDon the strength of exactly this finding.
This is not resolvable from the code, and it is the only thing between this PR and a merge.
Current head — Critical-only scan, no finding
The change adds, inside applies authenticated open before the yargs path starts the daemon:
await startServeHandlerWithArgs('--open-with-auth');
// Wait out the fire-and-forget handler's browser-open phase so its
// openBrowserSecurely call cannot land in the next test.
await vi.waitFor(() => expect(mockOpenBrowserSecurely).toHaveBeenCalled());- It is assertion-preserving. No expectation is removed, weakened or reordered; the three existing assertions still run after the wait. This is the shape the repository's deflake policy asks for.
- It cannot hang.
vi.waitForbounds itself, so a handler that never reaches the browser-open phase fails the test in about a second rather than parking it until the suite deadline — which is the failure mode the issue signature describes. - It waits on the right side effect. The wait target is the last observable of the fire-and-forget handler's own work, so the handler is quiesced before the next test installs its one-shot throwing mock; waiting on an earlier side effect would leave the race open. The maintainer's forced-race A/B at this exact head reports that of the three candidate wait targets only this one closes the race, and that the two mutants which wait earlier still leak.
- Scope is complete for the file. No other call site in the suite is left starting an unawaited handler that this diff's own class of leak would cover, and the change touches no production path, so no runtime behaviour can regress.
CI at this head
Every check passes — unit tests, lint and static analysis, integration tests, the web-shell smoke gate, the TUI gates and review-pr. Nothing pending, and no failure attributable to this PR.
Next step
A maintainer needs to edit the PR body: drop Fixes #11414 from both language halves and keep the issue as context, pointing the tracking claim at the occurrence this change actually addresses (#11346 is the one the thread names as still relevant). The autofix loop has a ready-to-paste replacement in its latest round report. Once the body no longer certifies a fix for a failure it does not touch, the code side needs nothing further — it is already verified at this head by a maintainer's A/B run and by a green suite.
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: 🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: AutoFix round — no code change (PR-body edit remains the only blocker; needs a maintainer)VerdictNo code change this round. The one blocking finding — [rv:5174992550] R1-1, the PR body still certifying Feedback dispositions
Verified facts (this round)
Paste-ready replacement PR body (updated)Implements the reviewer's next step verbatim: drops ## What this PR does
The serve command's test for authenticated open (`--open-with-auth`) now waits for its fire-and-forget handler's browser-open phase to finish before the test returns, so a trailing browser-open call can no longer leak into the next test, which asserts the browser was never opened.
## Why it's needed
The serve handler is fire-and-forget: the test's anchor resolves as soon as the daemon entry point is invoked, but the handler continues downstream — awaiting the runtime-ready promise and then opening the browser — on work the test never awaited. Under a real scheduling delay (e.g. an oversubscribed CI host) that browser-open can execute after its test has returned and fail the following test's assertion. #11362 quiesced the Local Control pairing phase of this same handler; this PR closes the remaining browser-open phase of the authenticated-open path. (See #11414 for the CI signature that motivated the original investigation — its recorded failure was a Web Shell `ReferenceError`, already resolved by #11406.)
## Reviewer Test Plan
### How to verify
Force a real macrotask window between the test's anchor and the browser-open: in the first `--open-with-auth` test, make the mocked daemon result's runtime-ready promise resolve ~300 ms late, e.g. `runtimeReady: new Promise((resolve) => setTimeout(resolve, 300))`. Then run `cd packages/cli && npx vitest run src/commands/serve.test.ts`:
- With this PR's wait removed: the delayed browser-open lands in the next test and breaks its assertion that the browser was never opened — 1 failed | 69 passed.
- With this PR as-is: 70/70 passed.
On unmodified mocks the wait is a no-op — the downstream chain is microtasks only and has always drained by the time the anchor is observed (measured 25/25 runs, including under CPU oversubscription) — so this is scheduling insurance, not a fix for a currently-red signature. Suite cost is unchanged. If the awaited browser-open ever became unreachable, the wait gives up at its 1000 ms default with a named, attributed assertion failure — bounded, never a hang.
### Evidence (Before & After)
N/A — test-only hardening; no user-visible behavior change.
### Tested on
| OS | Status |
| :--------: | :--------------: |
| 🍏 macOS | ⚠️ not tested |
| 🪟 Windows | ⚠️ not tested |
| 🐧 Linux | ✅ tested (70/70) |
### Environment (optional)
N/A — unit tests only.
## Risk & Scope
- Main risk or tradeoff: worst case is a bounded, attributed 1 s wait-timeout failure if the awaited call ever became unreachable; never a hang.
- Not validated / out of scope: no other test in this file needs the same wait — the one structurally similar sibling awaits the runtime-ready promise before its anchor point, so nothing observable outlives its anchor.
- Breaking changes / migration notes: none.
## Linked Issues
Related to #11346 (the occurrence the thread names as still relevant), #11414 (closed as resolved by #11406 — a different failure signature), and #11362 (merged sibling quiescence for this handler).
<details>
<summary>中文说明</summary>
## 本 PR 做了什么
serve 命令的认证打开(`--open-with-auth`)测试现在会等待其 fire-and-forget handler 的打开浏览器阶段结束后才返回,因此滞后的打开浏览器调用不会再泄漏到下一个(断言浏览器从未被打开的)测试中。
## 为什么需要
serve handler 是 fire-and-forget 的:测试的锚点在 daemon 入口被调用时即完成,但 handler 仍会继续向下游执行 —— await runtime-ready promise,然后打开浏览器 —— 这些工作测试从未等待。在真实的调度延迟下(例如超负荷的 CI 主机),这次打开浏览器可能在其所属测试返回后才执行,从而使紧随其后那个测试的断言失败。#11362 已经静默了同一 handler 的 Local Control 配对阶段;本 PR 关闭认证打开路径剩余的打开浏览器阶段。(最初促成此项排查的 CI 失败签名见 #11414 —— 其记录的失败是 Web Shell 的 `ReferenceError`,已由 #11406 解决。)
## 审查者测试计划
### 如何验证
在第一个 `--open-with-auth` 测试中,让 mock 的 daemon 结果里的 runtime-ready promise 延迟约 300 ms 解决,在锚点与打开浏览器之间制造真实的宏任务窗口,例如 `runtimeReady: new Promise((resolve) => setTimeout(resolve, 300))`。然后运行 `cd packages/cli && npx vitest run src/commands/serve.test.ts`:
- 去掉本 PR 的等待:被延迟的打开浏览器落进下一个测试,打破其「浏览器从未被打开」的断言 —— 1 failed | 69 passed。
- 保持本 PR 原样:70/70 通过。
在未改动的 mock 下该等待是空操作 —— 下游链路全是微任务,在锚点被观察到时总是已执行完毕(实测 25/25 次运行,包括 CPU 超额订阅场景)—— 因此这是针对调度延迟的保险,而不是对当前红色签名的修复。套件耗时不变。若被等待的打开浏览器调用变得不可达,等待会在 1000 ms 默认超时处放弃并给出有归属的具名断言失败 —— 有界,绝不悬挂。
### 证据(前后对比)
N/A —— 仅测试加固;无用户可见行为变化。
### 测试平台
| 操作系统 | 状态 |
| :--------------: | :--------------: |
| 🍏 macOS | ⚠️ 未测试 |
| 🪟 Windows | ⚠️ 未测试 |
| 🐧 Linux | ✅ 已测试(70/70)|
### 环境(可选)
N/A —— 仅单元测试。
## 风险与范围
- 主要风险或取舍:最坏情况是被等待的调用变得不可达时,等待在 1 秒超时处给出一个有归属的有界失败;绝不悬挂。
- 未验证 / 范围之外:本文件没有其他测试需要同样的等待 —— 唯一结构相似的兄弟测试在其锚点之前就 await 了 runtime-ready promise,因此没有任何可观测行为能活过其锚点。
- 破坏性变更 / 迁移说明:无。
## 关联 Issue
关联 #11346(讨论串中指认的仍然相关的 occurrence)、#11414(已由 #11406 关闭 —— 失败签名不同)与 #11362(已合并的、针对同一 handler 的兄弟静默改动)。
</details>Once a maintainer applies that body edit (retitling along the lines of Verification
中文说明AutoFix 本轮 —— 无代码改动(PR 描述编辑仍是唯一阻塞项,需要维护者操作)结论本轮无代码改动。唯一的阻塞发现 —— [rv:5174992550] R1-1,即 PR 描述仍声称 反馈处置
本轮核实的事实
可直接粘贴的替换 PR 描述(已更新)逐字落实评审者的下一步:在两个语言版本中去掉 (上方英文部分的代码块即完整替换文本,已按 PR 模板自带中文翻译,此处不再重复粘贴。) 维护者应用该描述编辑后(标题同步改为类似 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
Withdrawing the withholding in my two prior reviews — the one blocking ground is resolved at
|
| R1-1 asked for | State at 9fc78a24c105 |
|---|---|
Drop the Fixes #11414 trailer from both language halves |
Gone from both — no Fixes trailer anywhere in the body |
| Keep #11414 as context, disclaiming it | Both halves now carry it: "#11414 is not addressed by this PR: that run failed on an unrelated packages/web-shell test, which #11406 resolved." / "本 PR 不处理 #11414…" |
| Point the tracking claim at the occurrence this change actually addresses | Related to #11346 (same cross-test leak class; the pairing-test wait for it landed in #11362) |
| Drop the superseded run/commit citation | 34289483217 and 422929b3a7 no longer appear in the body |
| Align "What this PR does" with the hunk actually at head | It now describes the browser-open wait in applies authenticated open before the yargs path starts the daemon, and names #11362 as having landed the Local Control pairing-test half on main first |
That closes both halves of R1-1 — the false root-cause certification and the description/hunk mismatch.
2. The diff I measured is the diff at head
The head moved 6ca85e0b622a → 9fc78a24c105 via a Merge branch 'main', so the PR's own content is unchanged: 1 file, packages/cli/src/commands/serve.test.ts, blob f151086f7b92, +3/−0, zero production files — byte-identical to what my 04:34Z review scanned.
Because a main merge can falsify a +import line invisibly (the patch is a diff against the merge base, so a symbol main contributed through the merge is on neither side of it), I ran the compile-level check on the head blob rather than on the patch: exactly one declaration site of mockOpenBrowserSecurely (:15, vi.hoisted), 7 import lines, zero duplicate import paths. No TS2300-class collision landed through the merge.
3. Executed A/B at the new head — when the wait is load-bearing, and what it costs
Run in a git archive scratch tree at 9fc78a24c105 (no build, no worktree), Node v24.18.1 / vitest 3.2.7, --retry=0. Evidence is a file witness, not stdout: packages/cli/vitest.config.ts:237 sets silent: true, so a console.log witness is muted and a muted zero is byte-identical to a true negative. Probes record mockOpenBrowserSecurely.mock.calls.length at four points; beforeEach calls vi.clearAllMocks(), so a call recorded in a later probe belongs to a later test.
| Arm | Change vs head | 417_at_start_return |
417_end |
440_pre_assert |
Result |
|---|---|---|---|---|---|
| A ×3 | none (wait present) | calls=1 |
calls=1 |
calls=0 |
70/70 pass, :417 ≈ 54 ms |
| B ×3 | the 3 added lines removed | calls=1 |
calls=1 |
calls=0 |
70/70 pass — indistinguishable from A |
| D | runtimeReady deferred 300 ms, wait absent |
calls=0 |
calls=0 |
calls=0 |
:417 returns in 56 ms with the browser-open call still outstanding |
| E | runtimeReady deferred 300 ms, wait present |
calls=0 |
calls=1 |
calls=0 |
70/70 pass, :417 = 306.5 ms |
| C | browser launch disabled, wait present | calls=0 |
(never reached) | calls=0 |
1 failed / 69 passed in 1064 ms |
What that settles:
- The wait is load-bearing exactly when the browser-open phase outlasts the harness's own poll — which is the production shape.
maybeOpenWebShellBrowserdoesawait handle.runtimeReady(commands/serve.ts:151) before it can reachopenBrowserSecurely(:183), and in productionruntimeReadysettles on real I/O. The shipped fixture suppliesruntimeReady: Promise.resolve(), so the whole path is microtask-only and arm B cannot reproduce the leak: A ≡ B in 3/3 trials, andsetTimeout(…, 0)is still inside the poll window (D ≡ E at 0 ms). Defer it by 300 ms and the un-waited test returns with the call outstanding (arm D), while the waited test absorbs exactly the delay and records the call inside the test that caused it (arm E). That is why the original failure is intermittent rather than constant: it needs a runner slow enough to push runtime-ready past the poll. - The wait's cost is proportional, not fixed. Arm E's
:417took 306.5 ms against a 300 ms deferral — it waits as long as the phase takes and no longer. Neighbouring tests are unaffected (:44055.2 ms in both arms). - "Cannot hang" is now measured, not asserted. My earlier review claimed
vi.waitForbounds itself. Arm C forces the browser-open phase never to happen with the wait left in place: the suite fails one test in 1064 ms (1 failed / 69 passed) rather than parking until the suite deadline. So a future production change that stops launching the browser fails this test loudly in about a second. - The suite is green at the post-merge head: 70/70 in
serve.test.ts, 3/3 trials on the pristine blob (f151086f7b92, restored and re-hashed after every arm).
4. CI at head
commits/9fc78a24c105/check-runs census 23/23 complete. 11 product lanes: 8 success, 3 skipped, 0 non-green — including Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, TUI parity snapshots, OpenTUI no-flicker gate and both Desktop Shell lanes. The three skipped are Test (macos-latest, …) and Test (windows-latest, …), which do not run for a non-main base. The only lane repo-wide that is not green is review-pr, still in progress — the review bot's own lane, never a CI verdict.
5. What this comment does not claim
- No production surface was exercised, because there is none — the diff is one test file. A tmux/interactive-TUI report could not measure anything about it, so the instrument here is the suite the change lives in plus the arms above.
- I did not reproduce the maintainer's forced-race A/B, and arm B is the reason: the necessity of the wait under CI load rests on that A/B (three candidate wait targets, only this one closing the race) plus arm D's ordering inversion, not on anything I ran at head.
- The
NO CRITICALI recorded at6ca85e0bcovers the three added lines and their isolation mechanism. It is not a claim about the other 69 cases in the file.
Verdict: approving at 9fc78a24c105. The code side needed nothing further at 6ca85e0b and is byte-identical now; the body side was the only outstanding item and it is closed.
qqqys
left a comment
There was a problem hiding this comment.
Approving at 9fc78a24c105. My two prior reviews withheld on one ground — the body certifying Fixes #11414, a fix this diff does not contain — and that ground is closed at this head: no Fixes trailer in either language half, both halves now disclaim #11414 and name #11406 as what resolved it, and the tracking claim points at #11346. The diff is byte-identical to the one I scanned at 6ca85e0b (1 file, packages/cli/src/commands/serve.test.ts, blob f151086f7b92, +3/−0, zero production files). The executed A/B, the bounded-failure measurement and the 23/23 CI census are in the comment posted alongside this review.
|
🕐 Review received — an automatic review of the current head is still running, so this round is held until it lands (a push now would cancel it and discard its work, #8888). Your feedback stays queued for the next eligible round. 中文说明🕐 已收到评审 —— 当前 head 上仍有一轮自动 review 在运行,本轮暂缓(现在推送会取消该 review 并丢弃其工作,#8888)。反馈保持排队,等待下一次可运行的轮次处理。 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix review round — no action requiredFeedback triageExactly one feedback item arrived since the last evaluation (2026-09-11T04:34:24Z), and it requests no change:
There are no inline comments, no review-body findings, no failed checks, and no still-red checks in this round's feedback. Action takenNone. The PR head is unchanged at VerificationNo code was changed, so no pre-commit verification was required. As independent confirmation of the state the approval certifies, the suite the change lives in was run once at this head:
中文说明Autofix 评审轮次 —— 无需处理反馈分类自上次评估(2026-09-11T04:34:24Z)以来只有一条反馈,且它不要求任何改动:
本轮反馈中没有行内评论、没有评审正文发现、没有失败的检查,也没有持续红色的检查。 采取的操作无。PR head 保持在 验证没有改动代码,因此不需要提交前验证。作为对批准所认证状态的独立确认,在本 head 上运行了一次该改动所在的测试套件:
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
















What this PR does
Makes one more
serveunit test wait for its fire-and-forget handler to settle before the test returns. The testapplies authenticated open before the yargs path starts the daemonstarts the handler with--open-with-authand used to return as soon as the daemon mock had been called, while the handler still had its authenticated browser-open step ahead of it; it now also waits until that step has called the mocked browser launcher. This is the same quiescence pattern the neighbouring Local Control tests already use. Test-only; no production code changes.Note: an earlier revision of this PR also added the equivalent wait to the Local Control pairing test (
forwards --token and --allow-origin …). #11362 landed that identical change onmainfirst, so it is no longer part of this diff. The browser-open wait described here is what this PR still contributes; it is not a no-op now that #11362 has landed.Why it's needed
Without the wait, the handler's browser-open call can land in the next test,
prints the authenticated manual URL on the yargs headless path, whose final assertion is that the browser was never opened — so that test would fail for a reason that has nothing to do with its own behavior.On the file as it stands this cannot happen yet: the whole chain from the daemon mock to the browser open completes within a single microtask drain, before
vi.waitFor's timer-based poll can observe the daemon call, so CPU load alone does not trigger it. It becomes a real cross-test leak as soon as that window gains an asynchronous step of roughly one poll interval (≥ ~50 ms) — for example a productionawaitbefore the browser open, or a mock whoseruntimeReadyis genuinely asynchronous. This PR closes that latent hazard in the same way #11362 closed the equivalent one in the pairing test.Unlike the pairing-phase leak, this one cannot cause the unattributed whole-run failure (an unhandled
process.exit(1)): every failure of the browser launch is caught inside the handler, so its worst case is a named failure of the next test.Reviewer Test Plan
How to verify
The hazard only fires with an asynchronous stall in that window, so verify with a forced race window applied identically with and without this PR, then run
npx vitest run src/commands/serve.test.tsinpackages/cli:applies authenticated open before the yargs path starts the daemon, make the mockedruntimeReadyresolve after 100 ms instead ofPromise.resolve().prints the authenticated manual URL on the yargs headless path, make itsruntimeReadyresolve after 400 ms.AssertionError: expected "spy" to not be called at all, but actually been called 1 times. With this PR all 70 tests pass.Also confirmed under the same window: waiting on an earlier side effect instead (
mockApplyOpenWithAuthormockShouldLaunchBrowser) still fails, so the chosen wait target is the one that matters. With the file unmodified, both arms pass 70/70 idle, with every CPU core saturated, and under--sequence.shuffle(seeds 1–8), so the new wait does not hang when test order changes.Evidence (Before & After)
N/A (test-only change, no user-visible behavior). Maintainer verification with forced-race A/B, mutants and reachability probes: #11417 (comment) (Linux) and #11417 (comment) (macOS).
Tested on
Environment (optional)
N/A — unit tests only (Linux x64 with Node 22.22.2 and macOS arm64 with Node 24.18.1, vitest 3.2.7).
Risk & Scope
vi.waitForbounds the wait at 1 s even if a future change breaks that. With this change every fire-and-forget test in the file that still has work after its anchor is quiesced.startServeHandlerWithArgshelper, which would prevent the next hand-missed wait, is left as a follow-up.Linked Issues
Related to #11346 (same cross-test leak class; the pairing-test wait for it landed in #11362). #11414 is not addressed by this PR: that run failed on an unrelated
packages/web-shelltest, which #11406 resolved.中文说明
本 PR 做什么
让又一个
serve单元测试在返回前等待它的 fire-and-forget handler 稳定下来。测试applies authenticated open before the yargs path starts the daemon以--open-with-auth启动 handler,原先在 daemon mock 被调用后就立即返回,而 handler 后面还有带认证的浏览器打开步骤没执行;现在它还会等到这一步调用了被 mock 的浏览器启动函数。这与相邻 Local Control 测试已经采用的静默模式相同。仅测试改动,不涉及生产代码。说明:本 PR 的早期版本还给 Local Control 配对测试(
forwards --token and --allow-origin …)加了同样的等待。#11362 先把完全相同的改动合入了main,所以它已不在本 diff 中。这里描述的浏览器打开等待才是本 PR 仍然贡献的内容;#11362 合入后,本 PR 并不是空改动。为什么需要
没有这处等待时,handler 的浏览器打开调用可能落到下一个测试
prints the authenticated manual URL on the yargs headless path里,而该测试最后一个断言正是"浏览器从未被打开"——于是它会因为与自身行为无关的原因失败。以当前文件的状况,这还不会发生:从 daemon mock 到浏览器打开的整条链在同一次微任务清空内就跑完了,早于
vi.waitFor基于定时器的轮询观察到 daemon 调用,因此单靠 CPU 负载不会触发。一旦这个窗口里多出一个大约一个轮询周期(≥ 约 50 ms)的异步步骤,它就会变成真实的跨测试泄漏——例如生产代码在打开浏览器前多一个await,或者 mock 的runtimeReady变成真正异步。本 PR 以 #11362 关闭配对测试中同类隐患的相同方式,关闭这个潜在隐患。与配对阶段的泄漏不同,这一处不会造成无归属的整轮失败(未处理的
process.exit(1)):浏览器启动的任何失败都在 handler 内部被捕获,所以它最坏的结果是下一个测试出现一次有名字的失败。审查者测试计划
如何验证
该隐患只有在那个窗口里出现异步停顿时才会触发,因此请用强制竞态窗口验证:在有无本 PR 的两种情况下等同地施加,然后在
packages/cli下运行npx vitest run src/commands/serve.test.ts:applies authenticated open before the yargs path starts the daemon中,把 mock 的runtimeReady从Promise.resolve()改为 100 ms 后解析。prints the authenticated manual URL on the yargs headless path中,让它的runtimeReady400 ms 后解析。AssertionError: expected "spy" to not be called at all, but actually been called 1 times。有本 PR 时 70 个测试全部通过。在同一窗口下还确认了:改为等待更早的副作用(
mockApplyOpenWithAuth或mockShouldLaunchBrowser)仍然失败,说明所选的等待目标才是关键。文件不做改动时,两臂在空闲、所有 CPU 核心占满、以及--sequence.shuffle(种子 1–8)下都是 70/70 通过,因此新等待在测试顺序变化时不会挂死。前后对比证据
N/A(仅测试改动,无用户可见行为)。维护者验证(含强制竞态 A/B、变异体与可达性探针):#11417 (comment) (Linux)与 #11417 (comment) (macOS)。
已测试平台
环境(可选)
N/A——仅单元测试(Linux x64 + Node 22.22.2,以及 macOS arm64 + Node 24.18.1,vitest 3.2.7)。
风险与范围
vi.waitFor也会把等待限制在 1 秒内。本改动之后,文件中所有在锚点之后仍有后续工作的 fire-and-forget 测试都已静默。startServeHandlerWithArgshelper、从而防止下一次漏加等待,留作后续工作。关联 Issue
与 #11346 相关(同一类跨测试泄漏;针对它的配对测试等待已通过 #11362 合入)。本 PR 不处理 #11414:那次运行失败的是
packages/web-shell中一个无关的测试,已由 #11406 解决。