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
- Install dependencies.
- 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>.
WebSocketStream:
readable.cancel()thenclose()triggersERR_INVALID_STATE(controller already closed)Summary
When using
undici'sWebSocketStream, cancelling thereadablestream immediately after the handshake and then callingWebSocketStream#close()can lead to anuncaughtException:TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closedThis appears to happen when the server later closes the socket normally and
undicicloses the internalReadableStreamDefaultControlleragain during socket-close handling.Reproduction
Steps
node server.js.Minimal repro (
server.js)Expected behavior
Cancelling
readableand then closing theWebSocketStreamshould not throw an unhandled exception. The process should exit cleanly (printsdone).Actual behavior
The process terminates via
uncaughtException:Environment
Notes
<PROJECT_ROOT>.