Skip to content

Fix WebSocket polyfill throwing on close()/send() when CLOSING or CLOSED#160

Merged
bghgary merged 2 commits intoBabylonJS:mainfrom
bghgary:fix/websocket-close-idempotent
Apr 16, 2026
Merged

Fix WebSocket polyfill throwing on close()/send() when CLOSING or CLOSED#160
bghgary merged 2 commits intoBabylonJS:mainfrom
bghgary:fix/websocket-close-idempotent

Conversation

@bghgary
Copy link
Copy Markdown
Contributor

@bghgary bghgary commented Apr 16, 2026

[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/closed

WebSocket::Close throws Error: Close has already been called. when invoked on a socket whose readyState is already CLOSING or CLOSED. Per the spec, close() in those states must be a silent no-op.

send() throws when closing/closed

WebSocket::Send throws Error: Websocket readyState is not open. on any non-OPEN state. Per the spec, send() should only throw InvalidStateError when readyState is CONNECTING. When CLOSING or CLOSED, the data is silently discarded (the spec also bumps bufferedAmount, 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):

  1. Test calls ws.close() on an open socket.
  2. The native side transitions readyState -> Closing and issues the close.
  3. Before the onclose callback marshals to the JS thread, another path (a pending onmessage handler, or a catch block on a failed send) calls ws.close() again.
  4. The second close() finds readyState == Closing and throws, producing:
[Uncaught Error] close@[native code]
@app:///Scripts/tests.js:27799:22

The send() path is symmetric: a send scheduled between close() being called and the onclose callback firing would throw and escape uncaught.

Fix

close()

Replace the throw with an early return. No other state is touched, so the first close() still drives the transition to CLOSED via the normal callback path.

send()

Split the single non-OPEN throw into two cases: throw on CONNECTING, return silently on CLOSING/CLOSED.

Scope

  • Pure behavioral fix in the WebSocket polyfill. No changes outside Polyfills/WebSocket/Source/WebSocket.cpp.
  • No test changes — existing WebSocket tests already exercise these paths and will stop intermittently failing once this lands.
  • bufferedAmount tracking during the CLOSING/CLOSED send path is still not implemented; out of scope for this fix.

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>
Copilot AI review requested due to automatic review settings April 16, 2026 20:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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) when readyState is Closing or Closed.
  • 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>
@bghgary bghgary changed the title Make WebSocket.close() idempotent per WHATWG spec Make WebSocket.close() and send() spec-compliant Apr 16, 2026
@bghgary bghgary changed the title Make WebSocket.close() and send() spec-compliant Fix WebSocket polyfill throwing on close()/send() when CLOSING or CLOSED Apr 16, 2026
@bghgary bghgary merged commit 8e46916 into BabylonJS:main Apr 16, 2026
19 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants