Skip to content

fix(partysocket): reliable buffered messages and close events across socket replacement#403

Merged
threepointone merged 3 commits into
mainfrom
fix/partysocket-buffered-messages-and-sync-close
Jun 12, 2026
Merged

fix(partysocket): reliable buffered messages and close events across socket replacement#403
threepointone merged 3 commits into
mainfrom
fix/partysocket-buffered-messages-and-sync-close

Conversation

@threepointone

Copy link
Copy Markdown
Collaborator

Summary

Two long-standing failure modes strand partysocket consumers waiting on replies forever (surfaced via cloudflare/agents#1738, where useAgent RPC calls hung indefinitely):

  1. Messages buffered by 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).
  2. The replaced socket's close event is dispatched asynchronously, so consumers that detach their listeners during the swap never observe a terminal close — "connection closed" cleanup never runs.

ReconnectingWebSocket

  • close() dispatches its close event synchronously (mirroring the synthetic close reconnect() already dispatched) and detaches the inner socket's listeners so the real close isn't delivered twice. After close() returns, readyState reports CLOSED immediately. Re-entrant close() inside a close listener is a no-op; reconnect() right after close() doesn't dispatch a duplicate close.
    • ⚠️ Migration note: code that called close() and then attached a close listener relied on the event arriving asynchronously — attach the listener first.
  • send() returns a boolean: true if transmitted immediately over an open connection, false if buffered (always flushed before the open event) or dropped (maxEnqueuedMessages). Lets request/response protocols 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; this usually indicates a stale socket reference in the caller.
  • Detached inner sockets 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 — a buffered message for room A must not follow the socket to room B.
  • New transferEnqueuedMessages option overrides the heuristic: true always transfers, false never does.

Test plan

  • New ReconnectingWebSocket unit tests: send() return value, drainQueuedMessages(), synchronous close (while OPEN and while CONNECTING), re-entrant close, close-then-reconnect dedup, send-after-close warning + reset on reconnect()
  • New wire-level hook tests: buffered messages transfer on credential change, arrive in order, drop with warning on destination change, both transferEnqueuedMessages overrides
  • Three legacy lifecycle tests updated to the new documented close semantics; full partysocket suite passes (322 tests, 13 files)
  • All other workspace packages' suites pass (partyserver, partysub, partywhen, partytracks, y-partyserver); format/lint/typecheck clean
  • Cross-repo smoke test: the cloudflare/agents react test suite (96 tests, including its new RPC-robustness suite) passes against this build of partysocket

Changeset included (partysocket minor). Companion PR on the agents side: cloudflare/agents fix for #1738.

Made with Cursor

…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-bot

changeset-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4e99943

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
partysocket Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

hono-party

npm i https://pkg.pr.new/cloudflare/partykit/hono-party@403

partyfn

npm i https://pkg.pr.new/cloudflare/partykit/partyfn@403

partyserver

npm i https://pkg.pr.new/cloudflare/partykit/partyserver@403

partysocket

npm i https://pkg.pr.new/cloudflare/partykit/partysocket@403

partysub

npm i https://pkg.pr.new/cloudflare/partykit/partysub@403

partysync

npm i https://pkg.pr.new/cloudflare/partykit/partysync@403

partytracks

npm i https://pkg.pr.new/cloudflare/partykit/partytracks@403

partywhen

npm i https://pkg.pr.new/cloudflare/partykit/partywhen@403

y-partyserver

npm i https://pkg.pr.new/cloudflare/partykit/y-partyserver@403

commit: 4e99943

@threepointone threepointone merged commit 5e6261c into main Jun 12, 2026
6 checks passed
@threepointone threepointone deleted the fix/partysocket-buffered-messages-and-sync-close branch June 12, 2026 11:23
@github-actions github-actions Bot mentioned this pull request Jun 12, 2026
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.

1 participant