fix(partysocket): reliable buffered messages and close events across socket replacement#403
Merged
threepointone merged 3 commits intoJun 12, 2026
Conversation
…socket replacement Messages passed to send() while a socket isn't open are buffered in an internal queue. When the React hooks replace the socket because connection options changed (e.g. an auth token refresh), that buffer was silently lost with the old instance — and because the old socket's close event was dispatched asynchronously, consumers that detached their listeners during the swap never observed a terminal close either. Both failure modes strand callers waiting on a reply forever (see cloudflare/agents#1738). ReconnectingWebSocket: - close() now dispatches its close event synchronously (mirroring the synthetic close reconnect() already dispatched) and detaches the inner socket's listeners so the real close event isn't delivered twice. After close() returns, readyState reports CLOSED immediately. Re-entrant close() inside a close listener is a no-op, and reconnect() right after close() no longer dispatches a duplicate close. - send() returns a boolean: true if transmitted immediately over an open connection, false if buffered or dropped (maxEnqueuedMessages). Callers implementing request/response protocols can use this to know whether a request is actually in flight. - New drainQueuedMessages() removes and returns the unsent buffer so a discarded socket can hand it to a replacement. - send() after close() warns once per close cycle: the message is buffered but nothing will ever flush it unless reconnect() is called, which usually indicates a stale socket reference in the caller. - Inner sockets we've detached from get a no-op error listener so an aborted handshake (close while CONNECTING) doesn't surface as an unhandled error. React hooks (usePartySocket / useWebSocket): - useStableSocket now migrates the old socket's unsent buffer when it replaces the socket. By default messages transfer only when the destination is unchanged (only credentials like query changed); if destination options (room, party, path, host, URL, ...) changed, the messages are discarded with a warning rather than delivered to a destination they weren't composed for. The new transferEnqueuedMessages option forces either behavior (true = always, false = never). Tests cover the new send() return value, drainQueuedMessages(), synchronous close (open and CONNECTING, re-entrancy, close-then- reconnect dedup), the send-after-close warning, and wire-level hook tests for transfer on credential change, order preservation, drop on destination change, and both transferEnqueuedMessages overrides. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 4e99943 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
4 tasks
hono-party
partyfn
partyserver
partysocket
partysub
partysync
partytracks
partywhen
y-partyserver
commit: |
Merged
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.
Summary
Two long-standing failure modes strand
partysocketconsumers waiting on replies forever (surfaced via cloudflare/agents#1738, whereuseAgentRPC calls hung indefinitely):send()while a socket isn't open are silently lost when the React hooks replace the socket because connection options changed (e.g. an auth token refresh).closeevent is dispatched asynchronously, so consumers that detach their listeners during the swap never observe a terminal close — "connection closed" cleanup never runs.ReconnectingWebSocketclose()dispatches its close event synchronously (mirroring the synthetic closereconnect()already dispatched) and detaches the inner socket's listeners so the real close isn't delivered twice. Afterclose()returns,readyStatereportsCLOSEDimmediately. Re-entrantclose()inside a close listener is a no-op;reconnect()right afterclose()doesn't dispatch a duplicate close.close()and then attached a close listener relied on the event arriving asynchronously — attach the listener first.send()returns aboolean:trueif transmitted immediately over an open connection,falseif buffered (always flushed before theopenevent) or dropped (maxEnqueuedMessages). Lets request/response protocols know whether a request is actually in flight.drainQueuedMessages()removes and returns the unsent buffer so a discarded socket can hand it to a replacement.send()afterclose()warns once per close cycle — the message is buffered but nothing will ever flush it unlessreconnect()is called; this usually indicates a stale socket reference in the caller.CONNECTING) doesn't surface as an unhandled error.React hooks (
usePartySocket/useWebSocket)useStableSocketnow migrates the old socket's unsent buffer when it replaces the socket. By default messages transfer only when the destination is unchanged (only credentials likequerychanged). If destination options (room,party,path,host, URL, ...) changed, the messages are discarded with a warning rather than delivered to a destination they weren't composed for — a buffered message for room A must not follow the socket to room B.transferEnqueuedMessagesoption overrides the heuristic:truealways transfers,falsenever does.Test plan
ReconnectingWebSocketunit tests:send()return value,drainQueuedMessages(), synchronous close (while OPEN and while CONNECTING), re-entrant close, close-then-reconnect dedup, send-after-close warning + reset onreconnect()transferEnqueuedMessagesoverridespartysocketsuite passes (322 tests, 13 files)cloudflare/agentsreact test suite (96 tests, including its new RPC-robustness suite) passes against this build of partysocketChangeset included (
partysocketminor). Companion PR on the agents side: cloudflare/agents fix for #1738.Made with Cursor