ws: once('error') emits a failed connection's error twice, and the second is an unhandled 'error' event · Issue #46222 · oven-sh/bun · GitHub
Skip to content

ws: once('error') emits a failed connection's error twice, and the second is an unhandled 'error' event #46222

Open

Description

@manusa

What version of Bun is running?

1.4.3 (bun-v1.4.3)

What platform is your computer?

Linux x64 (WSL2)

What steps can reproduce the bug?

With ws@8.21.0 installed (Bun uses its built-in ws regardless), run with bun repro.mjs:

import { WebSocket } from "ws";

process.on("uncaughtException", err => {
  console.log("uncaughtException:", err.message);
});

const errors = [];
// Nothing listens on port 1, so the connection is refused.
const ws = new WebSocket("ws://127.0.0.1:1");
ws.once("error", err => errors.push(err.message ?? String(err)));
ws.on("close", code => {
  setImmediate(() => {
    console.log(JSON.stringify({ onceErrorCalls: errors.length, close: code }));
    process.exit(0);
  });
});

The same happens when the socket is closed before its handshake completes (new WebSocket(url) and then an immediate close()), which is what a UI component does when it unmounts while connecting.

What is the expected behavior?

node v26.12.0 with ws 8.21.0:

{"onceErrorCalls":1,"close":1006}

What do you see instead?

uncaughtException: Unhandled error. (ErrorEvent { isTrusted: [Getter] })
{"onceErrorCalls":1,"close":1006}

Without the uncaughtException handler the process crashes.

Additional information

The native WebSocket dispatches error only once here (a persistent and a {once: true} addEventListener each see one event), so the duplicate comes from the ws shim (src/js/thirdparty/ws.js):

  1. once("error", f) calls #onOrOnce("error", f, onceObject). No persistent listener is registered yet, so it adds a native error forwarder with {once: true}, and then calls super.once("error", f).
  2. EventEmitter.prototype.once registers its wrapper through this.on(...). That is the shim's overridden on, so #onOrOnce("error", wrapper, undefined) runs. The persistent bit is still unset, so it adds a second, persistent native forwarder.
  3. On failure both forwarders call this.emit("error", err). The first emit runs f, which removes the once wrapper. The second emit finds no error listener, and EventEmitter throws Unhandled error.

The EventEmitter.prototype.once → this.on step can be confirmed with a subclass that overrides on: calling once invokes it once, on both Bun and Node.

One fix: in #onOrOnce, register the EventEmitter side of a once listener without going through the overridden on. For example, add a self-removing wrapper with super.on (keeping wrapper.listener = listener so off(event, listener) still works), or let the native forwarder added in step 1 be the only one.

Related: #26358 (a later once("pong") no longer fires) is in the same once path but is a different symptom; its fix doesn't cover this.

Workaround: attach a persistent no-op error listener (ws.on("error", () => {})) before calling once("error", ...).

Activity

  1. ykisana commented on Oct 22, 2026

    @ykisana

    This should be fixed in 1.4.4, can do (bun upgrade)
    Fixed here: PR #46528

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions