Skip to content

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

Description

@ChristopherDedominici

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:

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):

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

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

Status
In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions