Mint SFU tokens for huddles, and stop stages colliding with them (LC-596) #569
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/lc596-sfu-huddle-tokens"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
First slice of LC-596. Does not close it - LC-596 is an epic and this is the SFU-backed-huddle workstream, server side. See the scope note at the bottom.
A latent bug this had to fix first
livekit::room_namehardcodedformat!("stage-{room_id}"). A lets-chat room can host a Stage and a huddle simultaneously - they are independent features, one gated bystage_enabledand the other by live mesh membership, and nothing prevents both being active. Naming bothstage-{id}would have dropped both sets of participants into a single LiveKit room, so a huddle would have leaked into the Stage broadcast and vice versa.Surfaceis now part of the name (stage-42vshuddle-42). This predates the ticket and would have bitten the moment huddles reached the SFU.The endpoint
GET /room/{id}/huddle/token, deliberately separate from the stage token rather than a mode flag on it. The two surfaces have different gates (mesh membership vs the stage roster), different publish rules (symmetric vs a granted floor), and different LiveKit rooms - one handler would have branched on all three.The gate mirrors
require_participantinroutes/transcripts.rs, which is the established precedent for this exact two-surface question: being able to see the room is not being in the call, so the caller must appear invoice_room_users. Every huddle participant publishes, because a huddle has no listener role.Why below-threshold is a refusal, not a fallback flag
Below
SFU_MIN_PARTICIPANTSthe endpoint returns 400. Issuing a token there would split one call across two transports - some participants on the SFU, some still on the mesh, hearing nobody - which is strictly worse than staying on the mesh.It also makes the client simple: the unconfigured case and the below-threshold case are the same signal, so mesh fallback is one code path rather than two.
The threshold is 3, living in
livekit.rsas one named constant rather than a number duplicated across client and server. Two peers is a singleRTCPeerConnection, where the mesh is cheaper and lower-latency than a server hop; the N-squared cost is what makes the SFU worth it from three up. The ticket flagged this as an assumption to revise once measured, so it is one edit when that happens.Tests
huddle_sfu_token_gatewalks the four states reachable without a LiveKit server: unconfigured, configured but not in the call, in the call but below threshold, and at threshold (a real token,can_publish: true, correct URL). Plus two unit tests:stage_and_huddle_in_one_room_do_not_collideandhuddle_participant_publishes.Confirmed load-bearing by deleting the threshold guard - the below-threshold case then returns 200 with a valid
huddle-1token:Full suite green, 181 test binaries.
just checkclean.One note on the test: it creates distinct extra users rather than extra connections, because
voice_room_usersdedupes by user id - more connections for the same user would not move the count, and the test would have passed for the wrong reason.Scope
LC-596 is four workstreams: group ring/invite, sidebar discovery, SFU-backed huddles, and converging
call.jswithvoice.js. This PR is the server half of the third.The browser media path is not here and needs its own ticket. It is not a small addition:
stage_media.jsis 129 lines of audio-only subscribe-and-attach, whereas a symmetric group call needs camera and mic publishing, N remote participants, tile rendering, and mute/screen-share parity with the existing mesh UI. That is comparable in size tovoice.jsitself, and bolting it onto this PR would make the whole thing unreviewable. The endpoint is independently correct and tested; nothing calls it yet.Structural blockers found while scoping the other three, recorded so they are not rediscovered:
RingingSlotis keyed byroom_idand holds a single caller,#lc-call-rootis single-peer by construction,VoiceJoinedonly reaches sockets with the room already open, and the call/ring/huddle paths have essentially no test coverage today.🤖 Generated with Claude Code
https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
First slice of LC-596, the SFU-backed group call. Server side only: the token endpoint and the room-naming fix it depends on. The browser media path is a separate ticket, see below. `livekit::room_name` hardcoded `stage-{room_id}`, so a huddle and a Stage in the same lets-chat room would have resolved to one LiveKit room and their participants would have heard each other. The two surfaces are independent - one gated by `stage_enabled`, the other by live mesh membership - and nothing stops both running at once. `Surface` is now part of the name, and `stage_and_huddle_in_one_room_do_not_collide` pins it. `GET /room/{id}/huddle/token` is a separate route from the stage token rather than a mode flag on it. The two have different gates (mesh membership vs the stage roster), different publish rules (symmetric vs a granted floor), and different LiveKit rooms; one handler would have branched on all three. The gate mirrors `require_participant` in `routes/transcripts.rs`: room access is not membership in the call, so the caller must be in `voice_room_users`. Every huddle participant publishes, because a huddle has no listener role. Below `SFU_MIN_PARTICIPANTS` the endpoint refuses. Handing out a token there would split one call across two transports - some participants on the SFU, some on the mesh, hearing nobody - which is worse than staying on the mesh. Refusing is also exactly the signal the client needs for mesh fallback, so the unconfigured case and the below-threshold case behave the same way from the browser's point of view. The threshold is 3, and it is one named constant in `livekit.rs` rather than a number spread across client and server. Two peers is a single `RTCPeerConnection` and the mesh is cheaper and lower-latency there; the N-squared cost is what makes the SFU worth a server hop from three up. The ticket called this an assumption to revise once measured. Tests: the four gate states (LiveKit unconfigured, not in the call, below threshold, at threshold), plus surface collision and huddle publish rights. Confirmed load-bearing by deleting the threshold guard, which turns the below-threshold case into a 200 with a real token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5