fix(ws): don't clean voice state when a replacement connection exists

readPump teardown always ran handleVoiceLeave, trusting the joined_at
guard in LeaveVoiceChannelIfMatch to protect replacement sessions. On
reconnect the voice session TRANSFERS to the replacement client with
the same joined_at, so the guard cannot tell the two apart: whenever
teardown snapshotted voiceChID before the transfer zeroed it, the old
connection deleted the replacement's voice_state row (flaked on the
Windows CI runner as TestServeWS_Reconnect_PreservesVoiceState).

Gate voice cleanup on !replaced — the same condition the
presence-offline broadcast four lines down already uses. A genuinely
final disconnect behaves exactly as before, and a stale row from a
crashed replacement is still swept by the fresh-connect cleanup.

Also deflake TestHub_ConcurrentRegisterUnregister: poll for quiescence
with a deadline instead of a fixed 50ms sleep that loses to the -race
scheduler on slow runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-18 13:50:38 +02:00
co-authored by Claude Fable 5
parent 2732b7c52d
commit 20e9e2f6a5
2 changed files with 13 additions and 4 deletions
+6 -1
View File
@@ -489,7 +489,12 @@ func TestHub_ConcurrentRegisterUnregister(t *testing.T) {
}(i)
}
wg.Wait()
time.Sleep(50 * time.Millisecond)
// The hub loop drains register/unregister asynchronously; poll instead of
// a fixed sleep, which flakes under -race on slow runners.
deadline := time.Now().Add(5 * time.Second)
for hub.ClientCount() != 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if hub.ClientCount() != 0 {
t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount())
}
+7 -3
View File
@@ -411,9 +411,13 @@ func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
voiceChID := c.getVoiceChID()
replaced := hub.unregisterNow(c)
if c.user != nil {
// Always clean up voice state — LeaveVoiceChannelIfMatch uses a
// join_token guard so it won't remove a replacement client's session.
if voiceChID != 0 {
// Clean up voice state only when this was the user's final
// connection. A replacement connection owns the (transferred)
// voice session, and the join_token guard cannot tell the
// difference — the transfer keeps the same joined_at — so
// cleaning here would delete the replacement's DB row whenever
// teardown snapshots voiceChID before the transfer zeroes it.
if voiceChID != 0 && !replaced {
hub.handleVoiceLeave(ctx, c)
}
c.mu.Lock()