This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
ethers.js is one of the most widely used Ethereum libraries, with over 3 million weekly downloads on npm. It provides the core infrastructure that thousands of dApps, wallets, and blockchain tools depend on for interacting with EVM chains.
I discovered a bug in the WebSocketProvider that causes uncatchable unhandled rejections — silently crashing production Node.js processes with no way for application code to intercept the error.
Bug Fix or Performance Improvement
The Problem
When a WebSocket provider returns a JSON-RPC error to an eth_subscribe call (auth failure, 5xx behind a gateway, unsupported method), the rejection has no handler and becomes an unhandled rejection that crashes the process.
SocketSubscriber.start() stores the subscribe promise with only a .then():
// Before fix — provider-socket.ts line 63
start(): void {
this.#filterId = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {;
this.#provider._register(filterId, this);
return filterId;
});
}
start() returns void and #filterId is private — no rejection handler exists and none can be attached from outside. try/catch around contract.on() or provider.on() cannot catch it. The only place it surfaces is process.on('unhandledRejection').
For a long-running event listener, this turns a transient, retryable provider error into a hard process exit.
The Real-World Trigger
This was observed during a provider regional incident. The WebSocket handshake succeeds, the provider reports ready, then eth_subscribe is answered with an error:
Error: could not coalesce error (error={ "code": -32608, "message": "unknown response, status code: 500" },
payload={ "method": "eth_subscribe", "params": [ "logs", { "address": [...] } ] })
Code
The Fix (Diff)
start(): void {
- this.#filterId = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {;
+ const promise = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {
this.#provider._register(filterId, this);
return filterId;
+ }, (error) => {
+ if (this.#filterId === promise) { this.#filterId = null; }
+ this.#provider.emit("error", makeError("failed to subscribe", "UNKNOWN_ERROR", {
+ error, info: { filter: this.filter }
+ }));
+ return null as any;
});
+ this.#filterId = promise;
}
stop(): void {
- (<Promise<number>>(this.#filterId)).then((filterId) => {
- if (this.#provider.destroyed) { return; }
- this.#provider.send("eth_unsubscribe", [ filterId ]);
- });
+ if (this.#filterId != null) {
+ this.#filterId.then((filterId) => {
+ if (filterId == null || this.#provider.destroyed) { return; }
+ this.#provider.send("eth_unsubscribe", [ filterId ]);
+ }).catch(() => { });
+ }
this.#filterId = null;
}
My Improvements
Why .then(onFulfilled, onRejected) instead of .catch()
My first attempt used .catch() with a re-throw to keep the rejection chain:
.catch((error) => {
this.#provider.emit("error", ...);
throw error; // Re-throw to preserve the rejection
});
This re-introduced the exact same bug. When .catch() re-throws, it creates a new rejected promise. Since nobody handles that new promise, you get another unhandled rejection — the same crash, just one step further down the chain.
The correct pattern is .then(onFulfilled, onRejected) where onRejected returns null to absorb the rejection. The promise fulfills with null instead of rejecting, so there's nothing unhandled.
Identity Guard for Promise Safety
The onRejected handler sets this.#filterId = null to clean up. But what if start() were called again before the first rejection settles? The stale handler would clobber the new subscription.
I added an identity guard:
if (this.#filterId === promise) { this.#filterId = null; }
This ensures the handler only clears its own promise, never a subsequent one.
Hardening stop()
The original stop() had two issues:
- No null check on
#filterId— calling.then()onnullthrows - No
.catch()— if the subscription had failed, theeth_unsubscribecall would also produce an unhandled rejection - No guard against sending
eth_unsubscribewith a null filter ID
Tests Added
Three tests with unhandledRejection process listeners that fail the test if any unhandled rejection occurs:
-
eth_subscriberejection emits provider"error"event with correct message -
stop()on a failed subscription does not throw -
stop()on a failed subscription does not sendeth_unsubscribe
Closes #5178. Related to #3924 (the FilterIdSubscriber variant, labelled fixed/complete).
Top comments (0)