Skip to content

ws: support upgrade and unexpected-response events#28114

Open
alii wants to merge 12 commits intomainfrom
ali/ws-upgrade-events
Open

ws: support upgrade and unexpected-response events#28114
alii wants to merge 12 commits intomainfrom
ali/ws-upgrade-events

Conversation

@alii
Copy link
Copy Markdown
Member

@alii alii commented Mar 14, 2026

Adds 'upgrade' and 'unexpected-response' events to the ws package shim. The native WebSocket client now surfaces the handshake response (status, headers, body) to JS instead of discarding it on non-101.

This is the load-bearing fix for miniflare/wrangler hanging — dispatchFetch resolves a promise exclusively from these two events.

Fixes #5951
Fixes #24229

@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Mar 14, 2026

Updated 7:56 PM PT - Apr 7th, 2026

❌ Your commit ff4a7c39 has 3 failures in Build #44369 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28114

That installs a local version of the PR into your bun-28114 executable, so you can run:

bun-28114 --bun

@github-actions
Copy link
Copy Markdown
Contributor

Found 8 issues this PR may fix:

  1. ws.WebSocket 'upgrade' and 'unexpected-response' event is not implemented in bun #5951 - Missing upgrade and unexpected-response events in ws package compatibility
  2. node:http server won't fallback an upgrade request to regular 'request' when there is no listeners of 'upgrade' event #26924 - HTTP server upgrade request fallback behavior not working properly
  3. node:http and node:https strange behaviour with upgrade event #18945 - Strange behavior with upgrade events in node:http and node:https
  4. Inconsistent handling of casing for the set-cookie header #4322 - Inconsistent handling of casing for the set-cookie header
  5. WebSocket onError event.error is undefined on internet connection loss #19176 - WebSocket onError event.error is undefined on connection loss
  6. SvelteKit Websocket doesn't work #18191 - SvelteKit WebSocket functionality not working
  7. 'websocket' package does not work #20547 - Alternative 'websocket' package does not work due to missing events
  8. Proxying WebSockets with node-http-proxy (from Vite) doesn't work, works with Node #10441 - WebSocket proxying fails with node-http-proxy due to event timing

If this is helpful, consider adding Fixes #5951 to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

@alii alii force-pushed the ali/ws-upgrade-events branch from 4ea3e91 to 7d24274 Compare April 3, 2026 23:18
@alii alii changed the title WIP ws: support upgrade and unexpected-response events Apr 3, 2026
@alii
Copy link
Copy Markdown
Member Author

alii commented Apr 4, 2026

@robobun adopt

@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Apr 4, 2026

Build 43967 — fix is green, only darwin infra noise remaining.

ASAN (the platform where ws-proxy was SIGILLing): all 20 test shards PASSED. The lazy-register fix in src/js/thirdparty/ws.js keeps ws-proxy.test.ts off the handshake dispatch path, which was the root cause.

Darwin situation (not my code):

  • test/js/bun/webview/webview.test.ts timed out on 🍎 13 x64 and 🍎 14 x64
  • Same webview test timed out on this PR back at build 43792 (March 14)
  • Evidence that darwin test-bun is broken fleet-wide right now: builds 43900, 43940, 43967 all show darwin test-bun jobs as canceled/expired/timed_out regardless of which PR they're testing
  • When 43967 did get agents, the webview shard consumed the 30-min wall clock on its own — it's a webview flake under starved darwin infra, nothing ws-related

Nothing to push. @alii, the PR is ready for review — the webview darwin failure is infra/flake that predates this PR.

@alii
Copy link
Copy Markdown
Member Author

alii commented Apr 6, 2026

@robobun the gate check had a parsing bug that misread passing tests as "1 FAILED" — fixed. Push an empty commit (or just continue) to re-trigger the check.

@robobun robobun force-pushed the ali/ws-upgrade-events branch 2 times, most recently from 10e1c03 to 82ad796 Compare April 6, 2026 09:44
@alii alii force-pushed the ali/ws-upgrade-events branch from 82ad796 to d3b47ab Compare April 7, 2026 20:01
@alii alii force-pushed the ali/ws-upgrade-events branch from cfa87a6 to 0f36bd4 Compare April 7, 2026 20:49
@alii alii marked this pull request as ready for review April 7, 2026 20:50
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds native handshake handling and JS plumbing: new C++ span-based didReceiveHandshakeResponse + C ABI, Zig FFI, buffered upgrade-response parsing, JS handshake/upgrade/unexpected-response dispatch with rawHeaders/statusMessage/body payloads, builtin identifier additions, and regression tests covering non-101 behavior and multi-chunk bodies.

Changes

Cohort / File(s) Summary
Native WebSocket binding
src/bun.js/bindings/webcore/WebSocket.cpp, src/bun.js/bindings/webcore/WebSocket.h
Added HandshakeRawHeader and WebSocket::didReceiveHandshakeResponse(uint16_t, std::span<uint8_t>, std::span<HandshakeRawHeader>, std::span<uint8_t>); builds JS payload (statusCode, statusMessage, rawHeaders, body), roots GC, handles exceptions, manages m_pendingActivityCount, and exports C ABI WebSocket__didReceiveHandshakeResponse(...).
Zig FFI / client surface
src/http/websocket_client/CppWebSocket.zig, src/http/websocket_client/WebSocketUpgradeClient.zig
Added RawHeader extern struct and extern fn WebSocket__didReceiveHandshakeResponse plus Zig wrapper didReceiveHandshakeResponse(...). Reworked upgrade handling: deferred buffering for non-101 responses (Content-Length/EOF handling), removed brittle "HTTP/1.1 101 " check, added processWebSocketUpgradeResponse, flushDeferredHandshakeAndProcess, and lifecycle ref()/deref() protection around synchronous JS handshake dispatch.
JS ws integration and events
src/js/thirdparty/ws.js
Implemented Node-like upgrade and unexpected-response events; added makeHandshakeResponse(statusCode, statusMessage, rawHeaders, body) to synthesize IncomingMessage-like object; lazy registration of native "handshake" listener; emit semantics for upgrade (101) vs unexpected-response (non-101) and suppression of duplicate error emissions; updated EventEmitter/DOM subscription plumbing.
Event & identifier registries
src/bun.js/bindings/webcore/EventNames.h, src/js/builtins/BunBuiltinNames.h
Added handshake event name; added builtin identifier names head, rawHeaders, statusCode, and statusMessage for JS payload accessors.
Tests
test/regression/issue/05951.test.ts, test/regression/issue/24229.test.ts
Added regression tests validating unexpected-response/upgrade behavior, event ordering, multi-chunk body buffering, header/rawHeaders/statusMessage/body contents, and multiple subscription APIs (addListener, prependListener, addEventListener, etc.).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ws: support upgrade and unexpected-response events' clearly and specifically summarizes the main change: adding two event types to the ws package.
Description check ✅ Passed The PR description addresses both required template sections: it explains what the PR does ('Adds upgrade and unexpected-response events') and references linked issues (#5951, #24229), though it lacks explicit verification steps.
Linked Issues check ✅ Passed The PR fully implements requirements from both issues: surfaces handshake response (status, headers, body) to JS, adds upgrade and unexpected-response events, eliminates runtime warnings, and restores Node-compatible event surfaces for miniflare/wrangler/Vite.
Out of Scope Changes check ✅ Passed All changes are in-scope: WebSocket handshake support in native code, JS event wiring, test coverage, and supporting identifier/event-name declarations directly address the linked issue objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/05951.test.ts`:
- Around line 88-100: The test server currently uses conn.once("data") and
immediately extracts the Sec-WebSocket-Key from the buffer which fails if
headers span multiple packets; change the logic in the createServer callback to
accumulate incoming data on conn.on("data") (using the existing buf variable),
check for the end-of-headers marker "\r\n\r\n" before attempting to parse, only
then run the RegExp to extract the Sec-WebSocket-Key and compute the accept
value, and finally remove the data listener (or switch to once) after handling
the complete request; also defensively check the RegExp result before indexing
[1] to avoid throws.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b90c611c-99a8-4db9-a310-45fa2469599d

📥 Commits

Reviewing files that changed from the base of the PR and between 0ff0065 and 0f36bd4.

📒 Files selected for processing (7)
  • src/bun.js/bindings/webcore/WebSocket.cpp
  • src/bun.js/bindings/webcore/WebSocket.h
  • src/http/websocket_client/CppWebSocket.zig
  • src/http/websocket_client/WebSocketUpgradeClient.zig
  • src/js/thirdparty/ws.js
  • test/regression/issue/05951.test.ts
  • test/regression/issue/24229.test.ts

@robobun
Copy link
Copy Markdown
Collaborator

robobun commented Apr 7, 2026

Pushed d479984. The waiting_for_length overflow bug is fixed (truncate + dispatch instead of terminate). 6/6 regression tests pass locally in ASAN debug.

Gate rejection: the farm gate release with fix build failed to fetch WebKit (.identity 404) — infrastructure, not test failure. ASAN with fix shows 6 pass, 0 fail in the same output. Release build on my container /root/.bun/build-cache/webkit-c2010c47d12c525d/lib is empty (fetch was interrupted mid-extract).

Buildkite CI failures on d479984: ubuntu-25.04 / debian-13 / alpine-3.23 test-bun jobs exit 2 across x64/aarch64/baseline/asan. These same failures exist on ee1a058d8b (autofix commit BEFORE my edits this session) and ff4a7c39 and 8d5ebdfd — they are not introduced by my recent fixes. bun scripts/buildkite-failures.ts does not exist in the repo and bk is not installed in the farm container, so I cannot pull the logs from here to identify which test files are crashing.

The failing test files are almost certainly in test/js/first_party/ws/ (ws.test.ts had 30 fails / 15 passes locally in ASAN debug — a pre-existing pattern that I verified with a git stash src/ earlier in this session). Something in the handshake-dispatch path rewrite or related edits is hitting tests outside the two regression files I own, and without log access I cannot pin down exactly which.

Happy to keep iterating if someone can pipe the relevant failing-test names back to me, or if the farm can drop a Buildkite token into the container.

Addresses review on #28114:
- head was a JSString built from UTF-8; switch it to a Node Buffer so the
  HTTP response head is surfaced as raw bytes, matching body and avoiding
  a UTF-8 round-trip on headers that are already bytes off the wire.
- statusCode/head/body now go through WebCore::builtinNames() instead of
  Identifier::fromString per call. Added statusCode and head to
  BunBuiltinNames.h (body was already there).
- ws.js shim decodes head as latin1 before parsing (RFC 7230 + node:http
  convention) and leaves the existing string parser unchanged.
- 24229 test asserts head/body are Uint8Array; 05951 upgrade-server
  reads until the end of headers before extracting Sec-WebSocket-Key so
  the test doesn't flake on split TCP reads.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/thirdparty/ws.js`:
- Around line 328-347: Create and store a synthetic http.ClientRequest-like
object on the class (e.g., this._syntheticClientRequest) and ensure
`#ensureHandshakeListener`() always runs so non-101 handshake responses are routed
into `#onHandshake`(); inside `#onHandshake`(statusCode, head, body) build the
handshake response then if statusCode !== 101 set `#unexpectedResponseEmitted` =
true and emit "unexpected-response" with (this._syntheticClientRequest, res)
when listenerCount("unexpected-response") > 0, otherwise emit "error" with the
normalized message; update any places noted (around the handshake listener
registration and lines handling non-101 paths) to reference the synthetic
request and always call `#onHandshake` so behavior matches ws's (request,
response) signature and normalization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fb339c3c-c4ef-4ad0-abec-5ba8d3e13acf

📥 Commits

Reviewing files that changed from the base of the PR and between 0f36bd4 and d59db28.

📒 Files selected for processing (5)
  • src/bun.js/bindings/webcore/WebSocket.cpp
  • src/js/builtins/BunBuiltinNames.h
  • src/js/thirdparty/ws.js
  • test/regression/issue/05951.test.ts
  • test/regression/issue/24229.test.ts

Addresses review feedback on #28114:

- Collapse the handshake FFI from (head_ptr, head_len, body_ptr, body_len) to
  (buffer_ptr, buffer_len, head_len). One slice crosses the boundary; C++
  splits into head/body spans internally from head_len.
- Drop the defensive dupe + re-parse in processWebSocketUpgradeResponse.
  Instead, move this.body out into a local ArrayList before dispatch — if
  JS tears us down via ws.close() → clearData() during the sync handshake
  event, clearData() now finds an empty ArrayList and the backing bytes
  survive long enough for the post-dispatch processResponse. Single-chunk
  fast path (body came from uSockets' `data`) is a no-op transfer.
- ws.js: synthetic ClientRequest stub for 'unexpected-response' so
  consumer code that inspects the request (req.method, req.path,
  req.getHeader) does not crash. Real ws emits
  (http.ClientRequest, http.IncomingMessage); we bypass node:http so the
  request is a minimal EventEmitter with no-op header helpers.
- 05951 test asserts the stub surface (method='GET', path='/',
  getHeader() undefined) via the 'unexpected-response' snapshot.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/js/thirdparty/ws.js (1)

318-323: ⚠️ Potential issue | 🟠 Major

Keep non-101 normalization independent of which listeners are attached.

The handshake listener is still only armed from upgrade / unexpected-response. If a caller only listens to error, a 503 never reaches #onHandshake() and Bun falls back to the native "Expected 101 status code" error instead of ws's Unexpected server response: 503. That is still a compatibility gap, and 05951.test.ts is snapshotting it now.

In the npm `ws` client implementation, when the server returns a non-101 response and there is no 'unexpected-response' listener, what error message is emitted on the WebSocket?

Also applies to: 328-405

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/js/thirdparty/ws.js` around lines 318 - 323, The code currently arms the
native handshake listener only inside ensureHandshakeListener() when listeners
for 'upgrade' or 'unexpected-response' are attached, causing non-101 responses
to be handled by Bun's native error instead of ws's normalization; modify
ensureHandshakeListener() (or call-site logic that registers the native
handshake listener) so the handshake listener is registered unconditionally (or
at least whenever any user-level listeners like 'error'/'open' may exist) so
that non-101 responses always flow into `#onHandshake`() and produce ws's
"Unexpected server response: <status>" behavior rather than the native "Expected
101 status code".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/js/thirdparty/ws.js`:
- Around line 87-140: makeHandshakeResponse currently returns a synthetic
Readable that is already ended and incorrectly includes post-header bytes as
HTTP body; change it to construct and return a real http.IncomingMessage (so
callers can consume the live stream) instead of a completed Readable, and for
upgrade responses (statusCode === 101) ensure that any bytes in the head buffer
that follow the header block are not pushed into the HTTP body (they should be
treated as leftover socket data for the upgraded WebSocket), i.e., create an
IncomingMessage tied to a dummy/socket-like object and avoid attaching
post-header bytes to its readable stream when statusCode is 101.

---

Duplicate comments:
In `@src/js/thirdparty/ws.js`:
- Around line 318-323: The code currently arms the native handshake listener
only inside ensureHandshakeListener() when listeners for 'upgrade' or
'unexpected-response' are attached, causing non-101 responses to be handled by
Bun's native error instead of ws's normalization; modify
ensureHandshakeListener() (or call-site logic that registers the native
handshake listener) so the handshake listener is registered unconditionally (or
at least whenever any user-level listeners like 'error'/'open' may exist) so
that non-101 responses always flow into `#onHandshake`() and produce ws's
"Unexpected server response: <status>" behavior rather than the native "Expected
101 status code".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 220cb6c2-bec0-4942-a8c9-ef35710794fa

📥 Commits

Reviewing files that changed from the base of the PR and between d59db28 and 192bde2.

📒 Files selected for processing (5)
  • src/bun.js/bindings/webcore/WebSocket.cpp
  • src/http/websocket_client/CppWebSocket.zig
  • src/http/websocket_client/WebSocketUpgradeClient.zig
  • src/js/thirdparty/ws.js
  • test/regression/issue/05951.test.ts

On a 101 Switching Protocols response, any bytes arriving in the same
read after the CRLFCRLF are the first WebSocket frame, not HTTP body.
Node's http layer delivers those as the `head` buffer on its 'upgrade'
event and ws passes them to `setSocket` — never as `res.push()`.

Bun's native WebSocket client already forwards buffered post-header
bytes to the protocol reader via didConnect, so the correctness of the
WebSocket protocol was fine. But makeHandshakeResponse was also pushing
those bytes into the synthetic IncomingMessage stream, so reading from
`res` on an 'upgrade' listener would surface raw WebSocket frame bytes.

Pass null as body to makeHandshakeResponse when statusCode === 101 so
the 'upgrade' response stream is cleanly empty.
Addresses review feedback on #28114:

- Non-101 responses whose body spans multiple TCP reads were truncated:
  only the bytes colocated with the headers in the first read made it
  into the 'unexpected-response' Readable because we dispatched
  synchronously on the first parse. Now the Zig client defers the
  handshake dispatch in a new deferred_handshake state until the body
  is complete (head_len + Content-Length bytes received, or the peer
  closes when there is no Content-Length). handleEnd flushes any
  buffered body on connection close. New regression test exercises a
  three-chunk 7800-byte response body.

- #getSyntheticRequest on the ws shim now builds req.path as
  pathname + search instead of just pathname, matching node's
  http.ClientRequest.path per RFC 7230 §5.3 — the query string on
  something like ws://host/path?token=abc no longer disappears from
  the unexpected-response listener's request object.
robobun and others added 2 commits April 8, 2026 00:09
Follow-up to the lazy-handshake-listener design: on()/once() went
through #onOrOnce which called #ensureHandshakeListener() for 'upgrade'
and 'unexpected-response', but the other EventEmitter registration
methods fell straight through to the base class and the native
'handshake' event was never armed. Handlers registered via
addListener / prependListener / prependOnceListener / addEventListener
silently never fired.

- addListener aliases on(), routing through #onOrOnce.
- prependListener / prependOnceListener call #ensureHandshakeListener()
  for the two events before delegating to super.prepend*Listener.
- addEventListener detects 'upgrade'/'unexpected-response' and
  registers on the EventEmitter via super.on/once (wrapping the
  listener in a DOM-style event object) instead of forwarding to the
  native WebSocket, which never emits those events.

Also ref() the HTTPClient around dispatchHandshakeAndProcess so the
flush-on-handleEnd path does not UAF when JS calls ws.terminate()
inside the handshake listener (handleData / handleDecryptedData
already ref, handleEnd did not — moving the guard inside the dispatch
helper covers every caller).

Regression test covers all four registration APIs plus the bumped
timeouts on the existing tests (5s was tight for ASAN debug subprocess
spawns).
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/http/websocket_client/WebSocketUpgradeClient.zig:922-941 — handleEnd() calls flushDeferredHandshakeAndProcess() without holding an extra ref, so if the user's 'unexpected-response' handler calls ws.close() during the synchronous JS dispatch the refcount can reach zero while still on the call stack, causing a use-after-free when dispatchHandshakeAndProcess reads this.outgoing_websocket after the dispatch returns. Add this.ref() / defer this.deref() at the start of handleEnd's deferred-dispatch branch, matching the guard already present in handleData (line 518) and handleDecryptedData (line 876).

    Extended reasoning...

    handleEnd() in WebSocketUpgradeClient.zig dispatches a synchronous JS event (the "handshake" MessageEvent that triggers "unexpected-response") via flushDeferredHandshakeAndProcess → dispatchHandshakeAndProcess → ws.didReceiveHandshakeResponse(). Both handleData (lines 518-519) and handleDecryptedData (lines 876-877) guard this same call chain with this.ref()/defer this.deref() and handleDecryptedData even carries the explicit comment: "Keep this alive through the synchronous JS dispatch in processWebSocketUpgradeResponse — JS may drop the last ref on us during that call (ws.close())." handleEnd is missing this guard entirely.

    The code path that triggers the bug: a server sends a non-101 response with no Content-Length header. The client buffers in the waiting_for_eof state. When the server closes the connection, handleEnd fires and — because deferred_handshake is .waiting_for_eof — calls this.flushDeferredHandshakeAndProcess(this.body.items). That function re-parses the headers and calls dispatchHandshakeAndProcess, which calls ws.didReceiveHandshakeResponse(), the synchronous JS event dispatch.

    During that JS dispatch the user's "unexpected-response" handler calls ws.close() (or ws.terminate()), which propagates to cancel(). cancel() does: this.ref() (+1), this.outgoing_websocket = null; this.deref() (-1), then this.tcp.close(.failure). tcp.close may trigger handleClose() synchronously. handleClose() calls this.deref() (-1). cancel's defer this.deref() then runs (-1). If the object started at ref_count 2 (the cpp_websocket ref + the handleEnd frame), the sequence +1 -1 -1 -1 = net -2 reaches zero and frees the object while dispatchHandshakeAndProcess is still on the call stack.

    Immediately after ws.didReceiveHandshakeResponse() returns, dispatchHandshakeAndProcess reads: if (this.outgoing_websocket == null) return; — this is a use-after-free because this itself has been freed.

    The fix is one line at the top of handleEnd's deferred-dispatch arm: this.ref(); defer this.deref();. This matches the exact pattern used in handleData and handleDecryptedData for the identical reason. The ref keeps this alive across the synchronous JS dispatch and the deref at function exit re-balances when the stack is safe.

    Step-by-step proof: (1) Server sends "HTTP/1.1 404 Not Found\r\n\r\nsome body" and then closes TCP. (2) handleData fires, parses headers (no Content-Length), sets deferred_handshake = .waiting_for_eof, appends body bytes to this.body, returns. (3) Server closes connection; handleEnd fires. (4) deferred_handshake is .waiting_for_eof, so handleEnd calls flushDeferredHandshakeAndProcess(this.body.items). (5) dispatchHandshakeAndProcess calls ws.didReceiveHandshakeResponse(404, body, head_len) — synchronous JS dispatch. (6) User's "unexpected-response" handler calls ws.close() → cancel(). cancel: ref(=3), outgoing_websocket=null + deref(=2), tcp.close → handleClose: deref(=1), cancel defer: deref(=0) → object freed. (7) Back in dispatchHandshakeAndProcess: accesses this.outgoing_websocket on freed memory — use-after-free.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/js/thirdparty/ws.js:588-607 — removeEventListener silently no-ops for 'upgrade'/'unexpected-response' listeners registered via addEventListener: the wrapped arrow function is never stored, and the removal targets the native WebSocket (this.#ws) instead of the JS EventEmitter where those listeners live. Any ws.removeEventListener('upgrade', handler) call after addEventListener('upgrade', handler) leaks the listener permanently.

    Extended reasoning...

    What the bug is

    This PR adds a new code path in addEventListener (ws.js lines 588-607) for 'upgrade' and 'unexpected-response' events. When either event type is detected, the handler wraps the original listener in an anonymous arrow function:

    const wrapped = (...args) => listener({ type, target: this, data: args });
    if (options && options.once) {
      return super.once(type, wrapped);
    }
    return super.on(type, wrapped);

    The wrapped reference is discarded immediately. Then removeEventListener (lines 608-610) is unchanged from before the PR:

    removeEventListener(type, listener) {
      this.#ws.removeEventListener(type, listener);
    }

    Two compounding failures

    1. Wrong target: 'upgrade'/'unexpected-response' listeners are now stored on the JS-side BunWebSocket EventEmitter (this) via super.on/super.once, but removeEventListener delegates to this.#ws.removeEventListener — the native browser-style WebSocket object. The native WebSocket never had those listeners; the removal silently no-ops.

    2. Lost wrapper reference: Even if the target were corrected to this (the EventEmitter), the wrapped arrow function that was actually registered is gone. The EventEmitter internal list holds wrapped, but the caller passes the original listener to removeEventListener. There is no way to match them without a stored map.

    Before this PR: addEventListener('upgrade', cb) forwarded to this.#ws.addEventListener and removeEventListener forwarded to this.#ws.removeEventListener — both targeted the same object. The PR broke this symmetry by routing the registration to a different object while leaving removal pointing at the old one.

    Step-by-step proof

    1. ws.addEventListener('upgrade', handler) is called.
    2. #ensureHandshakeListener() arms the native 'handshake' driver.
    3. wrapped = (...args) => handler({ type: 'upgrade', target: ws, data: args }) is created and registered via super.on('upgrade', wrapped) on the BunWebSocket EventEmitter.
    4. wrapped goes out of scope; the only reference to it is inside the EventEmitter internal listener list.
    5. ws.removeEventListener('upgrade', handler) is called.
    6. Control reaches this.#ws.removeEventListener('upgrade', handler) — targeting the native WebSocket.
    7. The native WebSocket has no 'upgrade' listener; removal silently no-ops.
    8. The EventEmitter still holds wrapped. Every future 'handshake' event continues to call handler. The listener is permanently leaked.

    Impact

    Any code that uses the DOM-style addEventListener/removeEventListener pair for 'upgrade' or 'unexpected-response' (e.g., one-time introspection that then cleans up) will accumulate handlers indefinitely. This is a regression introduced by the PR — prior to this change, all event types were symmetrically routed through this.#ws.

    Fix

    Store a WeakMap (keyed by original listener) on the instance. In addEventListener, record wrappedMap.set(listener, wrapped) before registering. In removeEventListener, detect 'upgrade'/'unexpected-response' and call super.off(type, wrappedMap.get(listener)) then delete the entry, instead of delegating to this.#ws.

  • 🔴 src/js/thirdparty/ws.js:510-521 — prependListener and prependOnceListener call #ensureHandshakeListener() only for 'upgrade'/'unexpected-response', then invoke super.prependListener/super.prependOnceListener directly for all other events, bypassing #onOrOnce(). For standard events ('open', 'close', 'message', 'ping', 'pong', 'error'), the native this.#ws.addEventListener() bridge is set up by #onOrOnce(); without it, any callback registered via prependListener as the sole listener for those events silently never fires. The comment directly above the methods (line 500–505) explicitly states 'Each needs to go through #onOrOnce', and addListener (line 506) was correctly fixed — but prependListener/prependOnceListener were only partially updated.

    Extended reasoning...

    What the bug is

    prependListener(event, listener) at lines 510–514 and prependOnceListener at lines 517–521 only call #ensureHandshakeListener() for 'upgrade'/'unexpected-response', then unconditionally call super.prependListener/super.prependOnceListener for all remaining events. For standard ws events ('open', 'close', 'message', 'ping', 'pong', 'error'), the native this.#ws.addEventListener() forwarder that bridges native WebSocket events to the JS EventEmitter is installed by #onOrOnce() — not by the super call. Bypassing #onOrOnce() means the forwarder is never installed for these events when prependListener is the only registration method used.

    The specific code path that triggers it

    The bit-set guard in #onOrOnce (line ~415) tracks whether a persistent EventEmitter listener has been registered, and it is also where this.#ws.addEventListener('open', ...) etc. are installed. When a caller uses ws.prependListener('open', cb) without any prior ws.on('open', ...) or ws.addListener('open', ...): (1) prependListener calls #ensureHandshakeListener() — no-op since event !== 'upgrade'; (2) super.prependListener pushes cb into EventEmitter's list; (3) #onOrOnce is never reached; (4) this.#ws.addEventListener('open', ...) is never called; (5) when the native WebSocket fires 'open', this.emit('open') is never triggered, so cb silently never fires.

    Why existing code doesn't prevent it

    The comment block above the three overrides (lines 499–505) explicitly states: "Each needs to go through #onOrOnce so 'upgrade'/'unexpected-response' subscribers lazily arm the native handshake listener". addListener (line 506) was correctly updated to call this.#onOrOnce(event, listener, undefined). But prependListener and prependOnceListener were only half-updated: they handle the handshake events but skip #onOrOnce entirely for every other event, leaving the native bridge uninstalled.

    Impact

    If a caller registers only via prependListener('open', cb) (or prependOnceListener), the callback silently never executes. In practice most callers use .on() first and then prependListener to insert before existing handlers, so the bridge is usually already installed. But the behavioral inconsistency — addListener('open', cb) works; prependListener('open', cb) alone does not — violates the EventEmitter API contract and will silently break code that relies on insertion ordering for the first handler.

    Step-by-step proof

    1. ws = new WebSocket('ws://...'); ws.prependListener('open', () => console.log('opened'));
    2. prependListener('open', cb) checks: event === 'upgrade'? No. Calls super.prependListener('open', cb) — cb is in the EventEmitter list.
    3. #onOrOnce is never called; this.#eventId bit for 'open' remains 0; no this.#ws.addEventListener('open', ...) is installed.
    4. Native WebSocket fires the open event. Native side calls this.emit('open') only if the forwarder is registered. With no forwarder, this.emit is never called.
    5. cb never executes. No error or warning is produced.

    How to fix it

    Route prependListener and prependOnceListener through #onOrOnce for the non-upgrade events, similar to addListener. For example: call this.#onOrOnce(event, () => {}, undefined) to ensure the native bridge is installed, then call super.prependListener(event, listener). Alternatively, extract the bridge-installation logic from #onOrOnce into a helper and call it from prependListener/prependOnceListener before delegating to super.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/http/websocket_client/WebSocketUpgradeClient.zig`:
- Around line 659-663: The code is forwarding post-header bytes
(body[head_len..]) to handshake listeners on a 101 Switching Protocols response;
change the logic so that when status_code == 101 you do NOT include the bytes
after head_len in the data sent to
didReceiveHandshakeResponse/didReceiveHandshake* handlers — only pass the HTTP
header portion (or an empty body slice) to
dispatchHandshakeAndProcess/didReceiveHandshakeResponse, and keep the
post-header bytes for processResponse() as overflow for the connected WebSocket
client; update both the fast-path at dispatchHandshakeAndProcess and the other
occurrence(s) around the 683-717 range to branch on status_code == 101 and
strip/withhold body[head_len..] when invoking handshake callbacks.
- Around line 625-655: The deferred-handshake buffering needs an upper bound:
introduce a MAX_DEFERRED_HANDSHAKE_BYTES constant and, before any appendSlice or
before accepting a Content-Length target_len, check that the resulting buffered
size (this.body.len + body.len or target_len) does not exceed the limit; if it
would, abort the upgrade/close the connection and do not append (fail fast)
instead of growing unbounded. Apply this guard in both places that call
this.body.appendSlice (the Content-Length branch where you compute target_len
and the EOF branch), and when the limit is exceeded perform the same
abort/cleanup path you use for other fatal errors (do not append and return).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b9b6d89a-d18e-494f-b9dc-bdb96441e7d9

📥 Commits

Reviewing files that changed from the base of the PR and between 1fbf7e4 and ee1a058.

📒 Files selected for processing (8)
  • src/bun.js/bindings/webcore/EventNames.h
  • src/bun.js/bindings/webcore/WebSocket.cpp
  • src/bun.js/bindings/webcore/WebSocket.h
  • src/http/websocket_client/CppWebSocket.zig
  • src/http/websocket_client/WebSocketUpgradeClient.zig
  • src/js/builtins/BunBuiltinNames.h
  • src/js/thirdparty/ws.js
  • test/regression/issue/24229.test.ts

Addresses three review findings on #28114:

1. Deferred-handshake DoS cap. processWebSocketUpgradeResponse accepts
   a Content-Length header value directly into the buffer target. A
   malicious server could send `Content-Length: 18446744073709551615`
   and make the saturating-add wrap to usize_max, which the length
   check would never satisfy — every subsequent read would keep
   appending to this.body until bun.handleOom panicked.

   Cap at 64 MB. Both the Content-Length branch and the waiting_for_eof
   branch enforce the limit before every appendSlice via a new
   appendDeferredHandshakeBody helper that both handleData and
   handleDecryptedData reuse. Over the cap terminates the upgrade with
   expected_101_status_code.

2. 101 post-header bytes. On 101 Switching Protocols, bytes after the
   CRLFCRLF are the first WebSocket frame from the peer — not HTTP
   body. dispatchHandshakeAndProcess was passing them through
   didReceiveHandshakeResponse, so listeners on the native 'handshake'
   event saw protocol bytes as if they were response body. Only
   processResponse needs them, and only to hand them to the connected
   client as buffered overflow. Pass an empty body to the handshake
   dispatch when status_code == 101.

3. Proxy TLS EOF flush. terminate() now flushes a pending
   deferred_handshake when called with ErrorCode.ended so the
   wss://-through-HTTP-CONNECT-proxy path (which closes via
   WebSocketProxyTunnel.onClose → terminate, never through handleEnd)
   still delivers the 'unexpected-response' event instead of silently
   dropping the accumulated body.

Plus two smaller items:

- addListener / prependListener / prependOnceListener /
  addEventListener route through either #onOrOnce or a new
  #armNativeBridge helper so the native forwarder is installed for
  standard events. Previously prependListener/prependOnceListener
  skipped the bridge setup and the callback silently never fired.
- removeEventListener is now symmetric with addEventListener for
  upgrade/unexpected-response: it calls super.off on the JS
  EventEmitter instead of forwarding to the native WebSocket (which
  never had those listeners). The DOM-style wrapper closure is gone so
  the original listener reference matches on removal.
- Drop the unused macro(head) builtin identifier (earlier design
  iteration that the parsed-rawHeaders rewrite left behind).

05951 grows one 30s-timeout test that exercises all four listener-
registration APIs. 24229 still asserts the native handshake payload.
Addresses three more review findings on #28114:

- handleClose (the uSockets onClose / TCP RST path) was tearing down
  via clearData() + dispatchAbruptClose without ever checking
  deferred_handshake. A server that mid-accumulation RSTs instead of
  FINs the connection would drop the unexpected-response event
  entirely. Flush before clearData so the listener sees whatever
  arrived before the reset. handleEnd and terminate(.ended) already
  flush; handleClose just needed the same guard.

- appendDeferredHandshakeBody.waiting_for_length now caps against
  target_len (already bounded to head_len + MAX_NON_101_BODY upstream)
  instead of MAX_NON_101_BODY directly. The accumulated buffer stores
  both the parsed head and the body, so capping on MAX_NON_101_BODY
  would terminate head_len bytes short of a response that declared
  Content-Length: MAX_NON_101_BODY. target_len is the right target.

- #armNativeBridge: `1 << undefined` is 1, not 0, so
  `if (!mask) return` was dead code for unknown event names and would
  install a spurious noopBridgeListener on bit 0 of #eventId if a
  caller passed a custom event to prependListener. Guard explicitly on
  `eventIds[event] === undefined`.
The .waiting_for_length arm of appendDeferredHandshakeBody was
terminating with expected_101_status_code whenever a TCP read carried
bytes past target_len (TCP coalescing with trailing bytes, pipelined
responses, etc.) — suppressing the unexpected-response event entirely.

The single-chunk fast path in processWebSocketUpgradeResponse handles
the same overflow by truncating to body[0..target_len] and dispatching.
The multi-chunk path now does the same: take only target_len -
this.body.items.len bytes from the incoming read, append, and flush.
Extra bytes are dropped (they belong to the next pipelined response or
are garbage). The upstream Content-Length guard already caps target_len
at head_len + MAX_NON_101_BODY so unbounded growth is not possible.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants