Skip to content

🐛 Bug: uncaught exception scheduled before the run starts yields nondeterministic results (0 vs 1 passing) and out-of-order reporter output #6116

Description

@satouriko

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', () => {});
});
npx mocha repro.test.js

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: in triagea maintainer should (re-)triage (review) this issuetype: buga defect, confirmed by a maintainer

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions