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.
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.
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.
) 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.