Conversation
`join/3` sends `:sync_presence` only when presence was already enabled at join, so a client that enables presence later by sending a track message never received `presence_state`. It saw only the diffs that followed, and every member already tracked on the topic stayed invisible to it. This is the default path: `tenants.presence_enabled` defaults to false, so any client calling track() without `config.presence.enabled` lands here. Sync when the track is what flips `presence_enabled?`, reusing the existing `:sync_presence` handler so the presence.read gate and the presence event rate limit still apply. Fixes supabase#2196
edgurgel
reviewed
Sep 11, 2026
| # This track is what enables presence for the socket, so it never got the join-time | ||
| # presence_state and only sees diffs from here on. Sync now, or the members tracked | ||
| # before this point stay invisible to this client. | ||
| if !socket.assigns.presence_enabled?, do: send(self(), :sync_presence) |
Member
There was a problem hiding this comment.
Can we do this from RealtimeChannel instead? Because this is a message that RealtimeChannel processes and PresenceHandler feels like the wrong place to assume what process is running this.
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.
Fixes #2196
What
RealtimeChannel.join/3sends:sync_presenceonly when presence is already enabled at join:Nothing sends it when
PresenceHandler.track/2later flipspresence_enabled?totrue, even though enabling presence with a track message is a supported path (#1527). Such a client never receivespresence_state— only the diffs that follow — so every member already tracked on the topic stays invisible to it, with no way to recover short of re-joining withconfig.presence.enabled: true.It is the default path rather than an edge case:
tenants.presence_enableddefaults tofalse, so any client that callstrack()without explicit join-time presence config lands here. The result is order-dependent and silent — the first client into a topic sees everyone, every later client sees only itself plus subsequent arrivals.Fix
Sync when the track is what enables presence:
Reusing the existing
:sync_presencehandler rather than pushing directly keeps both existing gates:PresenceHandler.sync/1still drops the state for a private socket withoutpresence.read, and still goes throughlimit_presence_event/1. Because the message is handled after the currenthandle_inreturns, the snapshot is taken afterPresence.track/4, so it includes the tracking member itself and cannot race ahead of its own join diff.Tests
test/integration/rt_channel/presence_test.exs:presence_state, then tracks — and must now receive apresence_statecontaining the early member.refute_receive _any, which the newpresence_statenow trips for the authorized member. I made the new behavior explicit rather than widening the refute, and added the security-relevant half that was implicit before: the member deniedpresence.readmust receive neither the diff nor the state its own track syncs.Both fail on
main@ee315cae(4 failures across the two serializer parameterizations), for the intended reason:Green with the fix.
Validation
Run locally on Elixir 1.20.4 / OTP 29 against
supabase/postgres:15.14.1.167(the repo pins 1.19.5 / OTP 28.5.0.4 — CI will confirm on the pinned toolchain and the full postgres matrix):mix test test/integration/rt_channel/presence_test.exs test/realtime_web/channels/realtime_channel/presence_handler_test.exs— 86 passed (both serializers)mix test test/integration/rt_channel test/realtime_web/channels— 595 passedmix compile --force --warnings-as-errors— no warnings from the changed files (the checkout emits a few pre-existing warnings under 1.20 that do not appear on the pinned 1.19)mix format --check-formatted,mix credo(no issues),mix deps.unlock --check-unused,mix sobelow,mix dialyzer(passed successfully),git diff --check— all cleanNot run locally: the full 4-partition × 5-postgres matrix — left to CI.