Skip to content

WebSocketStream throws ERR_INVALID_STATE after readable.cancel() before clean close #5104

@colinaaa

Description

@colinaaa

Bug Description

WebSocketStream can throw TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed when the readable side is canceled and the connection later completes a clean close handshake.

The failure happens because readable.cancel() closes the internal ReadableStreamDefaultController, and the clean socket close path later calls close() on the same controller again.

Reproducible By

  1. Start a WebSocket server.
  2. Connect with new WebSocketStream(url).
  3. Wait for wss.opened and get readable.
  4. Call await readable.cancel(new Error('client cancel')).
  5. Trigger a clean close, for example with wss.close() on the client and ws.close(1000, 'bye') on the server.
  6. Wait for the socket to close.

Minimal reproduction:

const { WebSocketServer } = require('ws')
const { WebSocketStream } = require('undici')

const server = new WebSocketServer({ port: 3000 })
server.on('connection', (ws) => {
  setTimeout(() => {
    ws.send('hello')
    setTimeout(() => ws.close(1000, 'bye'), 20)
  }, 200)
})

;(async () => {
  const wss = new WebSocketStream('ws://127.0.0.1:3000')
  const { readable } = await wss.opened

  await readable.cancel(new Error('client cancel')).catch(() => {})
  wss.close()
  await wss.closed.catch(() => {})
})()

Expected Behavior

Canceling the readable side before a later clean close should not throw or surface an uncaught exception. The connection should close normally and settle closed without crashing the process.

Logs & Screenshots

TypeError [ERR_INVALID_STATE]: Invalid state: Controller is already closed
    at ReadableStreamDefaultController.close (node:internal/webstreams/readablestream:1068:13)
    at #onSocketClose (.../lib/web/websocket/stream/websocketstream.js:389:38)

Environment

Linux
Node.js v22.x
undici main branch

Additional context

The clean close path in lib/web/websocket/stream/websocketstream.js currently calls this.#readableStreamController.close() directly. That is not safe after ReadableStream.cancel() because the controller is already closed at that point.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions