Conversation
Two defects in how voice state is reconciled leave users listed in voice
channels they are no longer in.
1. `VoiceChannelMove` was an unimplemented `// todo`. Its payload is
`{ user, from, to, state }`, so when the server reports that someone
switched voice channels the client never removed them from `from` and
never added them to `to` -- a permanent ghost in the old channel *and*
a missing participant in the new one, for the life of the tab. Both
sides are now resolved and null-checked independently, mirroring the
`VoiceChannelJoin`/`VoiceChannelLeave` cases either side of it, so one
uncached channel does not skip the other.
2. `Ready` pruned only the channels the server chose to mention. The
`.clear()` sat inside the per-channel loop, making reconciliation a
per-channel merge rather than a rebuild, so a channel the server omits
-- very likely how an emptied channel is represented -- kept its stale
entries through every reconnect. The clear is now a separate pass over
every known channel, still guarded on `voice_states` being present at
all: if the server sends nothing, clearing everything would discard
good state, which is worse than the bug.
Also records why the non-creating `client.channels.get()` lookup in that
block is safe -- channels are hydrated from `event.channels` a few lines
above -- rather than changing it to `getOrCreate`, which has no channel
data to create from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Murilo Duran <92951229+MuDuran@users.noreply.github.com>
MuDuran
force-pushed
the
upstream/voice-channel-ghosts
branch
from
September 12, 2026 02:15
fc4e0dc to
1318d6e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in voice-state reconciliation leave users listed in voice channels they are no longer in. Both are in
src/events/v1.ts; the change is +27/-3 in that one file.Found while debugging a client where the sidebar showed a user in two voice channels at once after a channel switch.
1.
VoiceChannelMovewas an unimplemented// todoThe payload is
{ user, from, to, state }(typed atv1.ts:190-196), so when the server reports that someone switched voice channels, the client never removes them fromfromand never adds them toto. The result is a permanent ghost in the old channel and a missing participant in the new one, for the life of the tab — nothing later reconciles it except aReady.Now resolved and null-checked independently per side, mirroring the
VoiceChannelJoinandVoiceChannelLeavecases immediately either side of it, so one uncached channel does not cause the other side to be skipped. I kept the// todo: eventmarker those neighbouring cases carry for the missing client-event emit, since adding that is a separate concern.2.
Readypruned only the channels the server chose to mentionThe
.clear()sat inside the per-channel loop:That makes reconciliation a per-channel merge rather than a rebuild. A channel the server omits from
voice_states— which appears to be how an emptied channel is represented — keeps its stale entries through every reconnect, so a ghost survives a full reload.The clear is now a separate pass over every known channel before the populate loop, still guarded on
event.voice_statesbeing present at all. That guard is deliberate: if the server sends no voice state, clearing everything would discard good state, which is worse than the bug being fixed.If omitting a channel is not how your server represents an emptied voice channel, this is the part to push back on — the fix is correct either way, but the motivation for it would be wrong.
Also
A short comment recording why the non-creating
client.channels.get(state.id)lookup in that block is safe — channels are hydrated fromevent.channelsa few lines above. Behaviour unchanged; I did not switch it togetOrCreate, which has no channel data to create from.Checks
main(44d45ade);v1.tsmerged cleanly with fix: sync relationship changes for users not in the local cache #189.tsc --noEmitoutput is byte-identical tomain's: 5 errors, all pre-existing insrc/classes/Bot.tsandsrc/classes/Server.tsand unrelated to this change (they look likestoat-apineeding a bump for the new discover endpoints). Zero new errors.prettier --checkpasses on the changed file.No new models or exports; reuses the existing
VoiceParticipant.