Subscriber inbound-frame decryptor wedges permanently when frames arrive before the peer key (encrypted-room Matrix→Discord silence) #73
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
dark/nether-voicebridge#73
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
P1, live-reproduced 2026-07-07, receive-side. In an encrypted room, when a human publisher's media frames reach the subscriber (
rtc::sessionplayback consumer) before that publisher's media key is installed into theKeyProvider, libwebrtc's inbound frame decryptor latches into a permanently-silent state: LiveKit reports E2EEstate=Ok, the frame counter keeps advancing, but every output frame is digital silence (peak_db=-96.0), and later key installs / rotations do not heal it. Result: that Matrix speaker is inaudible on Discord for the rest of the call. The listener direction (ghost→Matrix) is unaffected.This is the receive-side cousin of the #52 Ruffle wedge (which was send-side / ghost-outbound). #52's rotation fix does not apply here — this is the bridge's own inbound decryptor.
Trigger / race
The subscriber's
playback_consumerstarts and pulls the first frame the instant it subscribes to the track. The peer key arrives via a separate to-device event. When frames win that race, the decryptor sees ciphertext with no key → wedges. A subscriber already in the call when the publisher joins usually wins the key race (key known first) and is fine — so it manifests specifically on fresh joins (bridge (re)connects, or a Discord/Matrix user joins an encrypted call mid-session).Live evidence (stock v0.3.0-alpha.13, encrypted Dark Voice, fresh join)
State goes
MissingKey -> Ok, yet audio stays at the -96 dB silence floor permanently. A pre-block run where the subscriber was already keyed before the publisher's track arrived played real audio (-24..-55 dB) from the same code — confirming the race, not a config problem.Bisect note
Found while live-testing #66. Reproduced identically on stock alpha.13 with a fresh-join protocol, which exonerated the #66 branch (every earlier A/B flip tracked join-order, not the binary). So this is a pre-existing bug, unrelated to #66.
Likely fix directions (to investigate)
playback_consumer's frame intake (or the LiveKit subscribe) until the peer key for that identity is installed — i.e. gate first-frame decode on key-present.MissingKey -> Oktransition (or on eachinstall_peer_keyfor an already-subscribed identity), force the decryptor to re-initialize for that participant so buffered/subsequent frames decode (equivalent of the #52 rotation but receive-side).failure_tolerance/ key-ring behavior latches a participant's cryptor after N failures such that a laterset_keydoesn't re-arm it (KeyProviderOptions isfailure_tolerance:-1,ratchet_window_size:16in bothrtc::sessionandrtc::ghost).Impact
Encrypted-room Matrix→Discord audio is unreliable on any fresh join that loses the key race. Also blocks the encrypted-room live-validation of #66 (whose own logic is media-E2EE-agnostic and whose peer-key replay path is unit-tested, so #66 is not blocked by this).
Filed from the #66 live-test session.
Fix implemented on branch
fix/issue73-decryptor-wedge(a70dc4c) — runner-green (workspace tests + clippy-D warnings), staging binary pre-built on the VPS. Awaiting the fresh-join live test on an encrypted room before merge.Root-cause findings (upstream source archaeology)
Our livekit 0.7.45 ships a prebuilt libwebrtc built from
webrtc-sdk/webrtc @ m144_release(via rust-sdks release tagwebrtc-51ef663). Reading the actual decrypt path (api/crypto/frame_crypto_transformer.cc):failure_tolerance: -1,DecryptionFailure()always returnsfalse→ after the first successful decrypt flips state toOk, subsequent per-frame decrypt failures emit NO state change ever again — which is exactly why the logs showstate=Okwhile audio is dead. The state event is untrustworthy for detecting this wedge.peak_db=-96.0at a healthy frame cadence.SetKey→SetKeyFromMaterial+SetHasValidKeyresetshas_valid_key_and the failure counter, and the control run (key-before-frames) works with an identical post-install ring. So the wedged state lives in the per-receiver chain (transformer instance / receiver / decode pipeline) — inside the prebuilt libwebrtc, not patchable from Rust.Fix (Rust-layer, two parts)
TrackSubscribedtime (HealGate). When the first key for a keyless-subscribed identity is installed (to-device handler → session notifier channel), the track is unsubscribed → resubscribed once: the SDK mints a fresh RtpReceiver + FrameCryptor against the now-keyed provider, i.e. the proven-good "key before frames" join order. The existingTrackUnsubscribed/TrackSubscribedhandling restarts the playback consumer cleanly; puppets are untouched (membership owns release). Later installs/rotations don't re-trigger (the resubscribe re-records the identity as keyed).PeerKeyCachebeforeRoom::connect(previously replay ran post-connect, leaving a frames-before-key window on every disconnect-recovery rejoin).Unencrypted bridges: zero behavior change (no provider → gate inert).
Expected live-test signature (fresh join, encrypted room, human already publishing)
Live-validated on staging 2026-07-10 — automated, via a new #38 e2e scenario. Prod stayed up throughout (dedicated harness bots + Auto Test rooms).
New regression scenario:
audio_m2d_enc_freshjoin(branch commit3868f16)The existing m2d scenarios only speak after the key is installed, so they never exercised the frames-before-key race. The new scenario makes the #73 ordering deterministic: the Matrix speaker joins encrypted Auto Test 02 and streams continuously before the bridge process even starts, so the subscriber's first RTP deterministically beats the Olm-delivered key. It then asserts the full heal chain from the bridge log and oracles a post-heal probe on a Discord listener.
Result: PASS (74.2s)
Bridge log sequence, exactly as designed:
Exactly one heal (later key installs / heartbeat re-sends didn't re-trigger), zero bridge ERRORs, and the post-heal audio oracle: mean NCC 0.927, 7/8 good cycles (0.977–0.992; live-healthy band). On stock alpha.13 this exact ordering live-produced permanent
-96 dBsilence — the heal converts the wedge case into the working case end-to-end.Regression check:
audio_m2d_encalso passes on the fixed binary (mean NCC 0.871 — its historical band). One earlier run of it scored 0.126, fully explained by the documented shared-host gotcha (a CI build was pegging the cores during that exact window; the bridge-side log for that run shows a clean decrypt chain andstate=Okthroughout — re-run on a quiet host passed).Branch is runner-green (workspace tests + clippy
-D warnings) with the scenario included. Ready to merge on go-ahead.Shipped. Merged to master (
a70dc4cfix +3868f16regression scenario, ff), released in v0.3.0-alpha.14 and deployed to prod 2026-07-10 (deb upgrade alpha.13 → alpha.14; service active, all 5 bridges up, 0 errors).Validation trail:
-D warnings) on both commitsaudio_m2d_enc_freshjoinPASS (deterministic frames-before-key ordering; exactly one heal re-subscribe; post-heal oracle mean NCC 0.927) — comment 717audio_m2d_encregression PASS (0.871)The scenario stays in the e2e suite, so any future decryptor-wedge regression fails loudly. Closing.