Skip to content

🐛 Bug Report — Runtime APIs: WebSocket does not always dispatch close after error, as the spec requires #8068

Open

Description

@isker

A server-side WebSocket never receives a close event when its read loop ends with any error other than DISCONNECTED. A protocol violation from the peer is one example. workerd dispatches error and then nothing else.

A peer that simply drops the TCP connection does get close (code 1006, wasClean: false), but no error. The WebSockets Standard requires close in both cases. When the connection has to be failed, it requires error followed by close:

When the WebSocket connection is closed, possibly cleanly, the user agent must queue a task to run the following substeps:

  1. Change the ready state to CLOSED (3).
  2. If the user agent was required to fail the WebSocket connection, or if the WebSocket connection was closed after being flagged as full, fire an event named error at the WebSocket object.
  3. Fire an event named close at the WebSocket object, using CloseEvent, with the wasClean attribute initialized to true if the connection closed cleanly and false otherwise, the code attribute initialized to the WebSocket connection close code, and the reason attribute initialized to the result of applying UTF-8 decode without BOM to the WebSocket connection close reason.

Step 3 is unconditional.

The reproduction below triggers the "fail the WebSocket connection" case:

  • RFC 6455 § 5.2 (RSV1, RSV2, RSV3): "If a nonzero value is received and none of the negotiated extensions defines the meaning of such a nonzero value, the receiving endpoint MUST Fail the WebSocket Connection."
  • RFC 6455 § 7.1.7: to Fail the WebSocket Connection, the endpoint "MUST Close the WebSocket Connection".
  • RFC 6455 § 7.1.5: "If The WebSocket Connection is Closed and no Close control frame was received by the endpoint (such as could occur if the underlying transport connection is lost), The WebSocket Connection Close Code is considered to be 1006."

So the expected sequence is error, then close with code: 1006, wasClean: false.

Reproduction

worker.js:

const events = [];
let nextId = 0;

export default {
  async fetch(request) {
    if (new URL(request.url).pathname === '/events') {
      return Response.json(events);
    }
    const id = ++nextId;
    const [client, server] = new WebSocketPair();
    server.accept();
    server.addEventListener('error', (event) => {
      events.push({ id, type: 'error', message: event.message });
    });
    server.addEventListener('close', (event) => {
      events.push({ id, type: 'close', code: event.code, wasClean: event.wasClean });
    });
    return new Response(null, { status: 101, webSocket: client });
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [(name = "main", worker = .worker)],
  sockets = [(name = "http", address = "*:8787", http = (), service = "main")],
);

const worker :Workerd.Worker = (
  modules = [(name = "worker.js", esModule = embed "worker.js")],
  compatibilityDate = "2026-06-17",
);

client.mjs (NodeJS):

// Opens two WebSockets to the worker and ends each one abnormally:
//   1. sends a frame with RSV2 set (a protocol error: RFC 6455 §5.2 requires failing the connection)
//   2. drops the TCP connection without a Close frame (control case)
// then prints the events the worker observed.
import net from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';

const [host = '127.0.0.1', port = '8787'] = process.argv.slice(2);

const connect = () =>
  new Promise((resolve, reject) => {
    const sock = net.connect(Number(port), host, () => {
      sock.write(
        'GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
          'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n',
      );
    });
    sock.once('data', (d) => {
      const status = d.toString().split('\r\n')[0];
      if (status.startsWith('HTTP/1.1 101')) {
        resolve(sock);
      } else {
        reject(new Error(status));
      }
    });
    sock.on('error', () => {});
  });

// 1. FIN + RSV2 + text opcode, masked, empty payload.
const protocolError = await connect();
protocolError.write(Buffer.from([0xa1, 0x80, 0, 0, 0, 0]));
await sleep(500);
protocolError.destroy();

// 2. Abrupt TCP close.
const dropped = await connect();
dropped.destroy();

await sleep(1000);
const events = await (await fetch(`http://${host}:${port}/events`)).json();
console.log(JSON.stringify(events, null, 2));

Run workerd serve config.capnp, then node client.mjs.

Actual

[
  {
    "id": 1,
    "type": "error",
    "message": "Uncaught Error: WebSocket protocol error; protocolError.statusCode = 1002; protocolError.description = Received frame had RSV bits 2 or 3 set"
  },
  {
    "id": 2,
    "type": "close",
    "code": 1006,
    "wasClean": false
  }
]

Connection 1 gets error and never gets close.

Expected

Connection 1 gets error followed by close (code: 1006, wasClean: false).

AI disclosure

This issue was discovered in our production setup, and the reproduction written by, Claude.

Activity

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