Skip to content

ws: emit client upgrade event with the handshake response#31408

Open
robobun wants to merge 14 commits into
mainfrom
farm/fa575560/ws-client-upgrade-event
Open

ws: emit client upgrade event with the handshake response#31408
robobun wants to merge 14 commits into
mainfrom
farm/fa575560/ws-client-upgrade-event

Conversation

@robobun

@robobun robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

Bun's ws client shim hardcoded the upgrade (and unexpected-response/redirect) event as "not implemented" and printed a compatibility warning instead of firing it. Node's ws emits upgrade with the HTTP handshake response (an http.IncomingMessage) right before open, so code that waits for upgrade — e.g. to read handshake response headers before resolving a WebSocket-backed connection — broke under Bun.

Fixes #31406.

Reproduction

const { WebSocketServer, WebSocket } = require("ws");
const wss = new WebSocketServer({ port: 0 }, () => {
  const ws = new WebSocket(`ws://127.0.0.1:${wss.address().port}`);
  let gotUpgrade = false;
  ws.on("upgrade", () => { gotUpgrade = true; console.log("upgrade event emitted"); });
  ws.on("open", () => { console.log(`open upgrade=${gotUpgrade}`); ws.close(); wss.close(); process.exit(gotUpgrade ? 0 : 1); });
});

Before (Bun): prints [bun] Warning: ws.WebSocket 'upgrade' event is not implemented in bun then open upgrade=false, exit 1.
After (matches Node): upgrade event emitted then open upgrade=true, exit 0.

Cause

The native WebSocket upgrade client (WebSocketUpgradeClient) already parsed the 101 handshake response (status line + headers) but never forwarded it to the JS WebSocket, so the shim had nothing to build the upgrade event's IncomingMessage from. The shim therefore stubbed the event with a warning.

Fix

  • WebSocketUpgradeClient forwards the parsed 101 response (status code, reason phrase, header list) to the C++ WebSocket right before didConnect, while the parse buffer is still valid. Dispatching before didConnect (still CONNECTING) matches node's ordering; a handshake/upgrade handler that synchronously closes the socket is handled by the existing !tcp.is_closed() && has_ws re-check (cancel() clears outgoing_websocket, so didConnect is skipped).
  • WebSocket::didReceiveHandshakeResponse dispatches a handshake MessageEvent carrying { statusCode, statusMessage, rawHeaders, body }. It early-returns unless a handshake listener is registered, so the browser-style new WebSocket() path pays nothing.
  • src/js/thirdparty/ws.js lazily wires that native handshake listener when the user subscribes to upgrade, folds the rawHeaders into an http.IncomingMessage-shaped object (via node:stream's Readable, with set-cookie kept as an array like Node), and emits upgrade before open. The upgrade warning stub is removed.

unexpected-response and redirect remain stubbed — they require forwarding non-101 responses, which the native client fails before this point.

Verification

New tests in test/js/first_party/ws/ws.test.ts (upgrade event describe):

  • upgrade fires before open, and its argument is the handshake response (statusCode === 101, statusMessage, standard headers, rawHeaders array).
  • Custom handshake response headers (incl. set-cookie as an array) are surfaced, using a Bun.serve server that sets them.
  • once("upgrade") works.

These pass with the debug build and time out (event never fires) under USE_SYSTEM_BUN=1, confirming they exercise the fix. Verified reentrant close()/terminate() inside the upgrade handler and a 50-connection stress loop are clean under the ASAN debug build.

Related issues

The `ws` client shim hardcoded the `upgrade` event as "not implemented"
and printed a compatibility warning instead of firing it. Node's `ws`
emits `upgrade` with the HTTP handshake response (an `IncomingMessage`)
right before `open`, so code that waits for `upgrade` (e.g. to read
handshake response headers) broke under Bun.

The native WebSocket client already parsed the 101 handshake response but
never forwarded it. This surfaces it:

- `WebSocketUpgradeClient` forwards the parsed 101 response (status line +
  headers) to the C++ `WebSocket` right before `didConnect`, while the
  parse buffer is still valid. A `handshake`/`upgrade` handler that
  synchronously closes the socket is handled by the existing
  `!tcp.is_closed() && has_ws` re-check (cancel() clears
  outgoing_websocket), so `didConnect` is skipped.
- `WebSocket::didReceiveHandshakeResponse` dispatches a `handshake`
  MessageEvent carrying `{ statusCode, statusMessage, rawHeaders, body }`.
  It is a no-op unless a `handshake` listener is registered, so the
  browser-style `new WebSocket()` path pays nothing.
- The `ws` shim lazily wires that native listener when the user subscribes
  to `upgrade`, builds a minimal `http.IncomingMessage` from the response,
  and emits `upgrade` before `open`.

`unexpected-response` and `redirect` remain stubbed; they require
forwarding non-101 responses, which the native client fails before this
point.

Closes #31406
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 36 minutes and 30 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4c758ecf-2795-40d6-8d46-fee51a2468e7

📥 Commits

Reviewing files that changed from the base of the PR and between 96c23dc and 7455d72.

📒 Files selected for processing (1)
  • test/js/first_party/ws/ws-upgrade.test.ts

Walkthrough

Adds end-to-end WebSocket upgrade/handshake support: C++ types and event, Rust FFI bridge, HTTP client forwarding with delayed overflow-pointer extraction, JS IncomingMessage construction and lazy listener wiring, and tests ensuring upgrade fires before open with correct handshake data.

Changes

WebSocket upgrade event implementation

Layer / File(s) Summary
C++ handshake types and event infrastructure
src/jsc/bindings/webcore/WebSocket.h, src/jsc/bindings/webcore/EventNames.h, src/js/builtins/BunBuiltinNames.h
Introduces WebSocket::HandshakeRawHeader struct and didReceiveHandshakeResponse method declaration. Adds handshake event to the event names macro. Adds rawHeaders, statusCode, statusMessage builtin identifier accessors.
C++ handshake response callback and FFI
src/jsc/bindings/webcore/WebSocket.cpp
Implements didReceiveHandshakeResponse to construct a JS event payload with status code, status message, flattened raw headers array, and body bytes, then dispatches a handshake MessageEvent with pending-activity bookkeeping. Includes BunClientData.h and provides an extern "C" trampoline that converts pointer/length FFI arguments to std::span.
Rust FFI definitions and VM wrapper
src/http_jsc/websocket_client/CppWebSocket.rs
Defines the C-compatible HandshakeRawHeader struct and FFI declarations. Implements CppWebSocket::did_receive_handshake_response wrapper that enters/exits the VM event loop and forwards borrowed Rust slices as raw pointers and lengths.
HTTP 101 response forwarding to WebSocket
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
On successful 101 Switching Protocols, constructs a Vec<HandshakeRawHeader> from parsed response headers and forwards the handshake data to C++. Refactors overflow buffer handling: wraps the remaining bytes in Option<Box<[u8]>> and delays pointer extraction to the moment of did_connect or did_connect_with_tunnel handoff in both branches.
JavaScript upgrade event object and emission
src/js/thirdparty/ws.js
Adds makeHandshakeResponse() to build IncomingMessage-shaped Readable streams from handshake data, with normalized headers including special set-cookie array aggregation. Implements #handshakeListenerRegistered flag, #ensureHandshakeListener(), and #onHandshake() to emit "upgrade" events. Updates #onOrOnce() to lazily wire the native handshake listener only when "upgrade" subscribers are present.
Upgrade event test coverage
test/js/first_party/ws/ws-upgrade.test.ts
Tests verify that "upgrade" fires before "open" with correct handshake response details including status, message, HTTP version, and headers. Validates custom handshake headers and array behavior for Set-Cookie. Confirms ws.once("upgrade", ...) resolves correctly.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ws: emit client upgrade event with the handshake response' clearly and concisely summarizes the main change: implementing the missing upgrade event emission for ws clients with the handshake response.
Description check ✅ Passed The PR description is comprehensive and follows the template with both required sections ('What does this PR do?' under Problem/Fix, and 'How did you verify your code works?' under Verification) providing detailed explanations of the changes and testing approach.
Linked Issues check ✅ Passed All code changes directly address the linked issue #31406: implementing upgrade event emission with handshake response (status code, message, headers), matching Node.js behavior before open, and providing tests verifying the implementation.
Out of Scope Changes check ✅ Passed All code changes are scope-appropriate: Rust FFI infrastructure for handshake forwarding, C++ event dispatching, JavaScript event binding, builtin identifier additions, and related tests—all directly supporting the upgrade event feature without unrelated refactoring.

✏️ 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.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:27 PM PT - May 25th, 2026

@robobun, your commit 7455d72 has 3 failures in Build #58123 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31408

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

bun-31408 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. ws.WebSocket 'upgrade' and 'unexpected-response' event is not implemented in bun #5951 - The original issue tracking that ws.WebSocket upgrade and unexpected-response events are not implemented in Bun; this PR directly implements the upgrade event
  2. Vite dev server not starting with @cloudflare/vite-plugin and Bun ^1.3 #24229 - Vite dev server fails to start with @cloudflare/vite-plugin because the ws client upgrade event is missing (error output shows the "not implemented" warning, commenters confirm ws.WebSocket 'upgrade' and 'unexpected-response' event is not implemented in bun #5951 as root cause)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #5951
Fixes #24229

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. ws: Implement upgrade and unexpected-response events #25777 - Also implements ws upgrade and unexpected-response events, touching the same WebSocket.cpp/h, CppWebSocket, WebSocketUpgradeClient, and ws.js files
  2. ws: support upgrade and unexpected-response events #28114 - Also implements ws upgrade and unexpected-response events with the same approach across the same set of files

🤖 Generated with Claude Code

Comment thread src/js/thirdparty/ws.js Outdated
robobun added 2 commits May 25, 2026 22:03
The upgrade-event tests were in ws.test.ts alongside tests that spawn a
subprocess echo server per case. Those subprocess tests time out under the
slow ASAN debug build (1000ms timeout vs >2s cold spawn), so the file never
reaches a clean pass there regardless of the fix. Move the upgrade-event
tests into ws-upgrade.test.ts, which uses only in-process servers and runs
fast under ASAN.
Comment thread src/js/thirdparty/ws.js Outdated
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI note for maintainers: the red buildkite/bun aggregate is a stale latch from a transient failure of test/bundler/transpiler/transpiler.test.js on the Windows 2019 x64 lanes. Both of those lanes passed on retry within this same build (#58041), and every other leaf lane is green — including debian-13-x64-asan-test-bun, which runs this PR's new ws-upgrade.test.ts under ASAN.

This PR's diff is limited to the WebSocket client (src/http_jsc/websocket_client/, src/jsc/bindings/webcore/WebSocket.*, ws.js, codegen names) plus test/js/first_party/ws/ws-upgrade.test.ts; it does not touch the bundler/transpiler. The transpiler fixture that test exercises was deepened on main in #31382 (after this branch's base) specifically to address its Windows behavior, so the failure is unrelated to this change. cargo clippy, Format, and Lint JavaScript all pass.

Not pushing a retrigger since the affected lanes already recovered on their own.

…atch node headers prototype

Two review fixes:

- WebSocketUpgradeClient: the bytes trailing the 101 header block were
  leaked across FFI (`heap::into_raw`) before the `!tcp.is_closed() &&
  has_ws` re-check. When an `upgrade` handler synchronously closes the
  socket, `cancel()` clears `outgoing_websocket`/closes `tcp`, so the
  re-check routes into an else-arm that never calls `did_connect` — the
  only consumer that reclaims the buffer — leaking it whenever the server
  piggybacks frame data on the 101 write. Keep the buffer as an owned
  `Box<[u8]>` and only leak it across FFI inside the success arms, right
  before `did_connect`/`did_connect_with_tunnel`; every other path drops
  it normally.

- ws.js: `makeHandshakeResponse` built `res.headers` with a null
  prototype, but node's `http.IncomingMessage.headers` inherits from
  Object.prototype (so `res.headers.hasOwnProperty(...)` works). Use a
  plain object and `Object.hasOwn` for the header-folding dup-check so a
  header named "constructor" isn't confused with Object.prototype.constructor.
Comment thread src/jsc/bindings/webcore/WebSocket.cpp
Comment thread src/js/thirdparty/ws.js
The `upgrade` event's IncomingMessage body is always built from `null` in
the shim (for a 101 the trailing bytes are the first WebSocket frame, not an
HTTP body, and are delivered via did_connect's overflow handoff). Passing
`remain_buf` to did_receive_handshake_response made C++ allocate a Uint8Array
copy of those bytes that is immediately discarded. Pass an empty slice
instead; the C++ body parameter stays for a future unexpected-response.
Comment thread src/js/thirdparty/ws.js
…Listener

`#onOrOnce` (which wires the native `#ws` listener that drives `this.emit`)
was only reached from `on`/`once`. Subscribing via `addListener`,
`prependListener`, or `prependOnceListener` — all distinct EventEmitter
prototype methods — registered the user listener but never wired the native
listener, so the event (including the new `upgrade`) silently never fired,
unlike node + npm ws.

Extract the native-wiring side effect into `#armNativeBridge(event, once)` and
call it from all subscription entry points: `addListener` (an alias of `on`)
routes through `#onOrOnce`; `prependListener`/`prependOnceListener` arm the
bridge then delegate to the matching `super` method for correct ordering. This
fixes `upgrade` via every subscription method and also closes the same
pre-existing gap for open/close/message/error/ping/pong.

Adds tests covering `upgrade` via addListener/prependListener/prependOnceListener.
@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI update (build #58061, sha 8f8670c): the red buildkite/bun is again only the flaky test/bundler/transpiler/transpiler.test.js on Windows lanes — the windows-2019-x64-test-bun lane passed 7 times then flaked once on retry in this same build, and windows-11-aarch64/2019-x64-baseline show the identical transpiler failure. That test is unrelated to this PR (it was deepened on main in #31382 to address its Windows stack-overflow behavior, after this branch's base).

This PR's diff is limited to the WebSocket client + test/js/first_party/ws/ws-upgrade.test.ts — no bundler/transpiler. All relevant lanes are green, including debian-13-x64-asan-test-bun (which runs this PR's new test under ASAN) plus debian-13-x64-test-bun and ubuntu-25-04-x64-test-bun; cargo clippy, Format, and Lint JavaScript pass.

Not pushing a retrigger — it would only re-roll the same unrelated flaky Windows test without changing this diff's (green) result. This needs a maintainer to merge past the flaky lane.

Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
`did_receive_handshake_response` and `did_connect` each wrap their FFI call in
their own event-loop `enter()/exit()`, and the uWS poll that drives
`handle_data` runs at `entered_event_loop_count == 0`. So the `exit()` after
the `upgrade` dispatch hit count 1 and drained microtasks / `process.nextTick`
before `did_connect` fired `open` — a microtask/nextTick scheduled in the
`upgrade` handler observed CONNECTING (and `ws.send` from it hit
InvalidStateError). Node + ws emit `upgrade` and `open` from the same
socket-data turn with no checkpoint between them.

Bracket the handshake dispatch and the `did_connect`/`did_connect_with_tunnel`
handoff in one outer `EventLoop::enter_scope` guard so the inner pairs nest
(count stays ≥ 1, no drain); the drain happens when the guard drops at function
end, after `open`. The guard holds only the VM-owned loop pointer, so the
trailing derefs that may free `this` stay safe.

Adds a test asserting a queueMicrotask/process.nextTick from the `upgrade`
handler observes OPEN (fails without the scope guard).
Comment thread src/js/thirdparty/ws.js Outdated
robobun added 2 commits May 26, 2026 00:38
Adds a consumer-level test for the microtask-ordering fix: a
process.nextTick(() => ws.send(...)) from the upgrade handler runs after
open (socket OPEN), so the send is delivered rather than hitting
InvalidStateError while still CONNECTING.
…idge

Commit 8f8670c extracted the native-wiring into #armNativeBridge, which is
now the method that calls #ensureHandshakeListener (and prependListener/
prependOnceListener reach it without going through #onOrOnce). Update the
comment accordingly.
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
robobun and others added 2 commits May 26, 2026 01:03
Replace the open-coded unsafe { EventLoop::enter_scope(vm.event_loop()) }
with the VirtualMachine::enter_event_loop_scope() safe wrapper — semantically
identical, drops an unsafe block, and matches the convention used elsewhere
(ServerWebSocket, RequestContext, dns, AbortSignal, ...).

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/thirdparty/ws.js (1)

105-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace Object.hasOwn with intrinsic hasOwnProperty.$call in ws.js header folding

In src/js/thirdparty/ws.js (around Lines 105-110), replace Object.hasOwn(headers, lower) with the repo’s intrinsic-style Object.prototype.hasOwnProperty.$call(headers, lower) to match src/js hardening conventions.

♻️ Proposed fix
-    const seen = Object.hasOwn(headers, lower);
+    const seen = Object.prototype.hasOwnProperty.$call(headers, lower);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/thirdparty/ws.js` around lines 105 - 110, Replace the use of
Object.hasOwn in the header folding loop: instead of calling
Object.hasOwn(headers, lower) update the check to use the repo hardening
intrinsic style by calling Object.prototype.hasOwnProperty.$call(headers, lower)
so that the variables involved (headers, lower, rawHeaders) and the surrounding
loop in ws.js remain unchanged; locate the occurrence inside the block that
defines const headers = (res.headers = {}); and const lower =
rawHeaders[i].toLowerCase(); and replace only the predicate expression to use
hasOwnProperty.$call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/first_party/ws/ws-upgrade.test.ts`:
- Around line 139-142: Replace the disallowed setTimeout call inside the
ws.on("open", ...) handler with a test-safe macrotask barrier by making the
callback async and awaiting Bun.sleep(0) before calling resolve(states as {
microtask: number; nextTick: number }); this keeps the same “next turn” behavior
without using timers; reference the ws.on("open", ...) callback, the resolve
call, and the states variable when making the change.

---

Outside diff comments:
In `@src/js/thirdparty/ws.js`:
- Around line 105-110: Replace the use of Object.hasOwn in the header folding
loop: instead of calling Object.hasOwn(headers, lower) update the check to use
the repo hardening intrinsic style by calling
Object.prototype.hasOwnProperty.$call(headers, lower) so that the variables
involved (headers, lower, rawHeaders) and the surrounding loop in ws.js remain
unchanged; locate the occurrence inside the block that defines const headers =
(res.headers = {}); and const lower = rawHeaders[i].toLowerCase(); and replace
only the predicate expression to use hasOwnProperty.$call.
🪄 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: f36e6ea0-7a13-4ff6-af03-ef7851577404

📥 Commits

Reviewing files that changed from the base of the PR and between 33fb436 and 96c23dc.

📒 Files selected for processing (3)
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/js/thirdparty/ws.js
  • test/js/first_party/ws/ws-upgrade.test.ts

Comment thread test/js/first_party/ws/ws-upgrade.test.ts Outdated
The test suite disallows setTimeout; Bun.sleep(0) gives the same next-turn
checkpoint for asserting the microtask/nextTick ran after open.
@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI update (build #58085, sha 8bead67): same outcome as every prior build on this PR — the only red is the flaky test/bundler/transpiler/transpiler.test.js on the Windows 2019 x64 lanes. windows-2019-x64-test-bun failed once then passed on retry this build; windows-2019-x64-baseline-test-bun passed twice then flaked once — the test oscillates pass/fail and has done so across builds #58036/#58041/#58061/#58076/#58085.

That test is unrelated to this PR: the diff is limited to the WebSocket client (src/http_jsc/websocket_client/, src/jsc/bindings/webcore/WebSocket.*, ws.js, codegen names) + test/js/first_party/ws/ws-upgrade.test.ts — no bundler/transpiler. The lots-of-for-loop fixture it exercises was deepened on main in #31382 (after this branch's base) specifically to address its Windows stack-overflow behavior, so this branch predates that fix.

Every relevant lane is green, including debian-13-x64-asan-test-bun (runs this PR's new test under ASAN) plus the Linux/macOS x64 test lanes; cargo clippy, Format, and Lint JavaScript pass, and CodeRabbit's review is a clean approval (all 4 pre-merge checks pass). All review threads are resolved.

Not pushing a retrigger — it would only re-roll the same oscillating Windows test without changing this diff's (green) result. This needs a maintainer to merge past the flaky lane.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI update (build #58085): investigated the two red lanes; both are unrelated infra/flake, not regressions from this PR.

  1. test/js/web/websocket/autobahn.test.ts on alpine 3.23 x64 — this is NOT a WebSocket conformance failure. The failure is isDockerEnabled() throwing "A functional docker is required in CI" (harness.ts) because the Docker daemon wasn't functional on that runner; the Autobahn suite is describe.skipIf(!isDockerEnabled()) and never ran a single protocol case. Reproduced locally: with Docker down the suite throws under CI and skips cleanly otherwise. To rule out a real regression I ran the non-Docker WebSocket client suites against this branch — all green: websocket-client (29), websocket-client-short-read (1, exercises the overflow/remain_buf path this PR refactored), websocket-close-connecting (4), websocket-accept-header-validation (2), websocket-subprotocol-strict (18), plus this PR's ws-upgrade (8) — all under ASAN.

  2. transpiler.test.js on Windows 2019 x64 — the same recurring flake (passed on retry this build), unrelated to this diff.

My last commit was a test-only setTimeoutBun.sleep(0) change; the native WebSocket code is unchanged since #58061, where neither lane flagged a conformance issue. Re-triggering once since the alpine Docker failure is runner-provisioning infra that a fresh run may land clear.

@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.

All prior review threads are resolved and I found nothing new this pass, but given the cross-FFI surface (Rust ↔ C++ ↔ JS), reentrant JS dispatch during process_response, and the overflow-buffer ownership refactor, this warrants a maintainer's eyes before merge.

Extended reasoning...

Overview

This PR implements the ws client upgrade event by plumbing the parsed 101 handshake response from Rust (WebSocketUpgradeClient::process_response) through a new C++ FFI entry point (WebSocket::didReceiveHandshakeResponse) into a lazily-registered native handshake listener that the JS ws shim folds into an IncomingMessage-shaped object. It touches 8 files across three languages: a new #[repr(C)] header struct + FFI wrapper in CppWebSocket.rs, a new JS-dispatching call site plus an overflow-buffer ownership refactor and an outer event-loop scope guard in WebSocketUpgradeClient.rs, a new MessageEvent dispatch path in WebSocket.cpp/h, a new event name + three builtin identifiers, the ws.js shim's makeHandshakeResponse / #armNativeBridge / addListener/prependListener overrides, and a new 179-line test file.

Security risks

None identified. Header bytes flow from PicoHTTP's parser through borrowed slices into WTF::String copies; there's no shell/SQL/path interpolation. The res.headers object is now a plain {} with Object.hasOwn dup-checks, so a server-supplied header named __proto__/constructor becomes an own data property rather than a prototype write. The new handshake event is internal-only (not on the public WebSocket IDL surface) and gated on hasEventListeners.

Level of scrutiny

High. This is the first place process_response dispatches into JS before did_connect, which makes the previously-defensive !tcp.is_closed() && has_ws else-arms reachable via reentrant ws.close()/terminate(). The PR correctly refactored overflow-buffer ownership to an owned Option<Box<[u8]>> leaked only at the handoff point (fixing a leak I flagged earlier), and bracketed the two dispatches in one enter_event_loop_scope() so microtasks don't drain between upgrade and open (matching Node). Both fixes look correct and are ASAN-verified per the author, but the combination of raw-pointer FFI, reentrancy into cancel(), event-loop nesting semantics, and the _event_loop_scope guard's drop ordering relative to the trailing tcp.close()/terminate() derefs is subtle enough that a maintainer familiar with the WebSocket client lifecycle should sign off.

Other factors

I left seven inline comments across earlier revisions (one 🔴 leak, six 🟡 nits); all were addressed or reasonably declined, and all threads are resolved. CodeRabbit's one suggestion was applied. The new test file covers ordering, custom headers, once/addListener/prependListener paths, and the microtask-ordering invariant. CI is green on the relevant lanes (including ASAN); the only red is an unrelated Windows transpiler flake. Two prior PRs (#25777, #28114) attempted the same feature, so a maintainer may also want to reconcile/close those.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI update (build #58123): the two red lanes are unrelated flake, not regressions from this PR's diff.

This build is a no-op ci: retrigger commit (7455d721) — it changes no source. That same commit passed debian-13-x64-asan-test-bun on earlier runs and failed it once here; identical source producing different results is non-deterministic flake, not a fault in the diff. The ASAN lane that runs this PR's new test is green across those prior runs, and ws-upgrade.test.ts passes 10/10 locally under the ASAN debug build with no sanitizer reports.

  1. debian-13-x64-asan-test-bun — flake on a no-op commit (see above). To be sure this PR doesn't introduce a ws regression, I re-ran the full test/js/first_party/ws/ws.test.ts under ASAN both with this PR's source and with the source reverted to this branch's base: both produce the identical 15 pass / 30 fail. Those 30 failures are pre-existing WebSocket > subprocess-spawn tests timing out at the 1000ms test timeout under the slow debug build — present on base, so not caused by this diff (which doesn't touch ws.test.ts).
  2. alpine-3-dot-23-x64-test-bun — the Docker-dependent lane: test/js/web/websocket/autobahn.test.ts is describe.skipIf(!isDockerEnabled()) and isDockerEnabled() throws "A functional docker is required in CI" when the daemon isn't up on the runner. No protocol case runs; it's infra, not a conformance regression.

This PR's diff is limited to the WebSocket client (src/http_jsc/websocket_client/, src/jsc/bindings/webcore/WebSocket.*, codegen names, src/js/thirdparty/ws.js) + test/js/first_party/ws/ws-upgrade.test.ts. All relevant lanes are green; cargo clippy, Format, Lint JavaScript pass; all review threads are resolved. Needs a maintainer to merge — I've spent my one retrigger and won't re-roll the same unrelated flake.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI update (build #58123, final): every red lane on this commit also has a green pass recorded on the same commit — this is non-deterministic flake, not a regression from this diff. Commit 7455d721 is a no-op ci: retrigger that changes no source.

Per-lane history on 7455d721 (from the commit status timeline):

Lane Passes Fails Note
alpine-3-dot-23-x64-test-bun 19 1 Docker-dependent autobahn.test.ts (skipIf(!isDockerEnabled())) when the daemon isn't up on the runner
darwin-14-aarch64-test-bun 1 2 one "Expired" + one "exit status -1" — lane-level process kills (OOM/timeout), not a test assertion; it passed ("17m 45s") in between
windows-11-aarch64-test-bun 6 1 flaky transpiler.test.js
windows-2019-x64-baseline-test-bun 1 1 same

Identical source producing both passes and fails on the same lanes is flake by definition. None are reproducible failures from this PR.

I also re-verified there's no ws regression: test/js/first_party/ws/ws.test.ts produces the identical 15 pass / 30 fail both with this PR's source and with the source reverted to this branch's base — those 30 are pre-existing WebSocket > subprocess-spawn tests timing out at the 1000ms test timeout under the slow debug build, present on base, and not in this diff. This PR's own new test, ws-upgrade.test.ts, passes 10/10 under the ASAN debug build with no sanitizer reports.

Diff scope: WebSocket client (src/http_jsc/websocket_client/, src/jsc/bindings/webcore/WebSocket.*, codegen names, src/js/thirdparty/ws.js) + test/js/first_party/ws/ws-upgrade.test.ts. cargo clippy, Format, Lint JavaScript pass; all review threads resolved. I've spent my one retrigger and won't re-roll the same unrelated flake — this needs a maintainer to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ws client does not emit upgrade event in Bun 1.3.8

1 participant