#8432·hardhat

Sequential `hardhat test` intermittently dies with exit 1 on a rejection that was actually handled

Author: ChristopherDedominiciCreated Jul 8, 2026Updated Sep 14, 2026

Version of Hardhat

3.9.1

What happened?

What's happening

This hits any sequential test run — anything not using mocha's parallel: true. Sequential is the default, so a plain hardhat test is already exposed; and --coverage is always exposed because coverage can't run in parallel at all (parallel mode is the only safe one — it's not about coverage). When it triggers, the whole run suddenly dies with exit 1 and prints something like:

Unhandled promise rejection:
SolidityError: VM Exception ... reverted with reason string 'AccessControl: ...'

The annoying part: that revert is expected — it's a passing "this should revert" test. No test actually failed. And it's intermittent: editing unrelated test files earlier in the run flips it on and off, because it's a timing race.

Why it happens

When lots of tests do something similar to this:

typescript
await expect(contract.connect(stranger).setFoo(1)) // ← tx starts, can reject here
  .to.be.revertedWithOZAccessControlError(
    stranger.address,
    await contract.FOO_ROLE(), // ← this RPC call runs BEFORE the matcher attaches
  );

Between the tx starting and the matcher attaching (during that await contract.FOO_ROLE()), the tx promise has no .catch on it yet. If the expected revert lands in that gap, Node fires unhandledRejection. The matcher attaches a tick later and catches it just fine — Node even fires rejectionHandled to say "never mind, it got handled."

But Hardhat's CLI installs a global unhandledRejection handler at startup that just does process.exit(1) and never listens for rejectionHandled. In sequential mode mocha runs in that same process, so the handler is still armed and kills the run before the "never mind" arrives.

Parallel mode doesn't have this problem because tests run in mocha worker processes that never install that handler.

High-level fix

While a sequential (in-process) mocha run is active, swap the CLI's fail-fast unhandledRejection handler for a smarter one:

  • on unhandledRejectionpark it instead of exiting
  • on rejectionHandledrelease it (a matcher caught it late — all good)
  • when the run ends → anything still parked is a real never-handled bug → exit 1 and report it

Everything else stays the same: parallel mode untouched, hardhat node/scripts keep fail-fast, and genuinely unhandled rejections still fail the run.

Minimal reproduction steps

Repro

Add this test and run it sequentially (no parallel: true):

typescript
import { expect } from "chai";

describe("unhandled-rejection tolerance", () => {
  it("survives a rejection handled one tick later", async () => {
    const doomed = Promise.reject(new Error("expected revert")); // tx promise, no handler yet
    await new Promise((r) => setTimeout(r, 10)); // the `await contract.ROLE()` gap
    await doomed.catch((e) => expect(e.message).to.equal("expected revert")); // matcher attaches late
  });
});

Search terms

No response