Skip to content

ws: support upgrade and unexpected-response events#28114

Open
alii wants to merge 24 commits into
mainfrom
ali/ws-upgrade-events
Open

ws: support upgrade and unexpected-response events#28114
alii wants to merge 24 commits into
mainfrom
ali/ws-upgrade-events

Conversation

@alii

@alii alii commented Mar 14, 2026

Copy link
Copy Markdown
Member

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

robobun commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator
Updated 2:07 PM PT - May 30th, 2026

@robobun, your commit 0b89041 has 1 failures in Build #59376 (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

alii commented Apr 4, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@robobun

robobun commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator

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

alii commented Apr 6, 2026

Copy link
Copy Markdown
Member Author

@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 2 times, most recently 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

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

Comment thread test/regression/issue/05951.test.ts Outdated
Comment thread src/bun.js/bindings/webcore/WebSocket.cpp Outdated
Comment thread src/bun.js/bindings/webcore/WebSocket.cpp Outdated
@robobun

robobun commented Apr 7, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main and re-implemented the feature in Rust (main had ported the upgrade client Zig→Rust from a pre-PR snapshot that still early-rejected non-101 responses). PR is mergeable.

Fix summary:

  • WebSocketUpgradeClient.rs: dropped the early 101 reject; process_websocket_upgrade_response + DeferredHandshake buffers non-101 bodies (Content-Length / read-until-EOF); Transfer-Encoding and bodiless statuses (1xx/204/304, per RFC 7230 §3.3.3) dispatch immediately so a keep-alive connection never stalls; 64 MB cap; handle_end / RST close / proxy-tunnel terminate(Ended) all flush a pending body; on 101 post-header bytes stay out of the handshake event. Fixed a use-after-free on the RST path: if the unexpected-response listener calls ws.terminate()/ws.close() while a non-101 body was mid-buffer, the reentrant teardown freed the client before handle_close finished — now guarded by taking the socket ref + detaching before dispatch. On 101, upgrade and open now fire under one event-loop scope so a microtask queued in an upgrade handler observes readyState === OPEN (matching npm ws) rather than draining between the two events.
  • CppWebSocket.rs + C++ bindings: RawHeader FFI struct, didReceiveHandshakeResponse dispatch, handshake event, statusCode/statusMessage/rawHeaders identifiers.
  • ws.js: ws emits upgrade (101) and unexpected-response (non-101) with a Node-style IncomingMessage; addListener/prependListener/prependOnceListener/addEventListener all arm the native handshake bridge. The synthetic IncomingMessage.headers coalesces duplicate headers the way Node does; rawHeaders stays verbatim. When unexpected-response (or a co-registered error) handles the non-101, the native error is suppressed across on/addEventListener/onerror without double-firing. addEventListener now applies DOM-style dedup to every event type (including upgrade/unexpected-response) and clears its error wrapper bookkeeping on the {once:true} path.

Validation: regression tests in test/regression/issue/{05951,24229}.test.ts (16 cases) pass with the fix and fail on the baked bun without it.


CI status: the diff is green. The only red lane (darwin-14-aarch64-test-bun on build #59376) is test/regression/issue/8254.test.ts failing with ENOSPC: no space left on device — the macOS agent ran out of disk writing a >2 GB file in an unrelated 2GB-boundary write test. Nothing in this PR touches that path; every other lane and all WebSocket tests pass. Ready for a maintainer to merge.

robobun pushed a commit that referenced this pull request Apr 7, 2026
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

Comment thread src/js/thirdparty/ws.js
Comment thread src/http/websocket_client/WebSocketUpgradeClient.zig Outdated
Comment thread src/http/websocket_client/WebSocketUpgradeClient.zig Outdated
robobun pushed a commit that referenced this pull request Apr 7, 2026
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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

Comment thread src/js/thirdparty/ws.js
Comment thread src/js/thirdparty/ws.js
Comment thread src/js/thirdparty/ws.js
Comment thread src/js/thirdparty/ws.js
Comment thread src/http/websocket_client/WebSocketUpgradeClient.zig Outdated
robobun pushed a commit that referenced this pull request Apr 7, 2026
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.
Comment thread src/js/thirdparty/ws.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

Comment thread src/http/websocket_client/WebSocketUpgradeClient.zig Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

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.

Comment thread src/http/websocket_client/WebSocketUpgradeClient.zig Outdated
Comment thread src/js/thirdparty/ws.js
darwin-{14,26}-aarch64-test-bun expired in queue (no agent, 0s, no test ran);
every build and test lane that executed passed, including debian-13-x64-asan
and darwin-14-x64.
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
The SAFETY comment in dispatch_handshake_and_process claimed the header
name/value slices point into `full`, but on the direct-dispatch paths
(101 / bodiless / Transfer-Encoding) `response` was parsed against the
original `data`/`self.body` buffer and `full` is a separate to_vec() copy —
so the slices reference the original buffer, not `full`. No runtime change
(both buffers outlive the synchronous FFI; C++ copies the bytes into JS
strings before any user JS runs). Reword both comments so a future reader
doesn't assume the original buffer can be dropped/mutated before dispatch.
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
robobun and others added 2 commits May 30, 2026 15:10
…onse' on RST

When a non-101 response body is mid-accumulation (WaitingForLength/EOF) and the
peer RSTs the socket, handle_close (the RST path, not handle_end) flushes the
deferred body into the 'unexpected-response' listener. If that listener
synchronously calls ws.close()/ws.terminate(), the reentrant cancel() saw tcp
still attached and the socket's ext slot still populated, so it released the
socket ref a second time and re-closed tcp — re-entering handle_close. The
original handle_close then ran tcp.detach() on freed memory
(heap-use-after-free in NewSocketHandler::detach) and deref()'d an already-zero
refcount.

Fix: when a deferred handshake is pending, handle_close now takes the socket's
ext slot and detaches tcp BEFORE dispatching the flush, so a reentrant cancel()
finds ext == None / a detached tcp and no-ops both the socket-ref release and
the re-close. A ref_guard held across the flush keeps  alive even when
the C++ ref is released mid-dispatch; the C++ ref is then released
exactly-once via take(), and the socket ref once, with the guard drop as the
final release.

Regression test drives the exact path with a Bun.listen server whose
socket.terminate() sends a real RST while the client is WaitingForLength; gated
on isDebug/isASAN since only a sanitizer build surfaces the corruption.
Comment thread test/regression/issue/05951.test.ts Outdated
The run() helper returns only { stdout, exitCode } and already logs stderr
internally on non-zero exit, so the destructured stderr was undefined and the
console.error(stderr) printed a spurious 'undefined' line. Match the other
tests in the file, which rely on run()'s internal stderr logging.
Comment thread test/regression/issue/05951.test.ts
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
Comment thread src/js/thirdparty/ws.js
robobun and others added 2 commits May 30, 2026 16:24
…tener/onerror too

The #unexpectedResponseEmitted gate only covered the on()/once() bridge, so a
handler registered via addEventListener('error', h) or the onerror setter still
received the native 'Expected 101' error after 'unexpected-response' fired —
an observable inconsistency vs on('error') and real npm ws. Wrap both paths in
the same suppression gate: addEventListener('error') installs a gated wrapper
tracked in a WeakMap so removeEventListener still matches, and the onerror
setter stores the user fn while the native slot holds the wrapper.

Also make the 8 subprocess-spawning tests in 05951.test.ts concurrent (per
test/CLAUDE.md; each spawns its own ephemeral-port server, no shared state),
and reword a stale 'saturating add' comment in WebSocketUpgradeClient.rs — the
ported code uses plain head_len + cl, guarded by the 64 MB cap above.
Comment thread src/js/thirdparty/ws.js Outdated
Comment thread src/js/thirdparty/ws.js
robobun and others added 3 commits May 30, 2026 16:59
… addEventListener('error')

Two follow-ups to the error-suppression change:

1. #onHandshake set #unexpectedResponseEmitted unconditionally, so on the
   else-branch (no 'unexpected-response' listener) the subsequent native error
   was wrongly suppressed for addEventListener('error')/onerror handlers — they
   got nothing. Set the flag only when 'unexpected-response' actually fires, and
   guard the else-branch emit('error') with listenerCount('error') > 0 so it
   doesn't throw on zero EventEmitter listeners.

2. addEventListener('error', h) wrapped h in a fresh closure each call, so a
   duplicate registration created two wrappers (native identity-dedup no longer
   applied) and fired h twice, and the WeakMap overwrite leaked the first
   wrapper. Early-return when the listener is already wrapped, matching DOM
   dedup semantics.
clippy::undocumented_unsafe_blocks flagged the unsafe block inside the
.map(|ext| unsafe { (*ext).take() }) closure — the SAFETY comment was on the
preceding let, which clippy can't associate with the closure's block. Rewrite
as an if let with the SAFETY comment directly on the inner unsafe block.
Behaviorally identical.
Comment thread src/js/thirdparty/ws.js
robobun and others added 2 commits May 30, 2026 18:18
…xpected-response listener

66d76cb moved #unexpectedResponseEmitted = true inside the unexpected-response
branch so addEventListener('error')/onerror still get the native error. But
on('error') lives on both sides — the EventEmitter (gets the synthetic
'Unexpected server response' from the else-if) and, via the bridge closure, the
native socket (gets the native 'Expected 101'). With the flag left false, both
fired → h called twice. Set the flag in the else-if too: the synthetic error
already reached EE listeners, so suppress the native follow-up for them. The
addEventListener/onerror-only case is unaffected (listenerCount('error') === 0
there, so the else-if is skipped and the flag stays false).

Regression test: on('upgrade') + on('error'), no 'unexpected-response' → the
error handler fires exactly once (count:1), matching real ws.
Comment thread src/js/thirdparty/ws.js
robobun and others added 2 commits May 30, 2026 18:37
…e mixed

One suppression flag couldn't serve two distinct purposes: the EventEmitter
bridge needs 'did an EE listener already get an error?' (avoid double-fire),
while the addEventListener/onerror wrappers need 'did unexpected-response handle
it?' (real ws emits no error then). They coincided for single-style usage but
diverged when both on('error') and a DOM-style handler were registered: the
synthetic emit to on('error') set the flag, which then wrongly suppressed the
DOM handler's native error entirely.

Split into two flags: #unexpectedResponseEmitted (set whenever an error reached
EE listeners) gates the bridge; #unexpectedResponseHandled (set only when
unexpected-response fired) gates the DOM-style wrappers. Now every registration
combination delivers exactly one error to each handler.

Regression test covers the mixed on('error') + addEventListener('error')/onerror
case (each fires once). Also awaits both handlers explicitly so the native
error delivery isn't raced by 'close'.
Comment thread src/http_jsc/websocket_client/CppWebSocket.rs
On a 101 handshake the upgrade client dispatched 'upgrade'
(did_receive_handshake_response) and 'open' (did_connect) as two
independent event-loop entries. exit() drains microtasks when the
entered count drops 1->0, and with no outer scope the count hit 0
between the two dispatches, so a microtask queued inside an 'upgrade'
handler ran before 'open' and observed readyState CONNECTING (npm ws
fires both in the same frame, so it observes OPEN).

Wrap the 101 path in a single event-loop scope so the two inner
enter()/exit() pairs nest and the count never reaches 0 between them;
microtasks now drain once, after 'open'. Scoped to 101 only: a non-101
response calls terminate() instead of did_connect(), and the
'unexpected-response' body stream relies on the microtask checkpoint at
the handshake dispatch's exit() running before that teardown.
Comment thread src/js/thirdparty/ws.js
… once wrappers

addEventListener's 'upgrade'/'unexpected-response' branch registered on
the EventEmitter via super.on/super.once without a dedup check, so
addEventListener(type, h) twice fired h twice on a 101 — real ws (and
the 'error' branch in the same method) dedup every event type. Scan
listeners(type) for the handler before registering.

Also, when {once:true} is passed to addEventListener('error', h) the
native EventTarget auto-removes the wrapper after it fires, but the
#errorListenerWrappers entry stayed, so a later addEventListener('error',
h) hit the dedup guard and silently no-op'd. Clear the WeakMap entry from
inside the wrapper on the once path. (A native WebSocket dispatches
'error' at most once per lifetime — m_state==CLOSED guards plus the Rust
client consuming its back-pointer — so this re-registration path can't be
driven to a second fire through the public ws API; the fix keeps the map
consistent with DOM once semantics.)

Regression test covers the 'upgrade' dedup (fires twice without the fix).
@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

This also resolves #31792 (ws unexpected-response not firing on a non-101 upgrade, breaking chrome-devtools-mcp/puppeteer). I'd opened #31800 for that before finding this — yours is the superset (both upgrade + unexpected-response, Node-faithful header coalescing, multi-read body buffering, reentrancy hardening, and addEventListener('error')/onerror suppression), so I'm closing mine in favor of this.

One thing worth a regression test if you don't already have it: the no-listener path is exactly what puppeteer / chrome-devtools-mcp depend on — with no unexpected-response listener, ws must emit error: "Unexpected server response: <status>" then close: 1006. I verified that byte-for-byte against Node ws@8, and your #onHandshake does the same. A raw-TCP server replying a non-101 (so the bytes are actually flushed, unlike http.createServer on an upgrade request) is a reliable, fast way to exercise it in-process.

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