Skip to content

finding: any late console.* in a vitest worker can fail a fully green suite — the #9371 amplifier is upstream and still live #10374

Description

@os-zhuang

Recorded while fixing #9371. The trigger there was ours and is fixed. The amplifier is vitest's and is not. Filing so the next occurrence is diagnosed in minutes instead of two sightings and a reproduction.

The mechanism, read out of the installed vitest (4.1.10)

packages/vitest/dist/chunks/console.*.js — the worker's replacement console batches writes and flushes them from a microtask:

functionsendLog(type,taskId,content,size,origin){state().rpc.onUserConsoleLog({ type, content, taskId, ... });// ← return value DISCARDED}

packages/vitest/dist/chunks/init.*.jsexecute() tears the worker down:

}finally{awaitrpcDone().catch(()=>{});awaitPromise.all(cleanups.map((fn)=>fn())).catch(()=>{});// ← $rejectPendingCalls}

and the cleanup it pushed:

awaitPromise.all(rpc.$rejectPendingCalls(({ method, reject })=>{reject(newEnvironmentTeardownError(`[vitest-worker]: Closing rpc while "${method}" was pending`));}));

rpcDone() awaits a snapshot (Array.from(promises)) taken when it is called. Any console RPC created after that snapshot is still pending when $rejectPendingCalls runs, and gets rejected. Because sendLog discards the promise, nobody is holding it — so the rejection is unhandled, listenForErrors catches it, and it is reported as an unhandled error. Vitest fails a run on an unhandled error even when no assertion failed.

Net: one console.log emitted late enough, in any package, turns a fully green suite into exit 1.

Two properties that make it nasty

  1. It is invisible in the log. Vitest 4's non-TTY default reporter is MinimalReporter, which sets silent: 'passed-only' — console output from passing tests is never printed. But the worker still sends the RPC. Measured: a probe test's console.log never appeared in the output while its process.stdout.write did. So a suite can be paying hundreds of RPC round-trips per run for output nobody will ever read, and the only symptom is the flake.
  2. The window is load-width. It is the duration of rpcDone(). On an idle box that is ~1 ms; on a saturated queue runner it is long enough for a lingering async callback to slip inside. That is the whole of the "load-dependence" in flaky: a vitest worker teardown race (EnvironmentTeardownError: Closing rpc while onUserConsoleLog was pending) fails app-showcase with 334/334 tests passing #9371 — nothing about the code under test changes.

Reproduced

On a 4-vCPU container, 4 concurrent full examples/app-showcase suites × 3 rounds, with a probe wrapping rpc.onUserConsoleLog:

RPCREJECT 30847 t=38057 id=137 err=EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending
… ×6, all in one worker, all in the same millisecond sweep
AFTERALL 30847 t=37655 ← the file's tests and hooks were already done
RPCSTART 30847 t=37899 id=135 ← eight console RPCs began AFTER that
PROCEXIT 30847 t=38099

What this does and does not claim

Recognising the next one

Errors N with Tests X passed (X) and a non-zero exit, message EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending. ⚠️ The file it names is where the rejection originated — in practice the file the worker was on — so read it as "this file had console output in flight at teardown", not "the bug is in this file".

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions