Files
OwnCord/Server/ws/voice_leave.go
T
J3vbandClaude Fable 5 7be9ccd2f9 fix: batch of 22 correctness fixes across server and client (#1371)
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080)

Guard LiveKit session state against supersession: bump the camera/screen
generation in leaveVoice and teardownForReconnect so an in-flight enable
discards its track, bail out of restoreLocalVoiceState when a newer room
claimed _room mid-await, and recheck isStateConnected in the auto-reconnect
tail.

* fix(ws): 1 defect(s) (OC-0019)

* fix(db): 1 defect(s) (OC-0023)

* fix(ws): 1 defect(s) (OC-0029)

* fix(ws): 1 defect(s) (OC-0032)

* fix(voice): 1 defect(s) (OC-0034)

* fix(admin): 1 defect(s) (OC-0035)

* fix(service): 2 defect(s) (OC-0036, OC-0128)

* fix(voice): 2 defect(s) (OC-0038, OC-0065)

OC-0038: the LiveKit participant_left webhook cleared the leaver's own
client voice state before broadcasting voice_leave, so the broadcast
audience (READ_MESSAGES holders union still-in-the-room participants)
could no longer see them. Voice membership is gated on CONNECT_VOICE
alone, so a participant without READ_MESSAGES never learned the server
had torn down their call. Extracted finishVoiceLeave's audience logic
into broadcastVoiceEventWithLeaver and used it on the webhook path.

OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error
into the same branch as "no matching row", so a transient DB failure
ejected a legitimate participant from the SFU mid-call. Now the read
error is logged and the check skipped, matching sweepStaleVoiceStates.

* fix(client): 1 defect(s) (OC-0041)

* fix(client): 1 defect(s) (OC-0043)

* fix(client): 1 defect(s) (OC-0046)

* fix(client): 1 defect(s) (OC-0047)

* fix(client): 1 defect(s) (OC-0049)

* fix(client): 1 defect(s) (OC-0108)

* fix(client): 2 defect(s) (OC-0111, OC-0143)

OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the
window reopens, so auto-idle's return-to-online does not leave the server
and every other client stuck on idle.

OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes
are scoped per server, matching channel mutes, the NSFW gate and volume.

* test(ws): align aborted-switch test with OC-0034 no-resurrect behavior

The fix agent rewrote this pre-existing test (it locked the buggy restore
path) but the prove agent left it out of c67d25ed; committed state alone
failed go test ./ws/ without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:15:32 +02:00

160 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ws
import (
"context"
"log/slog"
"time"
)
// clearVoiceAndUnsubscribe clears c's voice state and drops its voice-topic
// subscription, returning the cleared channel ID and join token. Every path
// that takes a client out of voice while its WS stays up must use this pair:
// clearing state without unsubscribing leaves the socket receiving that room's
// voice_e2ee_announce relays (which carry no channel_id to filter on) for the
// connection's lifetime, polluting a later session's peer-key store.
func (h *Hub) clearVoiceAndUnsubscribe(c *Client) (int64, string) {
oldChID, oldJoinToken := c.clearVoiceState()
if oldChID != 0 {
h.pubsub.Unsubscribe(c, VoiceTopic(oldChID))
}
return oldChID, oldJoinToken
}
// handleVoiceLeave processes an explicit voice_leave message or a disconnect.
// 1. Gets old voiceChID from clearVoiceAndUnsubscribe.
// 2. If was in voice: remove from DB (with retry), broadcast voice_leave.
// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone).
func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) {
oldChID, oldJoinToken := h.clearVoiceAndUnsubscribe(c)
if oldChID == 0 {
slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID)
return
}
h.finishVoiceLeave(ctx, c, oldChID, oldJoinToken)
}
// handleVoiceLeaveIfStillIn is handleVoiceLeave conditioned on the channel: it
// evicts only if chID is still the client's current voice channel, reporting
// whether it did. An eviction decided against a snapshotted channel (the
// revocation sweep's DB-backed permission check) must not clear a newer
// membership committed while the decision was in flight — the same rule
// LeaveVoiceChannelIfMatch applies to the DB row.
func (h *Hub) handleVoiceLeaveIfStillIn(ctx context.Context, c *Client, chID int64) bool {
oldJoinToken, ok := c.clearVoiceStateIfMatch(chID)
if !ok {
return false
}
h.pubsub.Unsubscribe(c, VoiceTopic(chID))
h.finishVoiceLeave(ctx, c, chID, oldJoinToken)
return true
}
// finishVoiceLeave is the shared tail of the leave paths, run after the
// client's voice state and topic subscription are cleared: DB row removal
// (with retry), voice_leave broadcast, key-holder re-election and LiveKit
// participant removal.
func (h *Hub) finishVoiceLeave(ctx context.Context, c *Client, oldChID int64, oldJoinToken string) {
username := ""
if c.user != nil {
username = c.user.Username
}
slog.Info("voice leave",
"user_id", c.userID,
"username", username,
"channel_id", oldChID,
"remote", c.remoteAddr,
)
if err := leaveVoiceChannelWithRetry(ctx, h, c.userID, oldChID, oldJoinToken); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice leave failed — please rejoin if issues persist"))
}
// Audience = broadcastVoiceEvent's (READ still-in-the-room) plus the
// leaver themselves: the caller has already cleared their client voice
// state, so that union can no longer see them, yet for a server-initiated
// eviction (revocation sweep, moderator kick/move, token-refresh refusal)
// this voice_leave IS their only teardown signal.
h.broadcastVoiceEventWithLeaver(ctx, oldChID, buildVoiceLeave(oldChID, c.userID), c.userID)
// Re-elect key holder now that this user has left the channel.
h.updateKeyHolder(oldChID)
// E2EE keys are now managed client-side via ECDH key exchange.
// When a participant leaves, remaining clients rotate the room key
// automatically — the server has no key material to clear.
// Remove from LiveKit (best-effort).
if h.livekit != nil {
if err := h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken); err != nil {
slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)",
"err", err, "user_id", c.userID, "channel_id", oldChID)
}
}
}
// leaveVoiceChannelWithRetry attempts to remove the voice state from the DB
// using a channel-conditional delete. Only the row matching (userID, channelID)
// is removed — if the user has since moved to a different channel, the delete
// is a safe no-op. This prevents a race where a delayed retry could wipe a
// newer voice membership.
//
// The first attempt is synchronous. If it fails, subsequent retries run in a
// background goroutine with exponential backoff so the caller (readPump) is
// not blocked by time.Sleep. The goroutine respects ctx and the hub's stop
// channel to avoid leaking after shutdown (BUG-086).
// Returns nil on first-attempt success, the first error otherwise (retries
// continue in the background).
func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, channelID int64, joinToken string) error {
if joinToken == "" {
slog.Warn("LeaveVoiceChannelIfMatch skipped due to missing join token",
"user_id", userID, "channel_id", channelID)
return nil
}
// Synchronous first attempt — channel-conditional delete.
if _, err := h.db.LeaveVoiceChannelIfMatch(ctx, userID, channelID, joinToken); err != nil {
slog.Warn("LeaveVoiceChannelIfMatch failed, retrying in background",
"err", err, "user_id", userID, "channel_id", channelID,
"attempt", 1, "max_retries", 3)
// Background retries — cancellable via hub stop only. The caller's ctx
// is detached: on the webhook path it dies the moment the handler
// returns, and on the voice_leave path it dies with the connection —
// either would kill retry 2 before it ever ran, leaving a ghost
// voice_states row holding a capacity slot until the 60s sweep.
go func() {
retryCtx := context.WithoutCancel(ctx)
const maxRetries = 3
delay := 200 * time.Millisecond
for attempt := 2; attempt <= maxRetries; attempt++ {
select {
case <-h.stop:
slog.Info("LeaveVoiceChannelIfMatch retry cancelled (hub stop)",
"user_id", userID, "channel_id", channelID, "attempt", attempt)
return
case <-time.After(delay):
}
delay *= 2
if _, retryErr := h.db.LeaveVoiceChannelIfMatch(retryCtx, userID, channelID, joinToken); retryErr != nil {
slog.Warn("LeaveVoiceChannelIfMatch retry failed",
"err", retryErr, "user_id", userID, "channel_id", channelID,
"attempt", attempt, "max_retries", maxRetries)
if attempt == maxRetries {
slog.Error("LeaveVoiceChannelIfMatch exhausted retries — ghost state may persist",
"err", retryErr, "user_id", userID, "channel_id", channelID)
}
} else {
slog.Info("LeaveVoiceChannelIfMatch succeeded on retry",
"user_id", userID, "channel_id", channelID, "attempt", attempt)
return
}
}
}()
return err
}
return nil
}