Multi-SFU MatrixRTC (MSC4195): remote-homeserver users unbridged and reaped alive; Commet hears no ghosts — per-SFU fan-in + elected-SFU ghosts #139

Closed
opened 2026-09-29 23:49:49 +00:00 by robocub · 3 comments
Member

Symptom

A user on another homeserver with a current client joins a bridged room's call: the bridge joins, ghosts appear, but no audio crosses in either direction, and the bridge's stale-membership reaper (#25) keeps emptying the user's live m.call.member. Hit by tenant 📞┃Limited-VC (@therealfame:matrix.org, Element Call) on 2026-09-28 (84 reaps in one evening) and silently by bsch (@eezra:matrix.org) since 2026-08-13.

Root cause

Element Call 0.21 (July 2026) made MSC4195 multi-SFU the default: each participant publishes on its own homeserver's LiveKit SFU (focus_selection: multi_sfu, foci_preferred[0] = e.g. livekit-jwt.call.matrix.org). The bridge assumed the whole call lived on the SFU from its own .well-known and only ever connected there — it never heard remote-homeserver users, and judged their absence from our SFU as "phantom".

A second, independent failure was found live with Commet (legacy oldest_membership election): Commet listens only on the SFU elected by the oldest membership, so a ghost publishing on our SFU while a matrix.org Commet user is oldest is inaudible. Commet also skips memberships whose focus_selection it doesn't know, so advertising multi_sfu on ghosts made them invisible to it (it then fell back to its own SFU, and our resolver mis-placed and reaped it).

Fix (branch feat/multi-sfu-fanin)

  • rtc::focus: per-member SFU resolution mirroring matrix-js-sdk getTransport(); elected_focus = oldest active membership's transport (ghost-only ⇒ ours).
  • rtc::run::ForeignSessions: one subscribe-only LiveKit session per SFU that any human's resolved focus or own foci_preferred[0] names (JWT from their lk-jwt via our OpenID token); same router; E2EE shares the primary key provider + #73 heal gate.
  • Reaper: judges a member only when both its resolved and preferred SFUs are watched; anything else is unobservable, never absent.
  • Ghosts publish on the elected SFU, migrate when it moves (membership re-post keeps created_ts), and advertise the spec's legacy oldest_membership naming that SFU — valid for Commet's election and resolves correctly for multi-SFU Element Call.
  • Election published by the RTC loop on a watch; ghosts read room state authoritatively at connect.

Verification

  • Unit: focus resolution + election, reaper observability, foreign-SFU selection (158 tests in nvb-matrix-rtc); workspace clippy -D warnings green.
  • Live (staging on harness identities, 2026-09-29): Commet on matrix.org, both join orders — ghost-first (Commet followed the ghost to nether.im, no foreign session needed), Commet-first (ghost published on matrix.org, bridge subscribed there, ghost migrated home when Commet left). Zero reaps, zero jitter drops.
  • Pending: full --all audio e2e dispatch; then merge + release.

Follow-ups

  • Commet upstream: election ignores unknown focus_selection (so it can't hear multi-SFU Element Call users on other homeservers), sorts by origin_server_ts not created_ts, never re-elects mid-call.
  • A dedicated manual-staging identity set so manual staging and the CI e2e harness can coexist (they collided during this test).
## Symptom A user on another homeserver with a current client joins a bridged room's call: the bridge joins, ghosts appear, but **no audio crosses in either direction**, and the bridge's stale-membership reaper (#25) keeps **emptying the user's live `m.call.member`**. Hit by tenant `📞┃Limited-VC` (@therealfame:matrix.org, Element Call) on 2026-09-28 (84 reaps in one evening) and silently by `bsch` (@eezra:matrix.org) since 2026-08-13. ## Root cause Element Call 0.21 (July 2026) made MSC4195 **multi-SFU** the default: each participant publishes on its *own* homeserver's LiveKit SFU (`focus_selection: multi_sfu`, `foci_preferred[0]` = e.g. `livekit-jwt.call.matrix.org`). The bridge assumed the whole call lived on the SFU from its own `.well-known` and only ever connected there — it never heard remote-homeserver users, and judged their absence from *our* SFU as "phantom". A second, independent failure was found live with **Commet** (legacy `oldest_membership` election): Commet listens only on the SFU elected by the oldest membership, so a ghost publishing on our SFU while a matrix.org Commet user is oldest is inaudible. Commet also **skips memberships whose `focus_selection` it doesn't know**, so advertising `multi_sfu` on ghosts made them invisible to it (it then fell back to its own SFU, and our resolver mis-placed and reaped it). ## Fix (branch `feat/multi-sfu-fanin`) - `rtc::focus`: per-member SFU resolution mirroring matrix-js-sdk `getTransport()`; `elected_focus` = oldest active membership's transport (ghost-only ⇒ ours). - `rtc::run::ForeignSessions`: one subscribe-only LiveKit session per SFU that any human's resolved focus **or own `foci_preferred[0]`** names (JWT from their lk-jwt via our OpenID token); same router; E2EE shares the primary key provider + #73 heal gate. - Reaper: judges a member only when both its resolved and preferred SFUs are watched; anything else is unobservable, never absent. - Ghosts publish on the **elected** SFU, migrate when it moves (membership re-post keeps `created_ts`), and advertise the spec's legacy `oldest_membership` naming that SFU — valid for Commet's election and resolves correctly for multi-SFU Element Call. - Election published by the RTC loop on a watch; ghosts read room state authoritatively at connect. ## Verification - Unit: focus resolution + election, reaper observability, foreign-SFU selection (158 tests in `nvb-matrix-rtc`); workspace clippy `-D warnings` green. - Live (staging on harness identities, 2026-09-29): Commet on matrix.org, both join orders — ghost-first (Commet followed the ghost to nether.im, no foreign session needed), Commet-first (ghost published on matrix.org, bridge subscribed there, ghost migrated home when Commet left). Zero reaps, zero jitter drops. - Pending: full `--all` audio e2e dispatch; then merge + release. ## Follow-ups - Commet upstream: election ignores unknown `focus_selection` (so it can't hear multi-SFU Element Call users on other homeservers), sorts by `origin_server_ts` not `created_ts`, never re-elects mid-call. - A dedicated manual-staging identity set so manual staging and the CI e2e harness can coexist (they collided during this test).
Author
Member

Released as v0.3.8 and deployed to prod 2026-09-30 ~03:05 UTC.

  • Full --all compressed e2e on the merged branch (run 834): 27 pass + 1 known_fail (fed_cold_outreach_utd, #104, pre-existing).
  • Merge 416ddd2, release ca23e2e, tag v0.3.8.
  • Prod: nether-voicebridge_0.3.8_amd64.deb installed (sha256 d9533f85…6dfc), unit restarted, 23/23 bridges alive, 0 ERROR / 0 WARN after restart, every bridge published its call-focus election.

Release-asset gap (CI infra, not this fix): the tag's image-arm64 job failed with No space left on device on the arm64 (Oracle Ampere) runner, so manifest and packages were skipped and the v0.3.8 release currently has no assets. The amd64 image was built and pushed by CI; the prod deb was packaged from that exact image with the repo's packaging/nfpm.yaml via nfpm v2.46.3 — the same steps the packages job runs. Once disk is freed on the arm64 runner, re-dispatching docker.yml on ref v0.3.8 will publish the full asset set.

Live verification and the Commet findings are in the issue body. Closing.

**Released as v0.3.8 and deployed to prod 2026-09-30 ~03:05 UTC.** - Full `--all` compressed e2e on the merged branch (run 834): 27 pass + 1 known_fail (`fed_cold_outreach_utd`, #104, pre-existing). - Merge `416ddd2`, release `ca23e2e`, tag `v0.3.8`. - Prod: `nether-voicebridge_0.3.8_amd64.deb` installed (sha256 `d9533f85…6dfc`), unit restarted, 23/23 bridges alive, 0 ERROR / 0 WARN after restart, every bridge published its call-focus election. **Release-asset gap (CI infra, not this fix):** the tag's `image-arm64` job failed with `No space left on device` on the arm64 (Oracle Ampere) runner, so `manifest` and `packages` were skipped and the v0.3.8 release currently has **no assets**. The amd64 image was built and pushed by CI; the prod deb was packaged from that exact image with the repo's `packaging/nfpm.yaml` via `nfpm` v2.46.3 — the same steps the `packages` job runs. Once disk is freed on the arm64 runner, re-dispatching `docker.yml` on ref `v0.3.8` will publish the full asset set. Live verification and the Commet findings are in the issue body. Closing.
Author
Member

Follow-up regression fixed in v0.3.9 (tag pushed 2026-09-30 ~16:50 UTC; prod deploy follows the packages job).

Pinning created_ts across ghost membership re-posts (0.3.8) kept the election stable but also froze the membership's expiry (expires is relative to created_ts): a Discord user present > 4 h before a call started was invisible to Commet/Element although their audio flowed — the active-phase re-post carried identical content and the server deduplicated it (Light Voice, huxley, created 04:41 / expired 08:41 / re-post 14:39 changed nothing).

Fix (7fcfef4, merged bcf5506): expires is computed at every post as (now − created_ts) + 4 h, and a keeper task re-posts hourly for the ghost's lifetime (presence, active, post-migration), so created_ts stays fixed for the election while the membership never ages out. Teardown fires the ghost's cancel before awaiting the keeper so the join-before-leave ordering holds.

Verification: unit tests for the arithmetic and wire shape; full --all e2e on the branch = 25 pass + #104 known_fail + 2 audio-oracle fails (audio_d2m_enc, multi_bridge_isolation) that both passed on isolated re-runs (66 s / 96 s vs master's 95 s) under lower host load — the failing probes measure the Matrix→Discord puppet path the diff does not touch.

Known remaining gap (low priority): the E2EE subscriber's own membership (rtc/membership.rs) is posted once per call with the default 4 h expiry; a single encrypted call longer than 4 h would drop the bot from the roster.

**Follow-up regression fixed in v0.3.9** (tag pushed 2026-09-30 ~16:50 UTC; prod deploy follows the packages job). Pinning `created_ts` across ghost membership re-posts (0.3.8) kept the election stable but also froze the membership's expiry (`expires` is relative to `created_ts`): a Discord user present > 4 h before a call started was invisible to Commet/Element although their audio flowed — the active-phase re-post carried identical content and the server deduplicated it (Light Voice, huxley, created 04:41 / expired 08:41 / re-post 14:39 changed nothing). Fix (`7fcfef4`, merged `bcf5506`): `expires` is computed at every post as `(now − created_ts) + 4 h`, and a keeper task re-posts hourly for the ghost's lifetime (presence, active, post-migration), so `created_ts` stays fixed for the election while the membership never ages out. Teardown fires the ghost's cancel before awaiting the keeper so the join-before-leave ordering holds. Verification: unit tests for the arithmetic and wire shape; full `--all` e2e on the branch = 25 pass + #104 known_fail + 2 audio-oracle fails (`audio_d2m_enc`, `multi_bridge_isolation`) that both passed on isolated re-runs (66 s / 96 s vs master's 95 s) under lower host load — the failing probes measure the Matrix→Discord puppet path the diff does not touch. Known remaining gap (low priority): the E2EE subscriber's own membership (`rtc/membership.rs`) is posted once per call with the default 4 h expiry; a single encrypted call longer than 4 h would drop the bot from the roster.
Author
Member

v0.3.9 deployed to prod 2026-09-30 ~17:25 UTC (official deb, 23/23 bridges, 0 ERROR/WARN; all Light Voice ghost memberships fresh with the hourly refresh armed).

The remaining gap is closed in v0.3.10 (tag pushed ~17:50 UTC, deploy follows the packages job): the bridge bot's own E2EE roster membership now uses the same keeper — fixed created_ts per activation (kept across a disconnect-recovery rejoin), expires recomputed on every post, hourly refresh until the call ends. Helpers moved to rtc::membership and shared with the ghosts. Verified: unit tests; push CI; isolated e2e audio_m2d_enc, audio_d2m_enc, audio_m2d_enc_freshjoin, fresh_store_rekey, ghost_session_rotation all pass (run 858).

**v0.3.9 deployed to prod 2026-09-30 ~17:25 UTC** (official deb, 23/23 bridges, 0 ERROR/WARN; all Light Voice ghost memberships fresh with the hourly refresh armed). **The remaining gap is closed in v0.3.10** (tag pushed ~17:50 UTC, deploy follows the packages job): the bridge bot's own E2EE roster membership now uses the same keeper — fixed `created_ts` per activation (kept across a disconnect-recovery rejoin), `expires` recomputed on every post, hourly refresh until the call ends. Helpers moved to `rtc::membership` and shared with the ghosts. Verified: unit tests; push CI; isolated e2e `audio_m2d_enc`, `audio_d2m_enc`, `audio_m2d_enc_freshjoin`, `fresh_store_rekey`, `ghost_session_rotation` all pass (run 858).
Sign in to join this conversation.
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
nether/nether-voicebridge#139
No description provided.