Repository navigation
test(cli): yield the event loop between base-tree tests - #12573
Conversation
Every base-tree test drives real git through spawnSync/execFileSync, so the vitest worker's event loop never turns while the file runs (~115 s locally, ~118 s on GitHub-hosted runners). vitest's worker->main `onTaskUpdate` RPC times out after 60 s, and the cli leg exits 1 with every test green: `Errors 1 error: [vitest-worker]: Timeout calling "onTaskUpdate"`. Since #11540 grew the file to 106 tests, this fails `Test (ubuntu-latest)` on every hosted run, which is where fork PRs go. Yield once before each test, using a setImmediate captured at load so fake timers cannot intercept it, the same approach as the scripts test setup (#10050). The longest stall drops to ~12 s with unchanged wall time.
qqqys
left a comment
There was a problem hiding this comment.
Independent Critical-only review — head 021e9222
Test-infrastructure only: nine added lines in packages/cli/src/commands/review/base-tree.test.ts, no production code and no assertion changed.
The change does what it claims
The suite drives real git through spawnSync/execFileSync, which blocks synchronously, so the worker's event loop never turns for the duration of the file. Vitest's worker→main onTaskUpdate RPC then exceeds its timeout and the run exits non-zero with every test green — a false CI failure, which is the right thing to fix.
The remedy yields one loop turn per test:
const realSetImmediate = setImmediate;
beforeEach(() => new Promise<void>((resolve) => realSetImmediate(resolve)));Three properties make this correct rather than merely plausible:
- The primitive is captured at module load.
const realSetImmediate = setImmediateexecutes at import time, before any test body can install fake timers, so the yield cannot be swallowed by a mocked timer queue. This file does not currently callvi.useFakeTimers, so the capture is defensive rather than load-bearing today — but it is what keeps the hook working if a later test in this file fakes timers, and it costs nothing. beforeEachis genuinely in scope. It is imported fromvitestat line 18 alongsidedescribe,it,expect,afterEachandvi, so the file-level hook cannot fail with aReferenceErrorthat would take the whole suite down.- Hook order is right. The file-level hook at
:65is registered outside anydescribe, so vitest runs it before the suite's ownbeforeEachat:201that creates the base tree and acquires the worktree lease. The yield therefore drains the RPC queued by the previous test before the next one starts blocking on git, which is exactly the "bounds each stall to one test" property the comment claims.
Vitest awaits a promise returned from a hook, so returning new Promise<void> does make the yield real rather than fire-and-forget.
Consistent with an existing repo-wide precedent
The comment cites scripts/tests/test-setup.ts, and that precedent is real and already merged at main (:22-32), describing the same failure mode — "a worker's event loop blocked for the whole file (>60s)" — and applying the same capture-at-load technique:
const realSetTimeout = setTimeout;
beforeEach(() => new Promise((resolve) => realSetTimeout(resolve, 0)));The only difference is the primitive: setImmediate here versus setTimeout(…, 0) there. Both are captured before tests run and both yield a full loop turn; setImmediate fires in the check phase after I/O polling and is not subject to timer clamping, which is a reasonable choice for a suite whose stalls are caused by child-process I/O. Two independent sites in the repo now describe and treat the same vitest behaviour, which corroborates the diagnosis rather than resting on it.
Isolation impact
Adding a yield between tests does open a window in which a promise left pending by the previous test can settle. That is inherent to the fix and is bounded here: the prior test has already completed its assertions, and the suite's lease is released in afterEach (:382) before the next test's yield runs, so nothing that a passed assertion depended on can be retroactively disturbed. No existing assertion, expectation, fixture or timeout was modified — the diff is purely additive.
Scope and CI
One file, nine lines, no historical review and no review thread on this PR, so there were no prior blocking issues to re-verify. I did not audit the rest of base-tree.test.ts beyond the hook interactions above, since the diff cannot reach it.
Test (ubuntu-latest, Node 22.x), review-pr and triage were all still in progress at the time of review. I did not wait for them; per Critical-only scope a pending check is not a gate. Worth noting for the author that the check which actually demonstrates the fix is the one still running, so the empirical confirmation lands with CI rather than with this review.
Verdict: APPROVE — No Critical found. The hook is correctly scoped and ordered, the primitive is captured before fake timers could intercept it, and the technique matches an already-merged repo-wide precedent for the same vitest event-loop starvation.
|
Verdict: merge-ready — local deep verification, 27/27 scripted assertions passed (0 unexpected failures). Verified head: 中文摘要结论:可以合并。 本地深度验证,27/27 条脚本断言全部通过。
未覆盖:真实 GitHub hosted runner(以 PR 自身 CI 转绿代替)、macOS/Windows 腿、cli 全量测试(只跑了受影响文件)。 Central claim and A/BClaim: with the 9-line hunk, the Control construction: one built tree at the PR head; the base arm is the same tree with the test file restored to
Base: 6/6 reproductions of the exact CI failure signature. Head: 4/4 clean. Wall time identical within noise, matching the PR's "wall time does not change" claim. Mechanism, measured end-to-end
Fake-timer capture is load-bearingThe hunk captures
The suite itself uses no fake timers (grep), so the capture is defensive — matching CI corroboration
Gates
FindingsNone blocking. Two informational notes, both already disclosed in the PR body:
Not covered
MethodologyWorktree of head |
Without a pin, the yield can be deleted with every test still green; the only signal would be the hosted-only onTaskUpdate error returning. Add the two-test witness from #10050, armed with the same captured setImmediate the yield uses (immediates run FIFO, so the armed flag fires first). Removing the yield turns `observes the event loop turned between tests` red while the other 107 tests pass.
|
Thanks for the review. Here is how each point is handled. Triage note 1 / R1-1 (no guard test). Adopted in Triage note 2 ( CI.
中文说明感谢评审,各点处理如下。 Triage 意见 1 / R1-1(没有守护测试): 已在 Triage 意见 2( CI:
🤖 Generated with Claude Code — Claude Opus 5.5 (1M context) |
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Independent Critical-only review — head 1014ad02
Re-derived at this head. My earlier approval was recorded at 021e9222 and was auto-dismissed when the branch moved.
What moved
021e9222..1014ad02 is one commit, test(cli): pin the base-tree event-loop yield with a witness pair, adding 16 lines to packages/cli/src/commands/review/base-tree.test.ts and nothing else. No production file changed, so the yield hook I verified at the previous head is untouched — I re-confirmed it is still present and unmodified at :65 rather than assuming so from the commit message.
No historical blocker
The only finding ever filed on this PR is R1-1, a Suggestion at floor:"o", reporting that nothing in the repository pinned the invariant the yield establishes. There is one review thread and it is resolved. No Critical has been recorded in any round, and there is no CHANGES_REQUESTED against this PR.
The new commit is the answer to that Suggestion, so I checked whether it actually pins the invariant or merely looks like it does.
The witness is genuine, not vacuous
let yieldObserved = false;
it('arms a flag from a real macrotask callback', () => {
realSetImmediate(() => { yieldObserved = true; });
});
it('observes the event loop turned between tests', () => {
expect(yieldObserved).toBe(true);
});Three properties make this a real mutation-killer:
setImmediateis the right oracle. AsetImmediatecallback fires only in the check phase of a genuine loop turn. Microtask draining between tests — promise continuations in the runner, which is all that would happen without the hook — cannot reach the check phase, so the flag staysfalse. The witness therefore fails if the yield is removed, which is exactly the propertyR1-1asked to have pinned.- It cannot be vacuous in this file specifically. The defect being fixed exists because the loop does not turn on its own here: the suite drives real git through
spawnSync/execFileSyncand blocks the worker for the whole file. That is the strongest available evidence that nothing else in the runner supplies the turn the witness depends on. - Ordering is correct and the scope is clean. Both tests sit at file top level, after the
});that closesdescribe('runBaseTree')at:3995, so the suite's ownbeforeEachat:201— which creates the base tree and acquires the worktree lease through blocking git — does not apply to them. I confirmed the yield at:65is the only file-level hook in the file: there is no file-levelafterEach,beforeAllorafterAllthat could turn the loop independently. Immediates run FIFO, so the callback armed during the first test fires before the resolve the second test'sbeforeEachqueues, and the assertion observestruefor the right reason.
The tests reuse the same load-captured realSetImmediate the yield itself uses, so the witness stays correct even if a future test in this file installs fake timers — the property that made the original fix robust is preserved in the thing that now pins it.
One consequence worth stating rather than gating on: the pair is order-dependent by construction, so it assumes vitest runs tests in file order. That holds under the default configuration and is inherent to any witness for an inter-test property; a shuffled sequence would be the only thing to break it, and it would break loudly rather than silently passing.
CI
review-pr was queued at this head; no failure attributable to this diff, and CI state is not a gate. The check that empirically demonstrates the original fix — Test (ubuntu-latest, Node 22.x) — was not among the outstanding checks at the time of review.
Verdict: APPROVE — No Critical found. The delta is test-only, no production code moved since the yield was verified, and the added witness genuinely pins the invariant by depending on a check-phase turn that only the yield supplies.
yiliang114
left a comment
There was a problem hiding this comment.
LGTM at 1014ad02. Test-only change, CI green at this head, no open threads, and qqqys has already approved this head.




What this PR does
Makes the review base-tree test suite let the vitest worker's event loop turn once before every test. The yield uses a
setImmediatecaptured when the file loads, so a test that installs fake timers cannot swallow it. This is the approach #10050 took for the scripts test setup.This is a test-only change of 9 added lines in one file. Production code is untouched.
Why it's needed
Test (ubuntu-latest, Node 22.x)fails on every GitHub-hosted run even though every test passes. The cli leg reportsTests 32798 passedand thenErrors 1 error: [vitest-worker]: Timeout calling "onTaskUpdate", which makes the job exit 1. Fork PRs from authors without write access are routed to hosted runners, so they all hit this.The cause is the base-tree suite. Since #11540 it has 106 tests, and each one drives real git synchronously. On a hosted runner the whole file takes about 118 s, and the worker's event loop never turns during it. vitest's worker-to-main
onTaskUpdateRPC gives up after 60 s. ECS runners finish the file in 22–26 s, which is why the failure only shows up on hosted runs.Recent examples, all with this exact signature and the base-tree suite at about 118 s:
Every hosted
Test (ubuntu)job completed on 09-23 failed this way.Reviewer Test Plan
How to verify
Run the base-tree suite on its own from
packages/cliwithCI=true. Before this change it reports 106 passed plus theonTaskUpdateunhandled error, and exits 1. After it, it reports 106 passed and exits 0. On CI,Test (ubuntu-latest, Node 22.x)on a hosted runner should go green, assuming nothing else fails.Evidence (Before & After)
Local runs on
main64ac2faae7, Linux x86_64, Node 22,CI=true, using the cli's own vitest config plus an observation-only setup file. The setup file runs a 100 ms interval in the worker and records the longest stretch the event loop did not turn.main)Timeout calling "onTaskUpdate", exit 1 (4/4)The wall time does not change. The longest remaining stall is a single test, about 12 s, well under the 60 s RPC deadline.
eslint --max-warnings 0,prettier --checkandtsc --noEmitforpackages/cliall exit 0.Tested on
Environment (optional)
N/A. These were unit-test runs only.
Risk & Scope
testTimeoutcannot interrupt synchronous code. That limit is the same one fix(ci): yield the event loop between script tests to avoid vitest RPC timeouts (#10037) #10050 has.test-setup.ts. That would protect future suites too, but it touches all 1083 cli test files, so it deserves its own discussion.Linked Issues
Refs #11540 (grew the suite to 106 tests) and #10050 (the same fix for script tests). Observed on #12250.
中文说明
本 PR 做了什么
让 review base-tree 测试套件在每个测试开始前,把 vitest worker 的事件循环让出一次。这次让出用的是文件加载时捕获的
setImmediate,所以即使测试安装了 fake timers 也吞不掉它。做法与 #10050 在 scripts 测试 setup 中的一致。这是一个纯测试改动:一个文件新增 9 行,生产代码未改。
为什么需要
Test (ubuntu-latest, Node 22.x)在所有 GitHub hosted runner 上都会失败,尽管测试全部通过。cli 这一段先报Tests 32798 passed,接着报Errors 1 error: [vitest-worker]: Timeout calling "onTaskUpdate",job 因此以 1 退出。没有写权限的作者提的 fork PR 会被分到 hosted runner,所以它们都会遇到这个问题。根源在 base-tree 套件。自 #11540 起它有 106 个测试,每个都同步驱动真实的 git。在 hosted runner 上整个文件约 118 秒,期间 worker 的事件循环一次也没有转动。vitest 从 worker 到主进程的
onTaskUpdateRPC 在 60 秒后放弃。ECS runner 跑完这个文件只需 22–26 秒,所以只有 hosted 运行才会失败。近期实例,特征完全相同,base-tree 套件都在约 118 秒:
09-23 所有已完成的 hosted
Test (ubuntu)job 都以这种方式失败。评审测试计划
如何验证
在
packages/cli下以CI=true单独运行 base-tree 套件。修改前的结果是 106 passed,外加onTaskUpdate未处理错误,退出码 1。修改后是 106 passed,退出码 0。在 CI 上,只要没有其他失败,hosted runner 上的Test (ubuntu-latest, Node 22.x)应该变绿。证据(前后对比)
本地在
main64ac2faae7上运行,Linux x86_64,Node 22,CI=true。使用 cli 自己的 vitest 配置,外加一个只做观测的 setup 文件:它在 worker 中跑一个 100 ms 的 interval,记录事件循环最长一次没有转动的时长。main)Timeout calling "onTaskUpdate",退出码 1(4/4)总耗时不变。剩下最长的一次卡顿是单个测试,约 12 秒,远低于 60 秒的 RPC 期限。
packages/cli的eslint --max-warnings 0、prettier --check和tsc --noEmit均 exit 0。测试平台
环境(可选)
不适用,只运行了单元测试。
风险与范围
testTimeout打断不了同步代码。这一限制与 fix(ci): yield the event loop between script tests to avoid vitest RPC timeouts (#10037) #10050 相同。test-setup.ts里为整个 cli 加让出。那样能同时保护以后的套件,但会影响全部 1083 个 cli 测试文件,值得单独讨论。关联 Issue
参见 #11540(把套件扩到 106 个测试)和 #10050(script 测试的同类修复)。问题在 #12250 上观察到。