Fix WebSocket polyfill throwing on close()/send() when CLOSING or CLOSED#160
Merged
bghgary merged 2 commits intoBabylonJS:mainfrom Apr 16, 2026
Merged
Conversation
Previously, calling close() on a WebSocket that was already CLOSING or CLOSED would throw 'Close has already been called.' This violates the WHATWG WebSocket specification, which requires close() to be a no-op in those states. The bug can surface as an uncaught async exception when a delegate close/error callback fires after user code has already called close(), or when user code calls close() twice in rapid succession (for example, from both a catch block and an onmessage handler). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adjusts the WebSocket polyfill’s close() behavior to match the WHATWG spec by making repeated calls in CLOSING/CLOSED states a silent no-op instead of throwing.
Changes:
- Make
WebSocket::Close()return early (no-op) whenreadyStateisClosingorClosed. - Remove the synchronous JS exception previously thrown on repeated
close()calls. - Add an in-code comment documenting the spec-aligned behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Per the WHATWG WebSocket spec, send() throws InvalidStateError only when readyState is CONNECTING. When CLOSING or CLOSED, the call silently discards the data instead of throwing. The previous behavior of throwing on any non-OPEN state was too strict and could surface as an uncaught exception when a send raced with a close (e.g. a scheduled send issued between close() being called and the onclose callback firing). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ryantrem
approved these changes
Apr 16, 2026
bghgary
added a commit
to bghgary/JsRuntimeHost
that referenced
this pull request
Apr 17, 2026
…S#131 DO NOT MERGE. Expands the test to 20 iterations so CI catches the ~1/3 flake rate reported in issue BabylonJS#131. If CI passes, our fixes (BabylonJS#150, BabylonJS#160) resolved the issue and we can re-enable the single test in a follow-up. If CI fails, flake persists and needs more investigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bghgary
added a commit
to bghgary/JsRuntimeHost
that referenced
this pull request
Apr 17, 2026
The flake reported in BabylonJS#131 was resolved by BabylonJS#150 (UrlLib onError/onClose consolidation) and BabylonJS#160 (spec-compliant close/send). Verified via repro PR BabylonJS#161 which ran 20 iterations of this test across all platform/engine combos with zero failures. Fixes BabylonJS#131. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Apr 17, 2026
bghgary
added a commit
that referenced
this pull request
Apr 17, 2026
[Created by Copilot on behalf of @bghgary] Re-enable the "should trigger error callback with invalid server" WebSocket test that was commented out in #130 due to the flake reported in #131. ## Why this is safe to re-enable Two WebSocket-related fixes landed on `main` since the flake was observed: - **#150** — pulled in UrlLib BabylonJS/UrlLib#26, which consolidates `onError`/`onClose` dispatch on Windows and Apple. Before this, the Windows and Apple implementations only fired `onError` on connection failures without a matching `onClose`, leaving consumers (and this test) in inconsistent terminal-event states. - **#160** — made the JS-layer `close()` idempotent and `send()` spec-compliant when CLOSING/CLOSED, fixing cross-state dispatch bugs. ## Verification Repro PR #161 expanded this test to 20 sequential iterations on the same branch to exercise the historical 1/3 flake rate. If the flake persisted, P(all 20 pass) ≈ 0.03%. CI on #161 ran across every platform/engine combo — Chakra/V8/JSI on UWP/Win32, Linux (gcc, clang, sanitizers, TSan), macOS (Xcode 16.4, sanitizers, TSan), iOS (Xcode 15.2, 16.4) — and **all 20 iterations passed on every job** ([workflow run 24587856930](https://github.com/BabylonJS/JsRuntimeHost/actions/runs/24587856930)). This PR restores the original single test (no loop, default mocha timeout) as it was before #130. Fixes #131. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[Created by Copilot on behalf of @bghgary]
Problem
The WebSocket polyfill throws JavaScript errors in two places where the WHATWG WebSocket specification requires different behavior:
close()throws when already closing/closedWebSocket::ClosethrowsError: Close has already been called.when invoked on a socket whosereadyStateis alreadyCLOSINGorCLOSED. Per the spec,close()in those states must be a silent no-op.send()throws when closing/closedWebSocket::SendthrowsError: Websocket readyState is not open.on any non-OPEN state. Per the spec,send()should only throwInvalidStateErrorwhenreadyStateisCONNECTING. WhenCLOSINGorCLOSED, the data is silently discarded (the spec also bumpsbufferedAmount, which this polyfill does not track).Because the throws are synchronous from a JS call, a delayed or racing call from an async callback surfaces as an uncaught exception that terminates the JS runtime.
Repro path
Observed on the macOS_Xcode164_Sanitizers leg of #148. Sequence (from the existing multi-WebSocket test in
Tests/UnitTests/Scripts/tests.ts):ws.close()on an open socket.readyState->Closingand issues the close.onclosecallback marshals to the JS thread, another path (a pendingonmessagehandler, or acatchblock on a failedsend) callsws.close()again.close()findsreadyState == Closingand throws, producing:The
send()path is symmetric: a send scheduled betweenclose()being called and theonclosecallback firing would throw and escape uncaught.Fix
close()Replace the
throwwith an earlyreturn. No other state is touched, so the firstclose()still drives the transition toCLOSEDvia the normal callback path.send()Split the single non-OPEN throw into two cases: throw on
CONNECTING, return silently onCLOSING/CLOSED.Scope
Polyfills/WebSocket/Source/WebSocket.cpp.bufferedAmounttracking during the CLOSING/CLOSED send path is still not implemented; out of scope for this fix.