Repository navigation
🐛 Bug Report — Runtime APIs: WebSocket does not always dispatch close after error, as the spec requires #8068
Description
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:
- Change the ready state to
CLOSED(3).- 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
errorat theWebSocketobject.- Fire an event named
closeat theWebSocketobject, usingCloseEvent, with thewasCleanattribute initialized to true if the connection closed cleanly and false otherwise, thecodeattribute initialized to the WebSocket connection close code, and thereasonattribute 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