ws: emit client 'unexpected-response' event for non-101 upgrades#31800
ws: emit client 'unexpected-response' event for non-101 upgrades#31800robobun wants to merge 2 commits into
Conversation
When the server answers a WebSocket upgrade with a non-101 status (e.g. Chrome DevTools replying 401/404 on an unknown /devtools/browser/<id> path), the ws shim only warned that 'unexpected-response' was unimplemented and the failed upgrade surfaced as a generic 'Expected 101 status code' error. The native upgrade client now captures the non-101 response (status, headers, body) and forwards it to the C++ WebSocket via WebSocket__didReceiveHandshakeResponse, which dispatches an 'unexpected-response' event carrying the status/headers/body. The ws shim turns that into the 'unexpected-response'(req, res) event with an http.IncomingMessage-like res, and falls back to an 'Unexpected server response: <status>' error + close when there is no listener, matching Node's ws.
WalkthroughThis PR implements the ChangesWebSocket unexpected-response event for non-101 upgrades
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 2:23 AM PT - Jun 4th, 2026
❌ @autofix-ci[bot], your commit 98297b1 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 31800That installs a local version of the PR into your bun-31800 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Closing in favor of #28114, which implements both |
| CppWebSocket::opaque_ref(ws).did_receive_handshake_response( | ||
| status_code, | ||
| &status_text, | ||
| response.headers.list, | ||
| remain_buf, | ||
| ); |
There was a problem hiding this comment.
🟡 The body passed to 'unexpected-response' is best-effort — remain_buf only contains bytes that arrived in the same TCP read(s) as the headers, with no Content-Length/chunked handling before terminate() closes the socket. If a server's non-101 body spans multiple packets (e.g. a large 401 error page), res.on('data') will yield a truncated body, whereas Node's ws hands over a live http.IncomingMessage that streams the full body. Not a blocker (the motivating puppeteer/chrome-devtools-mcp paths only need res.statusCode, and this is strictly better than before), but worth a code comment noting the body is best-effort.
Extended reasoning...
What
The body delivered to the 'unexpected-response' listener may be incomplete. In handle_data (WebSocketUpgradeClient.rs:972), once picohttp::Response::parse sees the terminating \r\n\r\n it returns Ok, and remain_buf = body[bytes_read..].to_vec() captures only the bytes that happened to arrive in the same TCP read(s) as the headers. process_response then immediately hands remain_buf to JS and calls terminate(), which closes the socket — there is no Content-Length parsing, no chunked-encoding handling, and no continued reading. On the JS side, #onUnexpectedResponse does res.push(body); res.push(null), so 'end' fires after whatever partial bytes were captured.
Node's ws differs here: it hands the listener the actual http.IncomingMessage backed by the live socket, which continues streaming until the server closes or Content-Length is satisfied.
Step-by-step example
- Client sends the upgrade request to a server that replies with
HTTP/1.1 401 Unauthorizedplus a 16 KB HTML error page andContent-Length: 16384. - The server's TCP stack sends the headers + the first ~1.4 KB of body in one segment; the remaining ~14.6 KB follow in subsequent segments.
handle_datais invoked with the first segment.picohttpfinds\r\n\r\n, returnsOkwithbytes_read= header length.remain_buf= the ~1.4 KB body prefix.process_responseseesstatus_code != 101, callsdid_receive_handshake_response(..., remain_buf)→ JS builds aReadable, pushes the 1.4 KB, then pushesnull.terminate()closes the socket; the remaining 14.6 KB are never read.- The user's
res.on('data')handler observes a 1.4 KB body andres.on('end')fires — silently truncated relative toContent-Length.
Why nothing prevents it
The buffering loop in handle_data waits only for complete headers (ShortRead → buffer and return). Once headers are complete it proceeds unconditionally; nothing inspects Content-Length or Transfer-Encoding to decide whether more body bytes are expected. The new test writes headers + body in a single socket.end(...) call (small enough to coalesce into one packet on loopback), so it doesn't exercise the multi-packet case.
Impact
Low. The PR's stated goal — surfacing res.statusCode / res.statusMessage / res.headers for puppeteer and chrome-devtools-mcp (#31792) — works correctly regardless of body completeness; the no-listener path only uses statusCode in the error message. Realistic non-101 responses to WebSocket upgrades (CDP 401/404, proxy 407, redirects) have tiny or empty bodies that fit in one packet. And before this PR there was no event and no body at all, so any body bytes are a strict improvement. A consumer that actually needs the full error body (rare) would observe truncation, but this is an edge case.
Suggested fix
Full Node-compatible body streaming would require keeping the socket open past process_response, parsing Content-Length/chunked, and pumping further reads into the JS Readable — a substantial restructuring that's reasonably out of scope here. For this PR, a code comment at the did_receive_handshake_response call site (and/or in #onUnexpectedResponse) noting that the body is best-effort — "only bytes that arrived with the headers; not Content-Length-aware" — would document the known limitation. A follow-up issue could track full body streaming if a real consumer needs it.
Problem
Fixes #31792.
Bun's
wsclient shim hardcoded the'unexpected-response'event as "not implemented" and printed a warning instead of firing it. When a server answers a WebSocket upgrade with a non-101 status (e.g. Chrome DevTools replying401/404on an unknown/devtools/browser/<id>path), the failed upgrade only surfaced as a genericWebSocket connection ... failed: Expected 101 status codeerror — not Node's'unexpected-response'event, and not Node'sUnexpected server response: <status>error for the no-listener case.This breaks
puppeteer/chrome-devtools-mcp, which rely on this path (the former via the no-listenerUnexpected server responseerror, the latter via an'unexpected-response'listener).Reproduction
Before:
[bun] Warning: ws.WebSocket 'unexpected-response' event is not implemented in bunand the event never fires (an'error'withExpected 101 status codefires instead).After (matches Node):
unexpected-response 401.Cause
The native upgrade client (
WebSocketUpgradeClient) fast-failed on any non-HTTP/1.1 101response and discarded the HTTP response (status, headers, body), terminating with a genericExpected101StatusCode. JS had nothing to build(req, res)from, so the shim stubbed the event with a warning.Fix
WebSocketUpgradeClientno longer fast-fails on theHTTP/1.1 101prefix; it parses the full response so the existingstatus_code != 101branch inprocess_responsecan hand the status/headers/body to JS (viaCppWebSocket::did_receive_handshake_response) before terminating.max_http_header_size()still bounds buffering, and a non-HTTP reply still fails withInvalidResponse.WebSocket::didReceiveHandshakeResponse(C++) builds a{ statusCode, statusMessage, headers, rawHeaders, body }object and dispatches an"unexpected-response"MessageEvent. It early-returns unless an"unexpected-response"listener is registered, so the browser-stylenew WebSocket()path pays nothing. Headers are passed using the same{ptr,len}-of-picohttp::HeaderABI as the fetch header path.src/js/thirdparty/ws.jseagerly registers that native listener, and on a non-101 response:'unexpected-response'listener → emits'unexpected-response'(req, res)with anhttp.IncomingMessage-like readableres(statusCode/statusMessage/headers/rawHeaders+ the body as a stream) and suppresses the native error/close (matches Node — the user owns the connection).errorUnexpected server response: <status>thenclose(matches Node'sabortHandshake). This is the path puppeteer / chrome-devtools-mcp rely on.The
'unexpected-response'warning stub is removed;'upgrade'and'redirect'remain stubbed (out of scope for this issue).Verification
New tests in
test/js/first_party/ws/ws.test.ts(unexpected-response (non-101 upgrade)describe), using an in-process raw-TCP server so the non-101 bytes are flushed deterministically:'unexpected-response'fires withres.statusCode === 401,res.statusMessage, the responseheaders, and the body drained viares.on("data"/"end"); no spuriouserror/close.["error:Unexpected server response: 404", "close:1006"]— verified byte-for-byte identical to Nodews@8.Both tests pass under the debug (ASAN) build and fail under
USE_SYSTEM_BUN=1(the event never fires; the no-listener case emits the oldExpected 101 status code/1002instead), confirming they exercise the fix. Also verified: the happy-path 101 upgrade,once("unexpected-response"), and a302(nofollowRedirects) surfacing asunexpected-responsewithres.headers.location— all match Node.Relationship to #31408 and #5951
#31408 (open, issue #31406) implements the
upgradeevent for the 101 success path and introduces aWebSocket::didReceiveHandshakeResponseof its own (ahandshakeevent). This PR implements theunexpected-responseevent for the non-101 failure path — complementary, but it touches the same files (WebSocket.{h,cpp},CppWebSocket.rs,WebSocketUpgradeClient.rs,ws.js) with a differently-shapeddidReceiveHandshakeResponse. Whichever lands second will need a rebase; the two could also be unified onto a single handshake-response callback that drives both events.#5951 tracks both the
'upgrade'and'unexpected-response'warnings. This PR only implements the'unexpected-response'half, so it does not fully close #5951 — the'upgrade'half is #31408. I've intentionally not addedFixes #5951so it isn't auto-closed before'upgrade'also lands.