Carry huddle media over the SFU when LiveKit is configured (LC-610) #571

Merged
longjacksonle merged 1 commit from feat/lc610-sfu-huddle-media into main 2026-07-21 01:15:34 +02:00

The client half of #569. Closes LC-610 for the SFU media path; the mid-call handover is deferred and re-filed (see the bottom).

The scope changed mid-design, twice, for a real reason

I started to build the slice we first agreed - "SFU above the threshold, defer the handover" - and found it doesn't hold together. Under the mesh-for-2 / SFU-for-3+ model shipped in #569, AC1 (SFU carries 3+) requires AC3 (the handover), because a huddle grows one person at a time:

  • P1 joins (count 1): below threshold, mesh.
  • P2 joins (count 2): below threshold, mesh, connected to P1.
  • P3 joins (count 3): token endpoint says >= 3, so P3 goes SFU. But P1 and P2 are on the mesh. P3 is alone on the SFU, hearing nobody.

The only way all three end up together is to move P1 and P2 when the threshold is crossed - which is the handover. So the two are one feature, not two, and deferring the handover leaves a broken call, not a smaller one.

The chosen resolution: transport is fixed by server config, not by count. LiveKit configured -> the huddle is entirely SFU; not configured -> entirely mesh. No mid-call switch exists, so there is nothing to hand over. The token endpoint's < SFU_MIN_PARTICIPANTS refusal is removed; the constant stays as a documented seam so the handover work can reintroduce mesh-for-2 later without re-deriving the number.

How it is wired

The transport decision has a chicken-and-egg. It must happen before joining, but the token endpoint gates on hub membership, which only exists after the WS voice_join. Broken by having the huddle root carry data-lc-huddle-sfu - a pure livekit::available() read, also added to /call/config - so voice.js picks the transport up front without needing a token first.

huddle_sfu.js owns only the connection and the media. It renders into voice.js's existing tile grid through a small hook API passed in start(), so an SFU huddle is visually identical to a mesh one and the tile/control DOM is not duplicated. This is the seam LC-613 will formalize; for now it is a hook object, not exported internals.

The mesh path is untouched. join / leave / toggleMute / toggleCamera / toggleScreen each gain a guarded SFU branch at the top and fall through to the unchanged mesh body otherwise. leave()'s SFU branch returns before the mesh body; verified it resets the same state.

Presence still flows. voice_join / voice_leave fire on the SFU path too, so the hub roster, the LC-611 ring, and the transcription gate all keep working. The token endpoint gates on that membership, and the token fetch retries a few times to absorb the WS-vs-HTTP ordering (the frame may not be processed when the first HTTP request lands).

Control parity maps onto LiveKit publish toggles: setMicrophoneEnabled / setCameraEnabled / setScreenShareEnabled, with remote mute and active-speaker reflected from RoomEvent.TrackMuted and ActiveSpeakersChanged onto the same tile attributes the mesh uses.

Tests

There is no JS test harness, so the server-observable behaviour is what's covered:

  • the token gate now issues a token to a lone member (no threshold), replacing the old below-threshold 400
  • a render test proves data-lc-huddle-sfu flips with LiveKit config, confirmed load-bearing by reverting the template attribute to a constant

The two env-mutating tests now share a tokio::sync::Mutex - they were racing on the process-global LETS_CHAT_LIVEKIT_* env in parallel, which is why the render test failed intermittently before the guard.

For the client, I ran an adversarial review of both JS files. It found exactly one bug - toggleMute returning its intended post-toggle state rather than the actual unchanged state when the LiveKit call rejects (e.g. permission denied) - which is fixed here. No other findings.

Full suite green (182 test binaries), just check clean.

Deferred, and re-filed

The mid-call handover (mesh-for-2, upgrade to the SFU as it grows) is the only reason participant count would matter, and it is the risky part. It should be its own ticket, done after LC-614 lands JS-adjacent coverage. I will file it and link it under LC-596 unless you would rather fold it into the existing LC-610.

DM 1:1 is untouched: the endpoint refuses DM rooms and call.js is not involved.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5

The client half of #569. Closes LC-610 for the SFU media path; the mid-call handover is deferred and re-filed (see the bottom). ## The scope changed mid-design, twice, for a real reason I started to build the slice we first agreed - "SFU above the threshold, defer the handover" - and found it doesn't hold together. Under the mesh-for-2 / SFU-for-3+ model shipped in #569, AC1 (SFU carries 3+) **requires** AC3 (the handover), because a huddle grows one person at a time: - P1 joins (count 1): below threshold, mesh. - P2 joins (count 2): below threshold, mesh, connected to P1. - P3 joins (count 3): token endpoint says >= 3, so P3 goes SFU. But P1 and P2 are on the mesh. P3 is alone on the SFU, hearing nobody. The only way all three end up together is to *move* P1 and P2 when the threshold is crossed - which is the handover. So the two are one feature, not two, and deferring the handover leaves a broken call, not a smaller one. The chosen resolution: **transport is fixed by server config, not by count.** LiveKit configured -> the huddle is entirely SFU; not configured -> entirely mesh. No mid-call switch exists, so there is nothing to hand over. The token endpoint's `< SFU_MIN_PARTICIPANTS` refusal is removed; the constant stays as a documented seam so the handover work can reintroduce mesh-for-2 later without re-deriving the number. ## How it is wired **The transport decision has a chicken-and-egg.** It must happen before joining, but the token endpoint gates on hub membership, which only exists after the WS `voice_join`. Broken by having the huddle root carry `data-lc-huddle-sfu` - a pure `livekit::available()` read, also added to `/call/config` - so `voice.js` picks the transport up front without needing a token first. **huddle_sfu.js owns only the connection and the media.** It renders into `voice.js`'s existing tile grid through a small hook API passed in `start()`, so an SFU huddle is visually identical to a mesh one and the tile/control DOM is not duplicated. This is the seam LC-613 will formalize; for now it is a hook object, not exported internals. **The mesh path is untouched.** `join` / `leave` / `toggleMute` / `toggleCamera` / `toggleScreen` each gain a guarded SFU branch at the top and fall through to the unchanged mesh body otherwise. `leave()`'s SFU branch returns before the mesh body; verified it resets the same state. **Presence still flows.** `voice_join` / `voice_leave` fire on the SFU path too, so the hub roster, the LC-611 ring, and the transcription gate all keep working. The token endpoint gates on that membership, and the token fetch retries a few times to absorb the WS-vs-HTTP ordering (the frame may not be processed when the first HTTP request lands). **Control parity** maps onto LiveKit publish toggles: `setMicrophoneEnabled` / `setCameraEnabled` / `setScreenShareEnabled`, with remote mute and active-speaker reflected from `RoomEvent.TrackMuted` and `ActiveSpeakersChanged` onto the same tile attributes the mesh uses. ## Tests There is no JS test harness, so the server-observable behaviour is what's covered: - the token gate now issues a token to a lone member (no threshold), replacing the old below-threshold 400 - a render test proves `data-lc-huddle-sfu` flips with LiveKit config, confirmed load-bearing by reverting the template attribute to a constant The two env-mutating tests now share a `tokio::sync::Mutex` - they were racing on the process-global `LETS_CHAT_LIVEKIT_*` env in parallel, which is why the render test failed intermittently before the guard. For the client, I ran an adversarial review of both JS files. It found exactly one bug - `toggleMute` returning its *intended* post-toggle state rather than the *actual* unchanged state when the LiveKit call rejects (e.g. permission denied) - which is fixed here. No other findings. Full suite green (182 test binaries), `just check` clean. ## Deferred, and re-filed **The mid-call handover** (mesh-for-2, upgrade to the SFU as it grows) is the only reason participant count would matter, and it is the risky part. It should be its own ticket, done after LC-614 lands JS-adjacent coverage. I will file it and link it under LC-596 unless you would rather fold it into the existing LC-610. **DM 1:1 is untouched:** the endpoint refuses DM rooms and `call.js` is not involved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
feat(huddle): carry huddle media over the SFU when LiveKit is configured (LC-610)
All checks were successful
check-secrets / Nosey parker (push) Successful in 7s
check-secrets / TruffleHog (push) Successful in 10s
check-secrets / Kingfisher (push) Successful in 11s
check-secrets / Nosey parker (pull_request) Successful in 8s
check-secrets / Kingfisher (pull_request) Successful in 13s
Check / clippy + fmt + tests (pull_request) Successful in 13m15s
check-secrets / TruffleHog (pull_request) Successful in 11s
Create release / Create release from merged PR (pull_request) Has been skipped
efb81bff4a
The client half of #569. When the operator has LiveKit configured, a huddle now runs entirely over the SFU instead of the WebRTC mesh; when it is not, the mesh path is unchanged.

Transport is decided by server config, not participant count. #569 gated the token on >= 3 participants to keep the mesh for 2 peers, but that model is incoherent without a mid-call mesh->SFU handover: a huddle grows one person at a time, so at the crossing point the earlier peers are already meshed and would be stranded while the newcomer connects to the SFU. So AC1 (SFU above the threshold) actually required AC3 (the handover) - they are one feature, not two. Rather than build the risky live handover with no JS test harness, a huddle is now entirely SFU (LiveKit configured) or entirely mesh (not), fixed for its whole life. The token endpoint's count threshold is dropped; SFU_MIN_PARTICIPANTS stays as a documented seam for the handover work to reintroduce mesh-for-2 later.

The transport decision has to happen before joining, but the token endpoint gates on hub membership, which only exists after the WS voice_join. To break that chicken-and-egg, the huddle root carries data-lc-huddle-sfu (a pure livekit::available() read, also surfaced on /call/config), so voice.js picks the transport up front.

huddle_sfu.js owns only the LiveKit connection and the media. It renders into voice.js's existing tile grid through a small hook API, so an SFU huddle is visually identical to a mesh one and the tile/control code is not duplicated. The mesh path in voice.js is untouched: join/leave/mute/camera/screen each gain a guarded SFU branch at the top and fall through to the unchanged mesh body otherwise. voice_join/voice_leave still fire on the SFU path, so presence, the LC-611 ring, and the transcription gate keep working; the token endpoint gates on that membership, and the token fetch retries to absorb the WS-vs-HTTP ordering.

Control parity maps onto LiveKit publish toggles: setMicrophoneEnabled / setCameraEnabled / setScreenShareEnabled, with remote mute/speaking reflected from RoomEvent.TrackMuted and ActiveSpeakersChanged onto the same tile attributes the mesh uses.

Deferred to LC-610's follow-up: the mid-call handover (mesh-for-2 then upgrade), which is the only reason count would matter. DM 1:1 is untouched - the endpoint refuses DM rooms and call.js is not involved.

Tests: the token gate now issues to a lone member (no threshold); a render test proves data-lc-huddle-sfu flips with LiveKit config, confirmed load-bearing by reverting the template. The two env-mutating tests share a tokio::sync::Mutex so they cannot race on process-global LiveKit env. An adversarial review of the two JS files (no JS harness exists) surfaced one bug - the mute toggle returning its intended rather than actual state on error - already fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
longjacksonle deleted branch feat/lc610-sfu-huddle-media 2026-07-21 01:15:34 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!571
No description provided.