Repository navigation
fix(realtime): don't take channel messages for heartbeat replies - #1434
Merged
Merged
Conversation
sproctor
force-pushed
the
realtime-heartbeat-ref
branch
from
October 23, 2026 02:42
5bc50b5 to
a055047
Compare
heartbeatRef used 0 for "no heartbeat pending", but refs start at 0. A message carrying ref 0 while no heartbeat was pending, such as the server's phx_close or phx_error for a socket's first join, matched it and was dropped as a heartbeat reply. And a heartbeat that got ref 0 counted as not pending, so a missing reply to it went unnoticed for an extra interval. Use -1 instead. disconnect() also left a pending heartbeat set, so the first tick on the next socket reported a heartbeat timeout and reconnected again. Clear it. Builds on the realtime channel error fix: once a first join's close is no longer swallowed, a rejoin relies on its check for an earlier join_ref. The rejoin tests now send the server's real ref (the join_ref), which they couldn't while ref 0 was swallowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sproctor
force-pushed
the
realtime-heartbeat-ref
branch
from
October 23, 2026 02:53
a055047 to
d388d12
Compare
Collaborator
|
@sproctor Might need to resolve the conflict |
sproctor
marked this pull request as draft
October 27, 2026 13:31
Contributor
Author
|
Thanks. Yeah, this conflicted with #1423. I'll clean it up. |
Brings in supabase#1433, which this branch was stacked on, and supabase#1423, which also resets the heartbeat state, on connect. Conflict and resolution: - supabase#1423's reset on connect stored 0, which on this branch is a real ref, so every new socket started with heartbeat 0 pending and its first tick timed out. It now stores NO_HEARTBEAT, and this branch's reset in disconnect() is dropped as redundant: every socket goes through connect(). - Both sides added RealtimeHeartbeatTest; the tests are combined, this branch's reading sent messages with Turbine as ScriptedWebsocket now does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
onMessage took any message whose ref matched the pending heartbeat for its reply, whatever its topic, so the NO_HEARTBEAT sentinel was all that kept channel messages out. Match the "phoenix" topic too. sendHeartbeat also read the ref back from heartbeatRef to send it, which a reset on connect could change in between; send the ref it made. The lost-socket test from supabase#1423 now uses ScriptedWebsocket (with a new lose()) instead of its own socket, answers the new socket's heartbeat and checks that the next one goes out on the same socket. The test for a heartbeat pending at disconnect() is dropped: a lost socket goes through disconnect() and connect() too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sproctor
marked this pull request as ready for review
October 27, 2026 13:50
jan-tennert
approved these changes
Oct 27, 2026
Collaborator
|
Thanks, I think the tests broke too |
The server sent the close right after "Subscribed", so the channel could go SUBSCRIBED -> UNSUBSCRIBED before subscribe(blockUntilSubscribed = true) started waiting, and the wait never ended. Script the server with ScriptedWebsocket and send the close only once SUBSCRIBED is seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Thanks. It was a flaky test. It passed here. |
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.
Builds on #1433, now merged into master; master is merged in here.
What changed
0could be taken for a heartbeat reply.heartbeatRefused0to mean "no heartbeat pending", but refs start at0on every socket. While no heartbeat was pending, any message carrying ref0matched and was dropped as a heartbeat reply. The server'sphx_closeorphx_errorfor a socket's first join is one: it carries that join'sjoin_ref,0, as its ref. And a heartbeat that itself got ref0(the first message on a socket with no channels) counted as not pending, so a missing reply to it went unnoticed for an extra interval. The sentinel is now-1(NO_HEARTBEAT).phoenixtopic are taken for heartbeat replies. Heartbeats are sent on that topic, so a channel message can no longer match the pending heartbeat, whatever its ref.sendHeartbeat()sends the ref it made. It read the ref back fromheartbeatRefto send it, and a reset on connect in between could change it.#1423 (merged into master after this was opened) resets the heartbeat state when a socket connects. It stored
0, which with this change is a real ref: every new socket would start with heartbeat0pending and time out on its first tick. The merge makes it storeNO_HEARTBEAT. This branch's own reset indisconnect()is dropped, since every socket goes throughconnect().Why it builds on #1433
Swallowing the first join's close hid a race: when a channel rejoins (enabling presence on a subscribed channel, or after a channel error), the server's close for the join it replaced can arrive afterwards, and
RCloseEventremoves the channel. #1433 drops messages for an earlierjoin_ref, as Phoenix's client does. Without it, this fix alone would let a rejoin lose the socket's first channel, which master protected only by accident.Testing
RealtimeHeartbeatTest, on the test clock:0reaches its channel;0) times out on the next tick;ScriptedWebsocket(with a newlose()) instead of its own socket, and also answers the new socket's heartbeat and checks that the next one goes out on the same socket.0, the two heartbeat tests fail. With a topic other thanphoenixmatched, the lost-socket test fails. The ref-0close test fails on master, where both the topic check and the sentinel are missing.join_ref) for its errors and closes, which they couldn't while ref0was swallowed../gradlew :realtime-kt:jvmTest :realtime-kt:detektpass.🤖 Generated with Claude Code