livekit/voice-limit-guard.py decides at token issue. should_block(limit, present_users, requester) compares the limit with the users LiveKit already has connected (livekit_present_users → ListParticipants). Two things aren't counted:
tokens issued but not connected yet: the 1–2 s between /sfu/get and the LiveKit connect;
connects after issue: lk-jwt-service tokens are valid for an hour (the dev clone uses exp: now + 3600), and nothing re-checks the limit when the token is used.
Reproduced (local stack: Synapse + LiveKit + the real guard; Voice Lounge with io.lotus.voice_limit {max_users: 1}, 0/1 shown; two users press Join at the same moment):
run 1: both /sfu/get → 200 and both are in the call, each seeing the other. That's 2 people in a 1-person channel.
run 2: the guard caught the second request (403) and Element Call showed "Can't join this call — This voice channel is full." ✔
one after the other (second user 15 s later): Lotus disables the second user's Join button at 1/1 ✔.
So normal use mostly works and it takes exact timing to exceed the cap. But the cap isn't a hard limit: a custom client (or a second tab) can fetch a token while the room has space and connect at any time within the hour.
Fix:
Enforce at connect time as well. LiveKit webhooks (participant_joined) are signed with the API key the guard already holds. On a join that takes the room over its limit (distinct Matrix users, same matrix_user() reduction), call RemoveParticipant for the newest participant. Element Call then shows its normal disconnect.
Alternatively, reserve a slot at token issue (a short-lived in-memory reservation per user, ~30 s, counted in should_block) and serialise the check with a per-room lock. That closes the simultaneous-join race, though not the reuse of a token fetched earlier.
Optionally issue shorter-lived tokens (e.g. 5 min). Element Call refetches before connecting anyway.
Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny 30e2dbcd, cinny-desktop 8f67ec7, element-call 3b0050e8.
`livekit/voice-limit-guard.py` decides at **token issue**. `should_block(limit, present_users, requester)` compares the limit with the users LiveKit *already has connected* (`livekit_present_users` → `ListParticipants`). Two things aren't counted:
- **tokens issued but not connected yet:** the 1–2 s between `/sfu/get` and the LiveKit connect;
- **connects after issue:** lk-jwt-service tokens are valid for an hour (the dev clone uses `exp: now + 3600`), and nothing re-checks the limit when the token is used.
**Reproduced** (local stack: Synapse + LiveKit + the real guard; Voice Lounge with `io.lotus.voice_limit {max_users: 1}`, 0/1 shown; two users press **Join** at the same moment):
- **run 1:** both `/sfu/get` → **200** and **both are in the call**, each seeing the other. That's 2 people in a 1-person channel.
- **run 2:** the guard caught the second request (403) and Element Call showed "Can't join this call — This voice channel is full." ✔
- **one after the other** (second user 15 s later): Lotus disables the second user's **Join** button at 1/1 ✔.
So normal use mostly works and it takes exact timing to exceed the cap. But the cap isn't a hard limit: a custom client (or a second tab) can fetch a token while the room has space and connect at any time within the hour.
**Fix:**
- Enforce at **connect time** as well. LiveKit webhooks (`participant_joined`) are signed with the API key the guard already holds. On a join that takes the room over its limit (distinct Matrix users, same `matrix_user()` reduction), call `RemoveParticipant` for the newest participant. Element Call then shows its normal disconnect.
- Alternatively, **reserve a slot** at token issue (a short-lived in-memory reservation per user, ~30 s, counted in `should_block`) and serialise the check with a per-room lock. That closes the simultaneous-join race, though not the reuse of a token fetched earlier.
- Optionally issue shorter-lived tokens (e.g. 5 min). Element Call refetches before connecting anyway.
---
_Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny `30e2dbcd`, cinny-desktop `8f67ec7`, element-call `3b0050e8`._
Client side of the same limit: LotusGuild/cinny#342. The Lotus prescreen counts devices instead of people, so it is stricter than this guard and shows "Channel Full" when the guard would admit.
Client side of the same limit: LotusGuild/cinny#342. The Lotus prescreen counts devices instead of people, so it is stricter than this guard and shows "Channel Full" when the guard would admit.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
livekit/voice-limit-guard.pydecides at token issue.should_block(limit, present_users, requester)compares the limit with the users LiveKit already has connected (livekit_present_users→ListParticipants). Two things aren't counted:/sfu/getand the LiveKit connect;exp: now + 3600), and nothing re-checks the limit when the token is used.Reproduced (local stack: Synapse + LiveKit + the real guard; Voice Lounge with
io.lotus.voice_limit {max_users: 1}, 0/1 shown; two users press Join at the same moment):/sfu/get→ 200 and both are in the call, each seeing the other. That's 2 people in a 1-person channel.So normal use mostly works and it takes exact timing to exceed the cap. But the cap isn't a hard limit: a custom client (or a second tab) can fetch a token while the room has space and connect at any time within the hour.
Fix:
participant_joined) are signed with the API key the guard already holds. On a join that takes the room over its limit (distinct Matrix users, samematrix_user()reduction), callRemoveParticipantfor the newest participant. Element Call then shows its normal disconnect.should_block) and serialise the check with a per-room lock. That closes the simultaneous-join race, though not the reuse of a token fetched earlier.Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny
30e2dbcd, cinny-desktop8f67ec7, element-call3b0050e8.Client side of the same limit: LotusGuild/cinny#342. The Lotus prescreen counts devices instead of people, so it is stricter than this guard and shows "Channel Full" when the guard would admit.