Fire close event for server WebSocket close, with allowHalfOpen opt-out.#6197
Fire close event for server WebSocket close, with allowHalfOpen opt-out.#6197
Conversation
b0eba29 to
b4d604c
Compare
| webSocketCloseReadyStateClosed @166 :Bool | ||
| $compatEnableFlag("web_socket_close_ready_state_closed") | ||
| $compatDisableFlag("no_web_socket_close_ready_state_closed") | ||
| $compatEnableDate("2026-03-17"); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
| # 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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() - |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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).
|
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. |
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: