ws: support upgrade and unexpected-response events#28114
Conversation
|
Updated 2:07 PM PT - May 30th, 2026
❌ @robobun, your commit 0b89041 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 28114That installs a local version of the PR into your bun-28114 --bun |
|
Found 8 issues this PR may fix:
🤖 Generated with Claude Code |
4ea3e91 to
7d24274
Compare
|
@robobun adopt |
|
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 Darwin situation (not my code):
Nothing to push. @alii, the PR is ready for review — the webview darwin failure is infra/flake that predates this PR. |
|
@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. |
10e1c03 to
82ad796
Compare
cfa87a6 to
0f36bd4
Compare
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds native handshake handling and JS plumbing: new C++ span-based Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
src/bun.js/bindings/webcore/WebSocket.cppsrc/bun.js/bindings/webcore/WebSocket.hsrc/http/websocket_client/CppWebSocket.zigsrc/http/websocket_client/WebSocketUpgradeClient.zigsrc/js/thirdparty/ws.jstest/regression/issue/05951.test.tstest/regression/issue/24229.test.ts
|
Rebased onto Fix summary:
Validation: regression tests in CI status: the diff is green. The only red lane ( |
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.
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
src/bun.js/bindings/webcore/WebSocket.cppsrc/js/builtins/BunBuiltinNames.hsrc/js/thirdparty/ws.jstest/regression/issue/05951.test.tstest/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.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/js/thirdparty/ws.js (1)
318-323:⚠️ Potential issue | 🟠 MajorKeep 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 toerror, a 503 never reaches#onHandshake()and Bun falls back to the native"Expected 101 status code"error instead of ws'sUnexpected server response: 503. That is still a compatibility gap, and05951.test.tsis 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
📒 Files selected for processing (5)
src/bun.js/bindings/webcore/WebSocket.cppsrc/http/websocket_client/CppWebSocket.zigsrc/http/websocket_client/WebSocketUpgradeClient.zigsrc/js/thirdparty/ws.jstest/regression/issue/05951.test.ts
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
wrappedreference is discarded immediately. ThenremoveEventListener(lines 608-610) is unchanged from before the PR:removeEventListener(type, listener) { this.#ws.removeEventListener(type, listener); }
Two compounding failures
-
Wrong target: 'upgrade'/'unexpected-response' listeners are now stored on the JS-side BunWebSocket EventEmitter (
this) viasuper.on/super.once, butremoveEventListenerdelegates tothis.#ws.removeEventListener— the native browser-style WebSocket object. The native WebSocket never had those listeners; the removal silently no-ops. -
Lost wrapper reference: Even if the target were corrected to
this(the EventEmitter), thewrappedarrow function that was actually registered is gone. The EventEmitter internal list holdswrapped, but the caller passes the originallistenertoremoveEventListener. There is no way to match them without a stored map.
Before this PR:
addEventListener('upgrade', cb)forwarded tothis.#ws.addEventListenerandremoveEventListenerforwarded tothis.#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
ws.addEventListener('upgrade', handler)is called.#ensureHandshakeListener()arms the native 'handshake' driver.wrapped = (...args) => handler({ type: 'upgrade', target: ws, data: args })is created and registered viasuper.on('upgrade', wrapped)on the BunWebSocket EventEmitter.wrappedgoes out of scope; the only reference to it is inside the EventEmitter internal listener list.ws.removeEventListener('upgrade', handler)is called.- Control reaches
this.#ws.removeEventListener('upgrade', handler)— targeting the native WebSocket. - The native WebSocket has no 'upgrade' listener; removal silently no-ops.
- The EventEmitter still holds
wrapped. Every future 'handshake' event continues to callhandler. The listener is permanently leaked.
Impact
Any code that uses the DOM-style
addEventListener/removeEventListenerpair 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 throughthis.#ws.Fix
Store a
WeakMap(keyed by original listener) on the instance. InaddEventListener, recordwrappedMap.set(listener, wrapped)before registering. InremoveEventListener, detect 'upgrade'/'unexpected-response' and callsuper.off(type, wrappedMap.get(listener))then delete the entry, instead of delegating tothis.#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
- ws = new WebSocket('ws://...'); ws.prependListener('open', () => console.log('opened'));
- prependListener('open', cb) checks: event === 'upgrade'? No. Calls super.prependListener('open', cb) — cb is in the EventEmitter list.
- #onOrOnce is never called; this.#eventId bit for 'open' remains 0; no this.#ws.addEventListener('open', ...) is installed.
- 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.
- 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.
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.
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.
…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.
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.
…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.
… 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.
…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.
…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'.
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.
… 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).
|
This also resolves #31792 ( 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 |
Adds
'upgrade'and'unexpected-response'events to thewspackage 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 —
dispatchFetchresolves a promise exclusively from these two events.Fixes #5951
Fixes #24229