Skip to content

Leaving a presence channel: Unsubscribe() doesn't untrack and re-Register() throws "can only be called once" #64

Description

@Tr00d

Bug report

Describe the bug

Two intuitive calls for "I'm leaving a presence channel and might come back" are both wrong, and the resulting exception doesn't hint at the fix.

  1. Unsubscribe() does not untrack your presence. It sends a phx_leave but never calls Untrack(), and it does not remove the channel from the client's subscription cache:

    // RealtimeChannel.Unsubscribe()
    public IRealtimeChannel Unsubscribe()
    {
        IsSubscribed = false;
        NotifyStateChanged(ChannelState.Leaving);
        var leavePush = new Push(Socket, this, ChannelEventLeave);
        leavePush.Send();
        NotifyStateChanged(ChannelState.Closed, false);
        return this;          // no Untrack(); channel stays in _subscriptions
    }

    So your presence can linger as a "ghost" watcher — you have to call Untrack() explicitly.

  2. Revisiting the same channel throws. Channels are cached per topic, and the cached instance keeps its _presence, so Register() on the reused channel throws:

    // Client.Channel(channelName)
    var topic = $"realtime:{channelName}";
    if (_subscriptions.TryGetValue(topic, out var channel))
        return channel;       // returns the SAME instance, with _presence still set
    
    // RealtimeChannel.Register<T>(presenceKey)
    if (_presence != null)
        throw new InvalidOperationException(
            "Register can only be called with presence options for a channel once.");

    Because Unsubscribe() left the channel in _subscriptions, navigating back gets the cached channel and Register() throws "can only be called … once". The only way to evict it is client.Realtime.Remove(channel):

    // Client.Remove(channel)
    if (channel.IsJoined) channel.Unsubscribe();
    _subscriptions.Remove(channel.Topic);

Why it bites: "Navigate away and come back" is the most basic interaction and it breaks. The two intuitive calls (Unsubscribe() to leave, Register() again on return) are both wrong, and the exception text doesn't mention that the fix is Remove().

To Reproduce

Steps to reproduce the behavior, please provide code snippets or a repository:

  1. Join a presence channel, register, subscribe, track:
    var ch = client.Channel("watchers");
    var pres = ch.Register<Watcher>(key);
    await ch.Subscribe();
    await pres.Track(new Watcher { /* ... */ });
  2. "Leave" by unsubscribing:
    ch.Unsubscribe();        // presence not untracked; channel still cached
  3. Return to the same channel and try to set presence up again:
    var ch2 = client.Channel("watchers");   // returns the cached instance
    var pres2 = ch2.Register<Watcher>(key);  // throws InvalidOperationException:
                                             // "Register can only be called with presence options for a channel once."
  4. Observe (a) your earlier presence may still show up to other clients (ghost), and (b) re-registering throws unless you first call client.Realtime.Remove(ch).

Expected behavior

  • Leaving a presence channel should remove your presence (no ghost) — either Unsubscribe() should untrack, or the leave/untrack relationship should be documented clearly.
  • Returning to a channel you previously left should "just work": either Unsubscribe() should evict the cached channel so a fresh Channel()/Register() succeeds, or Register() on an already-registered cached channel should be idempotent (or its exception should point at Remove()).

Screenshots

N/A — reproducible from the exception and other clients' presence view.

System information

  • OS: macOS (Darwin); reproducible cross-platform
  • Browser (if applies): N/A
  • Version of the SDK: Supabase.Realtime 7.0.2 (via umbrella Supabase 1.1.1); current master has the same behavior
  • Runtime: .NET 10

Additional context

Workaround: call Untrack() before leaving to remove your presence, and client.Realtime.Remove(channel) (not Unsubscribe()) so that returning to the page rebuilds a fresh channel and Register() succeeds.

JS SDK parity reference. realtime-js caches channels by topic the same way C# does — channel(topic) returns the existing instance if one exists:

// realtime-js — RealtimeClient.channel(topic)
const exists = this.getChannels().find((c) => c.topic === realtimeTopic)
if (!exists) { /* create, push, return */ } else { return exists }

The difference is what happens on close. The RealtimeChannel constructor registers an _onClose hook that auto-evicts the channel from the registry:

// realtime-js — RealtimeChannel constructor
this._onClose(() => {
  this.socket._remove(this)   // _remove: this.channels = this.channels.filter(c => c.topic !== channel.topic)
})

So in JS, unsubscribe() (= phoenix channel.leave()) drives the channel to closed, _onClose fires, and the channel removes itself from this.channels — a later channel(topic) returns a fresh instance. There is also no throwing "register once" guard: presence is configured via channel params + channel.on('presence', …) (repeatable), and untrack() is documented as "stop broadcasting presence while staying subscribed", not as a required step for leaving (leaving drops presence server-side).

Where C# diverges (caching is identical; the lifecycle is not):

JS C#
channel/Channel(topic) dedupes by topic yes (return exists) yes (returns cached)
On close/leave _onClose → socket._remove(this) auto-evicts Unsubscribe() keeps it in _subscriptions
Re-setup presence on a reused channel on('presence', …), repeatable Register() throws "can only be called once"
Leaving drops presence leave() handles it needs a separate Untrack() to avoid a ghost

Suggested fix (mirror JS): hook channel close to evict from _subscriptions (the equivalent of JS _onClose → socket._remove(this)), so Unsubscribe() self-cleans and a later Channel(topic) yields a fresh channel — no manual Remove(), and no "can only be called once" because the registered channel isn't reused. Secondarily, ensure Unsubscribe()'s leave actually drops presence so Untrack() isn't a required prelude.

References:

  • C# (this SDK): Realtime/RealtimeChannel.cs (Unsubscribe, Register<T>), Realtime/Client.cs (Channel, Remove, _subscriptions).
  • JS (parity target): realtime-js src/RealtimeClient.ts (channel, _remove) and src/RealtimeChannel.ts (constructor _onClose → socket._remove(this), unsubscribe, untrack).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions