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
unhandledRejection → park it instead of exiting
- on
rejectionHandled → release 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
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 plainhardhat testis already exposed; and--coverageis 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: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:
Between the tx starting and the matcher attaching (during that
await contract.FOO_ROLE()), the tx promise has no.catchon it yet. If the expected revert lands in that gap, Node firesunhandledRejection. The matcher attaches a tick later and catches it just fine — Node even firesrejectionHandledto say "never mind, it got handled."But Hardhat's CLI installs a global
unhandledRejectionhandler at startup that just doesprocess.exit(1)and never listens forrejectionHandled. 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
unhandledRejectionhandler for a smarter one:unhandledRejection→ park it instead of exitingrejectionHandled→ release it (a matcher caught it late — all good)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):Search terms
No response