ws: stop once() from installing a second native listener
Problem
ws.WebSocket#once()delivers one native event twice when it is the first listener for that event. For'error'the second delivery finds no listener and throwsERR_UNHANDLED_ERRORUnhandled error. (ErrorEvent { isTrusted: [Getter] })as an uncaught exception.events.once(ws, "open")hits it through its internalonce("error").- The cause is
#onOrOnce(src/js/thirdparty/ws.js:384), since #26359. Foronce()it adds a{ once: true }native listener and leaves the#eventIdbit clear.super.once()then callsthis.on(wrapper), which adds a second, persistent native listener.
Fix
#armNativeBridgeadds one persistent native listener per event and sets the bit.on(),once(),addListener()and bothprepend*methods call it. TheEventEmitterwrapper gives the once behavior.- Correct because one native listener gives one
emit()per native event. npmws8.21.3 on Node 26.3.0 gives the same listener calls in all seven tested cases. #armNativeBridgeno longer adds a placeholder listener, whichnewListenerobservers could see.- Verified:
test/regression/issue/26358.test.ts. 7 of 12 tests fail on main and all pass with the change, on linux-x64 and windows-x64. Alsotest/js/first_party/ws/andtest/cli/inspect/inspect.test.ts.
Background
BunWebSocketreplaces the npmwsclient. It wraps the nativeWebSocketin anEventEmitter. A bridge is the native listener that forwards one event toemit().- Considered a one-shot native listener plus
super.on(wrapper). Anon("error")added after aonce("error")is still called twice, and twoonce("error")still throw.
Downsides
- None found. Checked
on()(same single native listener) andonce()(one native listener less). The bridge that stays after a once listener fired is not new: main arms it too.
Notes
Repro
import WebSocket from "ws";
import { once } from "node:events";
process.on("uncaughtException", e => console.log("uncaughtException:", e.message));
const s = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
const url = `ws://127.0.0.1:${s.port}/`;
s.stop(true); // nothing listens, so the connection is refused
const a = new WebSocket(url);
a.once("error", () => console.log("once('error') listener called"));
await Bun.sleep(300);
let calls = 0;
const b = new WebSocket(url);
b.once("error", () => {});
b.on("error", () => calls++);
await Bun.sleep(300);
console.log("on('error') calls:", calls);
await once(new WebSocket(url), "open").catch(() => console.log("once(ws,'open') rejected"));
On main:
once('error') listener called
uncaughtException: Unhandled error. (ErrorEvent { isTrusted: [Getter] })
on('error') calls: 2
uncaughtException: Unhandled error. (ErrorEvent { isTrusted: [Getter] })
once(ws,'open') rejected
With this change:
once('error') listener called
on('error') calls: 1
once(ws,'open') rejected
The same cases on Node 26.3.0 with npm ws 8.21.3
Listener calls in order, and the uncaught exceptions the process saw. The tests assert the same values.
| case | listener calls | uncaught |
|---|---|---|
once('error'), refused connection | once | none |
once('error') then on('error') | once, on | none |
two once('error') | first once, second once | none |
on('error') then once('error') | on, once | none |
events.once(ws, 'open') | rejected once | none |
once('open') then on('open'), live server | once, on | none |
once('message') then on('message'), live server | once:first, on:first, on:second | none |
on('error') then once('error') passes on main too. It is in the test file as a guard.
The other design, emulated on an unmodified build
once() as a one-shot native listener plus a wrapper added with EventEmitter.prototype.on:
once alone -> ["once"]
once then on -> ["once","on","on"]
two once -> ["once1","once2"], then ERR_UNHANDLED_ERROR
Each one-shot native listener calls emit(), and emit() reaches every listener on the emitter.
Other probes, main compared with this change
| probe | main | this change |
|---|---|---|
once('close') then on('close') | once, on, on | once, on |
prependOnceListener('error') | one call | one call |
newListener events for prependListener('error', userHandler) | noopBridgeListener, userHandler | userHandler |
emit('error') calls for one native error after once('error') | 2 | 1 |
src/js/thirdparty/ws.js | 46870 bytes | 45530 bytes |
Behavior that does not change
After a bridge is armed, a native 'error' with no listener on the emitter throws ERR_UNHANDLED_ERROR. For example on('error', f), then off('error', f), then a failed connection. npm ws does the same, because it always emits 'error' on the emitter.
Suites run with the debug build on linux-x64
test/regression/issue/26358.test.tstest/js/first_party/ws/ws.test.ts,ws-upgrade-events.test.ts,ws-proxy.test.ts,ws-syscall-fault.test.tstest/js/web/websocket/websocket-client.test.ts,websocket-proxy.test.ts,websocket-client-short-read.test.ts,websocket-buffered-amount.test.ts,websocket-accept-header-validation.test.tstest/js/node/http/node-http-with-ws.test.tstest/cli/inspect/inspect.test.tstest/regression/issue/32734.test.ts,3613.test.ts,03844/03844.test.ts,012040.test.ts,29684.test.ts
Rebase
The branch is rebased onto main at 29d9638da3. The previous head was 7847f25c95. Main changed the ping and pong bridges to controlPayload(this.#binaryType, data) after this branch started. The rebase keeps that call in #armNativeBridge.