ws: stop once() from installing a second native listener

Closed
CI
25d ago
+162-84

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 throws ERR_UNHANDLED_ERROR Unhandled error. (ErrorEvent { isTrusted: [Getter] }) as an uncaught exception. events.once(ws, "open") hits it through its internal once("error").
  • The cause is #onOrOnce (src/js/thirdparty/ws.js:384), since #26359. For once() it adds a { once: true } native listener and leaves the #eventId bit clear. super.once() then calls this.on(wrapper), which adds a second, persistent native listener.

Fix

  • #armNativeBridge adds one persistent native listener per event and sets the bit. on(), once(), addListener() and both prepend* methods call it. The EventEmitter wrapper gives the once behavior.
  • Correct because one native listener gives one emit() per native event. npm ws 8.21.3 on Node 26.3.0 gives the same listener calls in all seven tested cases.
  • #armNativeBridge no longer adds a placeholder listener, which newListener observers 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. Also test/js/first_party/ws/ and test/cli/inspect/inspect.test.ts.

Background

  • BunWebSocket replaces the npm ws client. It wraps the native WebSocket in an EventEmitter. A bridge is the native listener that forwards one event to emit().
  • Considered a one-shot native listener plus super.on(wrapper). An on("error") added after a once("error") is still called twice, and two once("error") still throw.

Downsides

  • None found. Checked on() (same single native listener) and once() (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.

caselistener callsuncaught
once('error'), refused connectiononcenone
once('error') then on('error')once, onnone
two once('error')first once, second oncenone
on('error') then once('error')on, oncenone
events.once(ws, 'open')rejected oncenone
once('open') then on('open'), live serveronce, onnone
once('message') then on('message'), live serveronce:first, on:first, on:secondnone

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

probemainthis change
once('close') then on('close')once, on, ononce, on
prependOnceListener('error')one callone call
newListener events for prependListener('error', userHandler)noopBridgeListener, userHandleruserHandler
emit('error') calls for one native error after once('error')21
src/js/thirdparty/ws.js46870 bytes45530 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.ts
  • test/js/first_party/ws/ws.test.ts, ws-upgrade-events.test.ts, ws-proxy.test.ts, ws-syscall-fault.test.ts
  • test/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.ts
  • test/js/node/http/node-http-with-ws.test.ts
  • test/cli/inspect/inspect.test.ts
  • test/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.