diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index eca03f54..ecbbe1aa 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -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()) } diff --git a/Server/ws/serve.go b/Server/ws/serve.go index fde604eb..f6fc588b 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -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()