WebSocket: fire 'error' for every connection that fails before it opens
Problem
- A
WebSocketthat asks for subprotocols gets noerrorevent when the 101 answer has no subprotocol, or one it did not ask for. It gets onlyclose, withwasCleantrue. - Each arm of
WebSocket::didFailWithErrorCodechooses, with anisConnectionErrorargument, whetherdidReceiveClosefireserror(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
didReceiveClosefireserrorfor 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
errorfor each failed handshake. A failure afteropenstill 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 inSec-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
isConnectionErroron the four arms: it keeps a choice per arm that the next arm can miss.
Downsides
- An
onerrorhandler now runs for the two subprotocol failures, andclose.wasCleanbecomes false. cancelandfailed_to_writenow fireerrortoo. 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 | |
|---|---|
| main | close: 1002 Missing client protocol wasClean true |
| this PR | error: 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
wasConnectingis false and the argument had no effect. The connected client cannot report while the socket is CONNECTING:WebSocket::didConnectcreates 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) andfailed_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
wsgive 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
cancelarm keeps its close code 1000. - A failure after
openstill fires noerrorevent. That includescompression_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 testterminate()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 ofws.test.tstake 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.