Bug Report Checklist
Expected
When a test file schedules a callback that throws (e.g. setTimeout(() => { throw ... })) at load time, the output should be deterministic and self-consistent across runs. Either:
- the run is declared unrecoverable and the suite does not run (stable
0 passing, no test tree printed after the summary), or
- the suite runs first and the uncaught error is reported afterwards (stable
1 passing),
but never a mix where EVENT_RUN_END is emitted and the suite events (suite-start / test-pass) are still emitted afterwards.
Actual
The result is nondeterministic across runs of the very same file:
- sometimes
0 passing (the real, synchronous, passing test never counts), sometimes 1 passing;
- when
0 passing, the spec reporter's test tree is printed after the run summary (out of order).
A "bad" run (0 passing, tree printed after the summary):
1) Uncaught error outside test suite
0 passing (0ms)
1 failing
1) Uncaught error outside test suite:
Uncaught Error: boom
at Timeout._onTimeout (repro.test.js:2:9)
...
suite
✔ should pass
A "good" run on the same file (1 passing):
1) Uncaught error outside test suite
suite
✔ should pass
1 passing (1ms)
1 failing
...
Root cause. Runner#run defers the start via Runner.immediately(prepare) (lib/runner.js). The setTimeout(throw) registered earlier at file-load time can win the macrotask race and fire before prepare runs. At that moment this.state !== STATE_RUNNING, so Runner#_uncaught takes the "test run has not yet started; unrecoverable" branch and synthesizes the whole run:
// lib/runner.js — _uncaught()
} else {
// Can't recover from this failure
debug('uncaught(): test run has not yet started; unrecoverable');
this.emit(constants.EVENT_RUN_BEGIN);
this.fail(runnable, err);
this.emit(constants.EVENT_RUN_END); // run is now "ended"
}
The EVENT_RUN_END listener sets state = STATE_STOPPED, but the already-queued prepare is never cancelled. It still fires, calls begin() → runSuite(rootSuite, …), and emits a second, phantom run (EVENT_RUN_BEGIN, EVENT_SUITE_BEGIN, EVENT_TEST_PASS, …, another EVENT_RUN_END) after the run was already ended. Hence 0 passing (stats captured at the premature EVENT_RUN_END) co-existing with a rendered ✔ should pass, the tree after the summary, and the nondeterminism (whichever timer wins). A guard like if (this.state === STATE_STOPPED) return; at the top of prepare removes the phantom run; whether the suite should still run when an uncaught fires before start is the design question.
Related: #5251 (open, same family — uncaught in process.nextTick → later tests reported passed without running; this is the "before run start" sibling with the extra out-of-order symptom); #4481 (closed, 8.2.0 uncaught double-reporting); #4025 (v6.2.2, fixed double EVENT_RUN_END for "uncaught after a test passed", explicitly not the "before run start" case).
Minimal, Complete and Verifiable Example
repro.test.js:
setTimeout(() => {
throw new Error('boom');
});
describe('suite', () => {
it('should pass', () => {});
});
Run ~8 times; both 0 passing and 1 passing outcomes appear, and the 0 passing runs print the tree after the summary.
Versions
- mocha: 11.7.6 (also reproduces on 11.5.0)
- node: v24.11.1
- OS: macOS
- reporter: default (spec)
Additional Info
No response
Bug Report Checklist
npm audit, or GitHub Advisory issue.faqlabel, but none matched my issue.Expected
When a test file schedules a callback that throws (e.g.
setTimeout(() => { throw ... })) at load time, the output should be deterministic and self-consistent across runs. Either:0 passing, no test tree printed after the summary), or1 passing),but never a mix where
EVENT_RUN_ENDis emitted and the suite events (suite-start/test-pass) are still emitted afterwards.Actual
The result is nondeterministic across runs of the very same file:
0 passing(the real, synchronous, passing test never counts), sometimes1 passing;0 passing, the spec reporter's test tree is printed after the run summary (out of order).A "bad" run (
0 passing, tree printed after the summary):A "good" run on the same file (
1 passing):Root cause.
Runner#rundefers the start viaRunner.immediately(prepare)(lib/runner.js). ThesetTimeout(throw)registered earlier at file-load time can win the macrotask race and fire beforeprepareruns. At that momentthis.state !== STATE_RUNNING, soRunner#_uncaughttakes the "test run has not yet started; unrecoverable" branch and synthesizes the whole run:The
EVENT_RUN_ENDlistener setsstate = STATE_STOPPED, but the already-queuedprepareis never cancelled. It still fires, callsbegin()→runSuite(rootSuite, …), and emits a second, phantom run (EVENT_RUN_BEGIN,EVENT_SUITE_BEGIN,EVENT_TEST_PASS, …, anotherEVENT_RUN_END) after the run was already ended. Hence0 passing(stats captured at the prematureEVENT_RUN_END) co-existing with a rendered✔ should pass, the tree after the summary, and the nondeterminism (whichever timer wins). A guard likeif (this.state === STATE_STOPPED) return;at the top ofprepareremoves the phantom run; whether the suite should still run when an uncaught fires before start is the design question.Related: #5251 (open, same family — uncaught in
process.nextTick→ later tests reported passed without running; this is the "before run start" sibling with the extra out-of-order symptom); #4481 (closed, 8.2.0 uncaught double-reporting); #4025 (v6.2.2, fixed doubleEVENT_RUN_ENDfor "uncaught after a test passed", explicitly not the "before run start" case).Minimal, Complete and Verifiable Example
repro.test.js:Run ~8 times; both
0 passingand1 passingoutcomes appear, and the0 passingruns print the tree after the summary.Versions
Additional Info
No response