Skip to content

fix: implement VoiceChannelMove and rebuild voice state on Ready - #193

Closed
MuDuran wants to merge 1 commit into
stoatchat:mainfrom
MuDuran:upstream/voice-channel-ghosts
Closed

MuDuran wants to merge 1 commit into
stoatchat:mainfrom
MuDuran:upstream/voice-channel-ghosts

Conversation

@MuDuran

@MuDuran MuDuran commented Sep 12, 2026

Copy link
Copy Markdown

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. VoiceChannelMove was an unimplemented // todo

case "VoiceChannelMove": {
  // todo
  break;
}

The payload is { user, from, to, state } (typed at v1.ts:190-196), so when the server reports that someone switched voice channels, the client never removes them from from and never adds them to to. 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 a Ready.

Now resolved and null-checked independently per side, mirroring the VoiceChannelJoin and VoiceChannelLeave cases immediately either side of it, so one uncached channel does not cause the other side to be skipped. I kept the // todo: event marker those neighbouring cases carry for the missing client-event emit, since adding that is a separate concern.

2. Ready pruned only the channels the server chose to mention

The .clear() sat inside the per-channel loop:

for (const state of event.voice_states) {
  const channel = client.channels.get(state.id);
  if (channel) {
    channel.voiceParticipants.clear();   // <-- only channels present in the payload
    ...
  }
}

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_states being 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 from event.channels a few lines above. Behaviour unchanged; I did not switch it to getOrCreate, which has no channel data to create from.

Checks

  • Rebased onto current main (44d45ade); v1.ts merged cleanly with fix: sync relationship changes for users not in the local cache #189.
  • tsc --noEmit output is byte-identical to main's: 5 errors, all pre-existing in src/classes/Bot.ts and src/classes/Server.ts and unrelated to this change (they look like stoat-api needing a bump for the new discover endpoints). Zero new errors.
  • prettier --check passes on the changed file.

No new models or exports; reuses the existing VoiceParticipant.

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
MuDuran force-pushed the upstream/voice-channel-ghosts branch from fc4e0dc to 1318d6e Compare September 12, 2026 02:15
@MuDuran MuDuran closed this Sep 12, 2026
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