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>
This commit is contained in:
J3vb
2026-08-14 16:15:32 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8579cb5d91
commit 7be9ccd2f9
41 changed files with 1569 additions and 89 deletions
+37
View File
@@ -709,6 +709,43 @@ func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
}
}
// A voice_join racing the delete window must be refused, not silently create
// a voice_states row the FK cascade then wipes out from under it (OC-0035):
// CleanupVoiceForChannel snapshots participants ONCE, up front, so a join
// that lands after that snapshot but before AdminDeleteChannel's cascade
// leaves the joiner's hub-side voice state and LiveKit session orphaned with
// nothing left to clean it up. handleDeleteChannel must close that window the
// same way the archive path does (handlePatchChannel): persist archived=true
// BEFORE evicting current participants, so voice_join's archived gate
// (ws/voice_join.go) refuses any concurrent join that reads the channel row
// during cleanup.
func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel(context.Background(), "del-race", "voice", "", "", 0)
archivedAtCleanup := false
hub.onVoiceCleanup = func(channelID int64) {
ch, err := database.GetChannel(context.Background(), channelID)
if err != nil || ch == nil {
t.Fatalf("GetChannel during cleanup: %v", err)
}
archivedAtCleanup = ch.Archived
}
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
}
if !archivedAtCleanup {
t.Errorf("channel.Archived at CleanupVoiceForChannel time = false, want true — a concurrent voice_join would not be refused by the archived gate")
}
}
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
+27
View File
@@ -281,6 +281,33 @@ func handleDeleteChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
}
id := existing.ID
// Mark the channel archived BEFORE evicting participants, mirroring the
// archive path (handlePatchChannel): CleanupVoiceForChannel snapshots
// voice participants ONCE, up front, so a voice_join racing this delete
// could otherwise read the still-live channel row, pass the archived
// gate (ws/voice_join.go), and insert a voice_states row after the
// snapshot but before AdminDeleteChannel's cascade — leaving that
// joiner's hub-side voice state and LiveKit session orphaned with no
// DB row left for any sweep to find (OC-0035). Persisting archived=1
// first makes voice_join's existing archived check refuse that join
// outright, the same way it already refuses one racing an archive.
if !existing.Archived {
if err := database.AdminUpdateChannel(r.Context(), id, db.ChannelUpdate{
Name: existing.Name,
Topic: existing.Topic,
Category: existing.Category,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: true,
NSFW: existing.NSFW,
VoiceMaxUsers: existing.VoiceMaxUsers,
VoiceMaxVideo: existing.VoiceMaxVideo,
}); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
}
// Evict voice participants BEFORE deleting the row: the voice_states
// FK cascade wipes the rows the cleanup reads, and the stale sweeper
// cannot recover participants of a channel that no longer exists.