Uh oh!
There was an error while loading. Please reload this page.
test(showcase): disarm the vitest console-forwarding teardown race that reddens a green suite (#10293, #10374) - #10605
Conversation
…10293, #10374) vitest 4.1.10's worker console ships every write to the main thread over RPC and discards the returned promise (`sendLog` in its console chunk). Teardown awaits a SNAPSHOT of in-flight calls (`rpcDone()`) and then rejects whatever is still pending, so a console.log emitted after that snapshot is rejected with EnvironmentTeardownError with no handler attached — an unhandled rejection, and vitest fails a run on an unhandled error even when every assertion passed. Reproduced deterministically-enough to measure: a fixture that leaves a console.log rescheduling past the end of its file reddens a 1-passed/1-passed run 8/10 times on an idle box. Turn the interception off for this app: with no RPC there is no pending call to reject, so no future leak can redden a green run this way. It is not a silencing — the non-TTY reporter already discards this app's passing-test console output after paying the round-trip, so writing it straight to stdout makes it visible for the first time. The pin spawns real vitest processes over the fixture: an ablation leg under vitest defaults that must still reproduce the harm, and a guarded leg under this app's real config that must exit 0. Both assert the fixture was collected, so a run that collected nothing cannot read as a pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…urface This package deliberately ships without @types/node (types/node-shim.d.ts, and the tsconfig note explaining why vitest.config.ts is kept out of `include`), and compiles as CommonJS under module: NodeNext — so `import.meta.url` is rejected outright by `tsc --noEmit` with TS1470, and `process.env` is not on the shimmed `process`. Seed paths from `process.cwd()`, which is what test/coverage.test.ts and test/inert-wirings.test.ts already use and what the shim is cut for, and widen the shim by exactly the two members the pin needs: a synchronous-only `spawnSync` with the three result fields it reads, and `process.env`. Also correct the measured cost recorded in vitest.config.ts: the 72 onUserConsoleLog calls per run carry batched buffers, so the visible log grows by 285 lines, not 72. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
`pnpm check:empty-changeset` refuses an empty-frontmatter changeset outright, and its reasoning applies exactly here: this PR touches `examples/` and tests only, so it releases nothing, while an empty changeset is still a REAL input to changesets/action — an all-empty pending set makes the action print "All changesets are empty; not creating PR" and return green, which is how 17.0.0-rc.2 stalled silently. Route 2 it is: no changeset, `skip-changeset` label on the PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
os-zhuang
commented
Aug 21, 2026
PM triage of the red CI — the guard is right, the probe is wrong. Fix dispatched.
|
…ured prose The teardown-race pin scraped the child's human reporter output for `Test Files 1 passed (1)`. vitest 4 colourises that line on any machine std-env does not classify as an agent shell, so the escapes land between `Test Files` and its count and the regex can never match on CI — while it matches for the author, whose shell sets AI_AGENT/CLAUDECODE and makes vitest call disableDefaultColors() and pick the `agent` reporter. Pin the child to plain output (NO_COLOR, no FORCE_COLOR, an explicitly named reporter), strip ANSI from the captured bytes before any predicate reads them, and print each graded child's exit status and output tail so a CI-only collection failure is diagnosable from the log it fails in.
os-zhuang
commented
Aug 21, 2026
PM review — ACCEPT, arming. My hypothesis was right about the mechanism and wrong about the trigger, and the correction matters.Head ⭐ The correction to my diagnosisI told you the child colorizes because it inherits That inverts the whole picture: it is not that CI turns color on, it is that every agent shell in this fleet turns it off — so this class of bug is invisible to every one of us locally, by construction, and only ever appears on a runner. ⭐ And it explains a detail I had no account for: why the CI ablation broke after one attempt (1625 ms, one cold child) rather than exhausting all eight — ⭐ And my suggested fallback would have made it worse. I proposed setting Reproducing the CI red locally by unsetting the agent variables ( What earns the ACCEPT
Why this one matteredThis is the P1: Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10293
Part of #10374
A fully green
@objectstack/example-showcaserun can still exit 1. This makes that structurally impossible for this app, and pins it with a reproduction that fails without the change.The defect, read out of the installed vitest (4.1.10)
The worker replaces
consolewith one that ships every write to the main thread over RPC, andsendLoginpackages/vitest/dist/chunks/console.*.jsdiscards the returned promise. Teardown inpackages/vitest/dist/chunks/init.*.jsthen runsawait rpcDone()and immediately after a cleanup callingrpc.$rejectPendingCalls(...)— andrpcDone()awaits a snapshot (Array.from(promises)) taken when it is called. A console RPC created after that snapshot is still pending when the rejection sweep runs, is rejected withEnvironmentTeardownError, and because nobody kept the promise it surfaces as an unhandled rejection. Vitest fails a run on an unhandled error even when every assertion passed:The window is exactly the duration of
rpcDone()— about a millisecond idle, wide enough on a saturated runner for a leaked timer or poll to log inside it. That is the whole of this flake's load-dependence; nothing about the code under test changes. It dequeued three merge-queue PRs in one afternoon, each cascade forcing every speculative build behind it to rebuild.The change
disableConsoleIntercept: trueinexamples/app-showcase/vitest.config.ts— vitest's own supported option. With no RPC there is no pending call to reject, so no future leak in any test in this app can redden a green run this way. It removes the mechanism rather than narrowing one trigger.It is not a silencing. Vitest's non-TTY default reporter sets
silent: 'passed-only', so this app's console output from passing tests is discarded today after paying the round-trip. Written straight to the worker's stdout it becomes visible for the first time. Measured: 72onUserConsoleLogcalls per run, each carrying a batched buffer, so the visible log grows by 285 lines (about 0.9% of a 31,839-line Test Core shard log), most of it[Registry]registration chatter. That is the honest cost, and it is recorded in the config docblock. Quieting the registry itself is a separate question about@objectstack/objectql's default log level.Failing-then-passing evidence
The failure happens during worker teardown, strictly after every test in the file has finished, so no in-process assertion can see it — the only symptom is the child's exit code.
test/vitest-console-teardown-race.test.tstherefore drives real vitest processes overtest/fixtures/late-console-teardown/, a fixture that passes its one assertion and leaves aconsole.logrescheduling past the end of the file:vitest.config.ts, which must exit 0.Both legs assert
Test Files 1 passed (1), because a run that collected nothing also exits 0 and would read as a pass.Measured on an idle 4-vCPU container:
disableConsoleIntercept: true, standaloneThe ablation was run against the committed tree and confirmed on disk before each leg (guard-line count 1 to 0, then back to 1;
git diff --statshowing exactly the one deletion), not inferred from an editor's exit code.Two things in the brief that the tree falsified
test/approval-resume-relation-expand.test.tscontains zeroconsole.*calls. The[Registry] ...lines around it in the CI log come frompackages/objectql/src/registry.ts— product code — not from the test. And "RPC pressure" is not the driver: the whole suite emits 72 console RPCs per run, not hundreds per file.main. Under finding: any late console.* in a vitest worker can fail a fully green suite — the #9371 amplifier is upstream and still live #10374's own recipe — 4 concurrent full showcase suites, 3 rounds, 4 vCPU — the suite came back 12/12 green with this guard removed, matching the earlier investigation's 13/13. The specific trigger behind the three CI sightings is most plausibly the messaging dispatcher leak repaired in fix(service-messaging): stop the delivery dispatchers on the kernel's own teardown hook (#9371) #10375, which merged at 18:08Z on 2026-08-20, after the last sighting at 16:39Z. So this change is a structural defence for the package where the flake fired three times, not the repair of a currently-firing failure. It is filed that way deliberately rather than claimed as a reproduction of the live incident.On the repo-wide half, and on
--detectAsyncLeaks#10374 is general: any package is exposed. Closing it means 73 vitest test tasks (40 with a config, 33 without), which is a diff across every lane and outside this card's surface — so it is left open and the rollout shape is proposed in the report instead.
Its graded scope was to evaluate wiring
vitest --detectAsyncLeaks. Measured here: showcase wall time 21s to 40s (about 1.9x), and its four findings on this suite are never-settledPromise.racetimeout guards inObjectKernelthat emit no console output and therefore cannot cause this defect. Recorded as #10604. It is a detector for a different class, so it is not wired here.Follow-up (
d88f4445d1): the pin read the child through coloured proseTest CoreandTest Core (1/6)went red onb13c4fad07, both from this PR's own new test and both from its anti-vacuity guard, which refused to grade a nested run it could not see:The guard was right; the collection probe was wrong.
runFixture()detected collection by scraping the child's human reporter output forTest Files 1 passed (1), and vitest 4 decides both the colour and the reporter of that output from the environment it finds itself in.std-env'sisAgent- true whenAI_AGENT/CLAUDECODEare set, i.e. in the shell this was authored from - makes vitest call tinyrainbow'sdisableDefaultColors()(vitest/dist/chunks/cac.*.js) and select theagentreporter instead ofdefault. A CI runner has neither variable, so the identical summary line arrives coloured, with escapes sitting betweenTest Filesand its count (escapes written here asESC):The regex matches for the author and can never match on CI. Two further predicates over the same text carried the identical fragility: the ablation leg's
Tests 1 passed (1)assertion (equally unmatchable), andoutput.includes(TEARDOWN_ERROR), which survived only because no escape happens to land inside that phrase - which is why the ablation broke after one attempt on CI rather than exhausting eight.It is not
FORCE_COLOR. Measured in this container:FORCE_COLOR=1,CI=true,GITHUB_ACTIONS=trueand even--colorall leave the child's summary plain in an agent shell, becausedisableDefaultColors()runs after every one of them. The reproduction is unsetting the agent variables - the only difference that matters - after which both legs fail locally with the exact CI assertion text at the exact CI line numbers (143 and 170).The repair, all inside
runFixture()and its guards:NO_COLOR=1, the one condition tinyrainbow short-circuits on ahead of every enabling one, and deleteFORCE_COLORrather than set it to'0', because tinyrainbow tests its presence and the disabling spelling would have switched colour on;--reporter=default) instead of lettingisAgentpick it, so one summary format reaches the author and CI alike;stripVTControlCharactersbefore anything reads them - one boundary, so all three predicates are fixed at once rather than one regex at a time;Measured, before and after, in both environments:
Tests 3 passed (3)FORCE_COLOR=1Tests 3 passed (3)Tests 3 passed (3)Positive control, so this is not the measurement being switched off: the fixture was renamed out of the child's include glob, and both guards still refused - now naming the cause in the message,
No test files found, exiting with code 1. Restored and confirmed byte-identical by sha256, not by an editor's exit code. The ablation leg still reproducesEnvironmentTeardownError(that assertion sits inside the now-green leg) and the guarded leg is still clean across all 4 repetitions.Verification
Union re-derived with
node scripts/pm/dispatch-gates.mjs(no paths passed) and re-run atd88f4445d1, the final commit - all green, each read from the gate's own verdict line rather than from a piped$?:check:nul-bytes·check:cross-package-test-inputs·check:test-source-alias·check:type-source-resolution·check:query-options-erasure·check:type-check-coverage·check:type-check-debt(--self-testplus--re-measure: "33 ledger entr(ies) re-measured in 272.2s, 1924 raw tsc error(s) total, none above its recorded number") ·check:engine-double-contract·check:where-matcher·pnpm --filter @objectstack/example-showcase typecheck(tsc --noEmit, exit 0).Suite, in both shapes:
Test Files 24 passed (24)/Tests 364 passed (364)in the ordinary shell, and the same 24/364 under the CI-shaped environment (agent variables unset,CI=true,GITHUB_ACTIONS=true) that reproduces the red. Heavy runs were serialised throughscripts/pm/os-verify-lock.sh; both suite runs reportedVERDICT command-exit 0.No changeset:
pnpm check:empty-changesetrefuses an empty-frontmatter changeset and routes anexamples/-and-tests-only PR to theskip-changesetlabel instead, since an all-empty pending set makes changesets/action return green without creating a version PR.Generated by Claude Code
Generated by Claude Code