WebSocket: fire 'error' for every connection that fails before it opens

Closed
RO
1mo ago
+72-42

Problem

  • A WebSocket that asks for subprotocols gets no error event when the 101 answer has no subprotocol, or one it did not ask for. It gets only close, with wasClean true.
  • Each arm of WebSocket::didFailWithErrorCode chooses, with an isConnectionError argument, whether didReceiveClose fires error (src/jsc/bindings/webcore/WebSocket.cpp:1439). Four arms of a CONNECTING socket do not pass it: missing_client_protocol, mismatch_client_protocol, cancel, failed_to_write.

Fix

  • didReceiveClose fires error for each failure of a CONNECTING socket. The argument is gone. The two subprotocol arms also report a close that is not clean.
  • Correct because the WHATWG steps "fail the WebSocket connection" fire error for each failed handshake. A failure after open still only closes.
  • Verified: test/js/web/websocket/websocket-subprotocol-strict.test.ts (14 of 21 tests fail on main), and the suites in Notes.
  • Self-reviewed: 2 concerns raised, 2 addressed.

Background

  • A client can ask for subprotocols: new WebSocket(url, ["chat"]). The server picks one in Sec-WebSocket-Protocol. Any other answer fails the connection (RFC 6455 section 4.1).
  • Only the upgrade client (WebSocketUpgradeClient.rs) reports a failure while the socket is CONNECTING. The socket is OPEN before the connected client reads a byte.
  • Considered isConnectionError on the four arms: it keeps a choice per arm that the next arm can miss.

Downsides

  • An onerror handler now runs for the two subprotocol failures, and close.wasClean becomes false.
  • cancel and failed_to_write now fire error too. No test reaches these two arms.
  • Release text: 89,151,348 to 89,151,092 bytes (-256, size, linux-x64).
Notes

Repro

const net = require("node:net");
const crypto = require("node:crypto");
const server = net.createServer(socket => {
  socket.on("error", () => {});
  socket.once("data", data => {
    const key = /sec-websocket-key: (.*)\r\n/i.exec(String(data))[1];
    const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
    // A valid 101, with no Sec-WebSocket-Protocol header.
    socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + "\r\n\r\n");
  });
});
server.listen(0, "127.0.0.1", () => {
  const ws = new WebSocket("ws://127.0.0.1:" + server.address().port, ["chat"]);
  ws.onerror = event => console.log("error:", event.message);
  ws.onclose = event => (console.log("close:", event.code, event.reason, "wasClean", event.wasClean), server.close());
});
output
mainclose: 1002 Missing client protocol wasClean true
this PRerror: WebSocket connection to 'ws://127.0.0.1:<port>/' failed: Missing client protocol, then close: 1002 Missing client protocol wasClean false

The same server with the npm ws 8.18.3 client on Node v26.3.0 and an 'error' listener: the listener gets Error: Server sent no subprotocol, then close 1006. With no listener the process exits with 1.

Why the state alone decides

didReceiveClose has one caller, didFailWithErrorCode. It computed wasConnecting && isConnectionError. On main 16 arms pass true. The other arms belong to one of two groups:

  • Arms that only the connected client reports (a protocol error in a frame, invalid UTF-8, a message that is too big). The socket is OPEN or CLOSING then, so wasConnecting is false and the argument had no effect. The connected client cannot report while the socket is CONNECTING: WebSocket::didConnect creates it and sets OPEN in the same call, and the bytes that came after the 101 go to it in a microtask (InitialDataTask, src/http_jsc/websocket_client.rs).
  • Arms that the upgrade client reports and that did not pass true: the two subprotocol arms, cancel (WebSocketUpgradeClient.rs:1391, :1443: the socket is closed when the 101 has been read) and failed_to_write (:510: the request cannot be written). Each is a failed opening handshake.

So wasConnecting alone gives the same result for every arm that passed true, and adds the event for those four.

close() and terminate() on a CONNECTING socket do not go through didReceiveClose. failConnectingWebSocket fires their error and close, as before.

What does not change

  • The close code stays 1002 and the reason text stays. The WHATWG steps and npm ws give 1006 and an empty reason for a failed handshake. Eleven handshake arms use 1002 today, so that is a change for all of them, not for these two.
  • The cancel arm keeps its close code 1000.
  • A failure after open still fires no error event. That includes compression_unsupported, which reports a clean close with code 1011 for a frame that breaks the protocol. It is a different defect, and it has its own change.

For the ws module

A client of the built-in ws module with an 'error' listener now hears the subprotocol failures. Before, it got only 'close'. Two rows of the test check that. A client with no listener stays quiet here. That is #45125.

Suites run with the debug build (linux-x64)

  • test/js/web/websocket/websocket-subprotocol-strict.test.ts: 21 pass. With the release build of main: 7 pass, 14 fail.
  • test/js/web/websocket/: websocket-upgrade (4), websocket-handshake-event (1), websocket-custom-headers (10), websocket-close-code (27), websocket-close-connecting (4), websocket-proxy (52), websocket-unix (9), websocket-accept-header-validation (2), error-event (2), websocket-client-short-read (4), websocket-buffered-amount (7): all pass.
  • websocket-client.test.ts: 74 pass. In 2 of 8 runs the test terminate() takes more than its limit of 1 s. With main's sources it does the same in 2 of 8 runs.
  • test/regression/issue/28014.test.ts, 3613.test.ts, test/js/bun/websocket/websocket-server-upgrade-reentrant.test.ts: all pass.
  • test/js/first_party/ws/ws.test.ts: 96 pass, 3 todo. ws-proxy.test.ts: 19 pass. Two TLS tests of ws.test.ts take 5 to 7 s on this machine, at a limit of 5 s. In interleaved runs the binary without this change takes the same time.
  • The release build of this change (linux-x64): the test file and the eleven suites of the second bullet pass.