You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.*.js — execute() tears the worker down:
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
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.
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
Upstream, either half would close it: flush pending console logs before $rejectPendingCalls, or do not reject a fire-and-forget notification into an unhandled rejection. Whether to raise that with vitest is triage's call, not this finding's.
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".
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 replacementconsolebatches writes and flushes them from a microtask:packages/vitest/dist/chunks/init.*.js—execute()tears the worker down:and the cleanup it pushed:
rpcDone()awaits a snapshot (Array.from(promises)) taken when it is called. Any console RPC created after that snapshot is still pending when$rejectPendingCallsruns, and gets rejected. BecausesendLogdiscards the promise, nobody is holding it — so the rejection is unhandled,listenForErrorscatches 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.logemitted late enough, in any package, turns a fully green suite into exit 1.Two properties that make it nasty
MinimalReporter, which setssilent: 'passed-only'— console output from passing tests is never printed. But the worker still sends the RPC. Measured: a probe test'sconsole.lognever appeared in the output while itsprocess.stdout.writedid. 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.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-showcasesuites × 3 rounds, with a probe wrappingrpc.onUserConsoleLog:What this does and does not claim
vitest --detectAsyncLeaksfinds those (it named flaky: a vitest worker teardown race (EnvironmentTeardownError: Closing rpc while onUserConsoleLog was pending) fails app-showcase with 334/334 tests passing #9371's file and no other in the showcase suite).$rejectPendingCalls, or do not reject a fire-and-forget notification into an unhandled rejection. Whether to raise that with vitest is triage's call, not this finding's.Recognising the next one
Errors NwithTests X passed (X)and a non-zero exit, messageEnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending.