Skip to content

fix(realtime): return the existing channel for a topic from channel() - #1673

Open
breken-ai wants to merge 1 commit into
supabase:mainfrom
breken-ai:fix/realtime-reuse-channel-for-topic
Open

breken-ai wants to merge 1 commit into
supabase:mainfrom
breken-ai:fix/realtime-reuse-channel-for-topic

Conversation

@breken-ai

Copy link
Copy Markdown

What kind of change does this PR introduce?

Bug fix (realtime).

What is the current behavior?

AsyncRealtimeClient.channel(topic) always creates a new AsyncRealtimeChannel and stores it with self.channels[topic] = chan. If a channel for that topic already exists, the new one replaces it.

_listen routes every incoming message by topic (self.channels.get(message.topic)). So after a second channel("room") call, broadcasts, presence and postgres changes for realtime:room go to the new channel, which is not subscribed and has no callbacks. The first channel is still joined on the server, but its callbacks never fire again, and nothing reports an error. Its later leave reply is routed to the new channel too, so the first channel also stays in LEAVING.

Code ported from supabase-js is where this shows up. There, supabase.channel('room') returns the existing channel for the topic, so calling it again, for example to send_broadcast from a handler, is a common pattern.

Reproduction on main, with the socket's websocket mocked and a real broadcast frame fed through _listen:

channel = socket.channel("room").on_broadcast("ping", lambda p: received.append(p["payload"]))
await channel.subscribe()
socket.channel("room")
# server sends {"topic": "realtime:room", "event": "broadcast", "payload": {"type": "broadcast", "event": "ping", "payload": {"n": 1}}, "ref": null}
await socket._listen()
assert received == [{"n": 1}]   # main: received == []

What is the new behavior?

channel() returns the existing channel for the topic, unless that channel is leaving. This matches realtime-js RealtimeClient.channel(), which returns the channel it finds for the topic. After unsubscribe() (state LEAVING), a new channel is still created, as before, so the unsubscribe-and-recreate flow is unchanged.

Additional context

New tests in tests/test_connection.py (mocked websocket, no server needed):

  • test_channel_returns_existing_channel_for_same_topic: on main, a second channel("room") returns a different object.
  • test_second_channel_call_keeps_first_subscription_receiving: on main, received == []. On this branch, it is [{"n": 1}].
  • test_channel_after_unsubscribe_creates_new_channel: passes before and after. It guards the recreate-after-unsubscribe flow.

Results:

  • On main, the first two tests fail. On this branch, all three pass.
  • Full src/realtime/tests run without a local Supabase: the only failures are the 6 tests that need the server at 127.0.0.1:54321. They fail the same way on main.
  • ruff check, ruff format --check and mypy src/realtime tests are clean.

I searched the open issues and PRs (topic, channel twice, duplicate channel) and found no overlap. #1652 and #1656 touch other parts of client.py.

This PR was prepared by an AI coding agent (Claude Code) working from the breken-ai account. The tests above were run as described.

🤖 Generated with Claude Code

channel() always built a new AsyncRealtimeChannel and stored it in
socket.channels under its topic, replacing any channel already there.
Incoming messages are routed by topic, so a second channel("room") call
silently stopped delivering broadcasts, presence and postgres changes to
the first, already subscribed channel.

Return the existing channel for the topic unless it is being left,
matching realtime-js.
@breken-ai
breken-ai requested review from a team and o-santi as code owners September 26, 2026 03:52

This branch has not been deployed

No deployments
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