Skip to content

Fire close event for server WebSocket close, with allowHalfOpen opt-out.#6197

Open
anonrig wants to merge 1 commit intomainfrom
yagiz/add-websocket-allow-half-open
Open

Fire close event for server WebSocket close, with allowHalfOpen opt-out.#6197
anonrig wants to merge 1 commit intomainfrom
yagiz/add-websocket-allow-half-open

Conversation

@anonrig
Copy link
Member

@anonrig anonrig commented Feb 26, 2026

Per the WebSocket spec, when a server sends a Close frame, the client should send a reciprocal Close frame and then fire a close event with readyState set to CLOSED. Our implementation didn't do this. It left the WebSocket in CLOSING and expected the application to call close() itself, which is a half-open state that no browser implements.

This PR makes the spec-compliant behavior the default behind the web_socket_close_ready_state_closed compat flag. When the flag is enabled (which it will be for new compat dates), receiving a server-initiated Close frame automatically sends the reciprocal close and transitions readyState to CLOSED before firing the close event.

However, this breaks an important use case: WebSocket proxying. If a worker is proxying between a client and a backend, and the backend sends a Close frame, the old behavior let the worker observe the close on the backend side without the runtime automatically tearing down the connection. The worker could then cleanly close the client side at its own pace. With the new behavior, the runtime auto-closes before the worker gets a chance to coordinate.

To address this, I've added an allowHalfOpen property (getter + setter) on WebSocket. When set to true, the old half-open behavior is restored regardless of the compat flag. The default value is derived from the compat flag: true when the flag is off (old behavior), false when the flag is on (spec-compliant). Users who need the proxying pattern can set ws.allowHalfOpen = true after construction.

Some important things that I've considered:

  • allowHalfOpen is NOT a constructor option. The WPT constructor.any.js test verifies that extra arguments beyond (url, protocols) are silently ignored, and adding a third parameter would cause JSG to attempt deserialization of the stray argument.

@anonrig anonrig requested review from a team as code owners February 26, 2026 23:03
@anonrig anonrig force-pushed the yagiz/add-websocket-allow-half-open branch from b0eba29 to b4d604c Compare February 26, 2026 23:08
webSocketCloseReadyStateClosed @166 :Bool
$compatEnableFlag("web_socket_close_ready_state_closed")
$compatDisableFlag("no_web_socket_close_ready_state_closed")
$compatEnableDate("2026-03-17");
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not yet convinced this should be enabled by default, at least not yet. I think we need to evaluate if the standard behavior is actually the desirable behavior for us, or is the proxy-friend approach better. I'd say for now, let's make this opt-in only but I can be convinced. @kentonv thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the thing that convinces me that not allowing half-open should be the default behaviour is that it's how I think most people expect the API to behave (as evidenced by more than one user being confused by this and reporting it to us).

If we had named (or even documented) WebSocket's send, close, addEventListener methods or at least the close event differently to the web standard then I think you could make an argument, but we didn't.

@codecov-commenter
Copy link

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.52%. Comparing base (010afa1) to head (b4d604c).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6197      +/-   ##
==========================================
- Coverage   70.59%   70.52%   -0.08%     
==========================================
  Files         413      413              
  Lines      109972   109994      +22     
  Branches    18121    18122       +1     
==========================================
- Hits        77635    77572      -63     
- Misses      21531    21624      +93     
+ Partials    10806    10798       -8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

# bindings to specify a requested version. The behaviour of this flag will change in the future.

webSocketCloseReadyStateClosed @166 :Bool
$compatEnableFlag("web_socket_close_ready_state_closed")
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm I think it might be slightly nicer to match the naming of the compat flag to the "half open" terminology but I guess it's tricky to name because the flag will set the default to "not half open by default". Flipping the convention and calling the enable flag "no_web_socket_half_open_by_default" but that might just be more confusing and there's not really a precedent for it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah I'm oopen to suggestions for both enable and disable flags. no_web_socket_half_open_by_default and web_socket_half_open_by_default looks good to me. I'll wait for consensus before making this change.

// web_socket_close_ready_state_closed compat flag), automatically send a reciprocal
// Close frame through the outgoing message pump so that readyState is CLOSED (3)
// when the close event fires.
auto pendingAutoResponses = autoResponseStatus.pendingAutoResponseDeque.size() -
Copy link
Contributor

Choose a reason for hiding this comment

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

[nit] De-dupe the pending auto response logic. Looks like we always enqueue them whenever we insert into outgoingMessages and we now have 3 call sites duplicating this uninteresting logic.

autoResponseStatus.queuedAutoResponses =
autoResponseStatus.pendingAutoResponseDeque.size();

outgoingMessages->insert(
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if close was previously called on this websocket? I see there is a check in close for native.closedOutgoing to avoid double-sending the close message. Is that not needed here too? What about the checks for native.outgoingAborted and native.state.is<Released> too? Do they not apply/are those states not supposed to be possible here?

JSG_READONLY_PROTOTYPE_PROPERTY(url, getUrl);
JSG_READONLY_PROTOTYPE_PROPERTY(protocol, getProtocol);
JSG_READONLY_PROTOTYPE_PROPERTY(extensions, getExtensions);
JSG_PROTOTYPE_PROPERTY(allowHalfOpen, getAllowHalfOpen, setAllowHalfOpen);
Copy link
Contributor

Choose a reason for hiding this comment

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

When writing the public docs for this, I think we should advise users to set allowHalfOpen to whatever value they desire synchronously before calling accept to avoid a possible race condition where the other end sends a close frame before the setting has taken effect. In practice, the race condition can only occur if they set it asynchronously after calling accept and also after the other end becomes capable of sending a close frame (e.g. handing off over a network boundary).

@jasnell
Copy link
Collaborator

jasnell commented Feb 27, 2026

Overall I'm fine with this approach. I'll do a final review pass once @jp4a50's reviews are addressed but currently not seeing anything else here that would be blocking.

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.

4 participants