Skip to content

WebSocketStream: cancel readable then close triggers uncaughtException ERR_INVALID_STATE (Controller is already closed) #5103

@BetaGo

Description

@BetaGo

WebSocketStream: readable.cancel() then close() triggers ERR_INVALID_STATE (controller already closed)

Summary

When using undici's WebSocketStream, cancelling the readable stream immediately after the handshake and then calling WebSocketStream#close() can lead to an uncaughtException:

TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed

This appears to happen when the server later closes the socket normally and undici closes the internal ReadableStreamDefaultController again during socket-close handling.

Reproduction

Steps

  1. Install dependencies.
  2. Run node server.js.

Minimal repro (server.js)

const { WebSocketServer } = require("ws");
const { WebSocketStream } = require("undici");

process.on("uncaughtException", (e) => {
  console.error("uncaughtException:", e);
  process.exit(1);
});

const wss = new WebSocketServer({ port: 3000 });
wss.on("connection", (ws) => {
  // Delay a bit to ensure the client cancels the readable stream first
  setTimeout(() => {
    ws.send("hello");
    // Then close cleanly right after (1000 means normal closure)
    setTimeout(() => {
      try {
        ws.close(1000, "bye");
      } catch {}
    }, 20);
  }, 200);
});
console.log("WS server on ws://localhost:3000");

(async function main() {
  const wss = new WebSocketStream("ws://localhost:3000");
  const { readable } = await wss.opened;

  // Key: cancel the readable stream immediately after handshake, then call close
  // This closes the internal ReadableStreamController early
  try {
    await readable.cancel(new Error("client cancel"));
  } catch {}
  try {
    wss.close();
  } catch {}

  // Wait for the server's normal close; undici closes the already-closed controller again in onSocketClose
  try {
    await wss.closed;
  } catch {}

  console.log("done");
})();

Expected behavior

Cancelling readable and then closing the WebSocketStream should not throw an unhandled exception. The process should exit cleanly (prints done).

Actual behavior

The process terminates via uncaughtException:

WS server on ws://localhost:3000
(node:60840) [UNDICI-WSS] Warning: WebSocketStream is experimental! Expect it to change at any time.
(Use `node --trace-warnings ...` to show where the warning was created)
uncaughtException: TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed
    at ReadableStreamDefaultController.close (node:internal/webstreams/readablestream:1093:13)
    at #onSocketClose (<PROJECT_ROOT>/node_modules/undici/lib/web/websocket/stream/websocketstream.js:388:38)
    at Socket.onSocketClose (<PROJECT_ROOT>/node_modules/undici/lib/web/websocket/stream/websocketstream.js:64:45)
    at Socket.emit (node:events:520:22)
    at TCP.<anonymous> (node:net:350:12) {
  code: 'ERR_INVALID_STATE'
}

Environment

  • OS: macOS 26.4.1 (Darwin Kernel 25.4.0)
  • Arch: arm64
  • Node.js: v25.8.1
  • npm: 11.11.0
  • yarn: 1.22.21
  • undici: 8.1.0
  • ws: 8.20.0

Notes

  • All user-specific absolute paths were redacted as <PROJECT_ROOT>.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions