Skip to content

fix: sync presence state when a track enables presence - #2197

Open
hsusul wants to merge 1 commit into
supabase:mainfrom
hsusul:fix/sync-presence-on-auto-enable
Open

hsusul wants to merge 1 commit into
supabase:mainfrom
hsusul:fix/sync-presence-on-auto-enable

Conversation

@hsusul

@hsusul hsusul commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #2196

What

RealtimeChannel.join/3 sends :sync_presence only when presence is already enabled at join:

# Start presence and add user if presence is enabled
if presence_enabled?, do: send(self(), :sync_presence)

Nothing sends it when PresenceHandler.track/2 later flips presence_enabled? to true, even though enabling presence with a track message is a supported path (#1527). Such a client never receives presence_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 with config.presence.enabled: true.

It is the default path rather than an edge case: tenants.presence_enabled defaults to false, so any client that calls track() 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:

if !socket.assigns.presence_enabled?, do: send(self(), :sync_presence)

Reusing the existing :sync_presence handler rather than pushing directly keeps both existing gates: PresenceHandler.sync/1 still drops the state for a private socket without presence.read, and still goes through limit_presence_event/1. Because the message is handled after the current handle_in returns, the snapshot is taken after Presence.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:

  • New — "presence automatically enabled by track receives the members already tracked": an early member joins with presence enabled and tracks; a late member joins with presence disabled, gets no presence_state, then tracks — and must now receive a presence_state containing the early member.
  • Updated — "presence_diff gate holds when presence is auto-enabled via track" ends in a catch-all refute_receive _any, which the new presence_state now 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 denied presence.read must 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:

Assertion failed, no matching message after 500ms
code: assert_receive {:late, %Message{event: "presence_state", payload: state, topic: ^topic}}

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 passed
  • mix 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 clean

Not run locally: the full 4-partition × 5-postgres matrix — left to CI.

`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
# 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

Enabling presence with a track message never sends presence_state, so already-tracked members stay invisible

2 participants