User appears in two voice channels after switching channels quickly

(#217) Bug Awaiting confirmation voice

Bug Description

When a user rapidly switches between voice channels (clicking multiple channels in quick succession), they can appear as a "ghost" in multiple channels simultaneously. Navigating to the channel with the doppelganger triggers the "Switch to this device / Just join / Do nothing" modal, even though it's the same session.

Root Cause

Race condition in guild_voice_connection.erl — handle_new_connection/5 creates a new voice state entry without cleaning up existing voice states for the same user. When channel switches happen rapidly, the disconnect message for the old channel hasn't been processed by the gateway before the new channel's connect message arrives, resulting in two voice states for one user. Sequence:
  1. User clicks Channel A → Channel B rapidly
  2. Client sends: voice_state_update({channel_id: null}) (disconnect A)
  3. Client sends: voice_state_update({channel_id: B}) (connect B)
  4. Gateway processes connect B before disconnect A completes
  5. User now has voice states in both channels

Suggested Fix

In handle_new_connection/5 (fluxer_gateway/src/guild/voice/guild_voice_connection.erl), clean up any existing voice states for the user before creating a new one:
handle_new_connection(Context, Member, Channel, VoiceStates, State) ->
    UserId = maps:get(user_id, Context),
    ChannelIdValue = maps:get(channel_id, Context),
    GuildId = map_utils:get_integer(State, id, undefined),

    %% Clean up any existing voice states for this user before creating a new one.
    %% This prevents "doppelganger" ghosts when rapidly switching channels — the
    %% disconnect message for the old channel may not have arrived yet.
    StaleStates = voice_state_utils:filter_voice_states(VoiceStates, fun(_ConnId, V) ->
        voice_state_utils:voice_state_user_id(V) =:= UserId
    end),
    {CleanedVoiceStates, CleanedState} = case maps:size(StaleStates) of
        0 ->
            {VoiceStates, State};
        StaleCount ->
            logger:info(
                "[guild_voice_connection] Cleaning up ~p stale voice state(s) for UserId=~p before new connection",
                [StaleCount, UserId]
            ),
            VS1 = voice_state_utils:drop_voice_states(StaleStates, VoiceStates),
            S1 = maps:put(voice_states, VS1, State),
            voice_state_utils:broadcast_disconnects(StaleStates, S1),
            {VS1, S1}
    end,

    ViewerKeyResult = resolve_viewer_stream_key(Context, GuildId, ChannelIdValue, CleanedVoiceStates, #{}),

    PermCheck = guild_voice_permissions:check_voice_permissions_and_limits(
        UserId, ChannelIdValue, Channel, CleanedVoiceStates, CleanedState, false
    ),

    case {PermCheck, ViewerKeyResult} of
        {{error, _Category, ErrorAtom}, _} ->
            {reply, gateway_errors:error(ErrorAtom), CleanedState};
        {{ok, allowed}, {error, ErrorAtom}} ->
            {reply, gateway_errors:error(ErrorAtom), CleanedState};
        {{ok, allowed}, {ok, ParsedViewerKey}} ->
            get_voice_token_and_create_state(Context, Member, ParsedViewerKey, CleanedState)
    end.
This uses existing utility functions (filter_voice_states, drop_voice_states, broadcast_disconnects) so there are no new dependencies. The cleanup is atomic since the gateway processes messages sequentially per guild. Late-arriving disconnect messages for already-cleaned connections will simply find nothing to remove.

Impact

  • No breaking changes
  • Uses existing utility functions only
  • Fixes the ghost/doppelganger issue regardless of client behavior or message ordering

1 comment

Sign in with Fluxer to comment and vote.
Comment by Rex
RexSystem 1 vote
Status changed from Fixed to Awaiting confirmation
This was closed in a bulk cleanup before Fluxer V2 without being checked or fixed. It may work now, so it is waiting for someone to confirm whether the bug still happens.