From 1c19304d63fd0817e35e3df292d4c05285fb6814 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:21:20 +0000 Subject: [PATCH 1/6] Skip local participant in MatrixAudioRenderer track filter The useTracks hook returns all audio tracks including the local participant's, but validIdentities intentionally excludes the local user (you shouldn't hear your own mic). This caused a spurious warning on every track update. Filter out local tracks early to resolve the existing TODO. Co-Authored-By: Claude Opus 4.6 --- src/livekit/MatrixAudioRenderer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/livekit/MatrixAudioRenderer.tsx b/src/livekit/MatrixAudioRenderer.tsx index 10579c1b9e..42e76279d9 100644 --- a/src/livekit/MatrixAudioRenderer.tsx +++ b/src/livekit/MatrixAudioRenderer.tsx @@ -76,10 +76,9 @@ export function LivekitRoomAudioRenderer({ .filter((ref) => ref.publication.kind === Track.Kind.Audio) // Only keep tracks from participants that are in the validIdentities list .filter((ref) => { + if (ref.participant.isLocal) return false; const isValid = validIdentities.includes(ref.participant.identity); if (!isValid) { - // TODO make sure to also skip the warn logging for the local identity - // Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call. prefixedLogger.warn( `Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`, `current members: ${validIdentities.join()}`, From 0142560c1324f3057834b4fb0df31cdbb640a623 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:21:43 +0000 Subject: [PATCH 2/6] Debounce homeserver connection signals to prevent spurious reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /sync request occasionally aborts (AbortError), causing syncing$ to briefly go false. This cascaded through combined$ → tracks paused → "Reconnecting..." toast, even though the SDK auto-retries and sync resumes within seconds. - Add 8s debounce on syncing$ since sync errors are transient - Add 2s debounce on combined$ for membershipConnected$/certainlyConnected$ - Add per-condition diagnostic logging to identify which signal flaps Co-Authored-By: Claude Opus 4.6 --- .../localMember/HomeserverConnected.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts index c8bcd02179..33c84755c9 100644 --- a/src/state/CallViewModel/localMember/HomeserverConnected.ts +++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts @@ -12,7 +12,17 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk"; -import { fromEvent, startWith, map, tap, type Observable } from "rxjs"; +import { + fromEvent, + startWith, + map, + tap, + switchMap, + timer, + of, + distinctUntilChanged, + type Observable, +} from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type ObservableScope } from "../../ObservableScope"; @@ -45,11 +55,28 @@ export function createHomeserverConnected$( matrixRTCSession: NodeStyleEventEmitter & Pick, ): HomeserverConnected { - const syncing$ = ( + const syncingRaw$ = ( fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> ).pipe( startWith([client.getSyncState()]), map(([state]) => state === SyncState.Syncing), + tap((v) => logger.info(`syncing$ (raw): ${v}`)), + ); + + // Sync errors are transient — the SDK auto-retries and /sync abort + // recoveries can take several seconds. Debounce the false→true transition + // with an 8s grace period so brief sync interruptions don't cascade. + const syncing$ = syncingRaw$.pipe( + switchMap((syncing) => + syncing + ? of(true) + : timer(8000).pipe( + map(() => false), + startWith(true), + ), + ), + distinctUntilChanged(), + tap((v) => logger.info(`syncing$ (debounced): ${v}`)), ); const rtsSession$ = scope.behavior( @@ -61,6 +88,7 @@ export function createHomeserverConnected$( const membershipConnected$ = rtsSession$.pipe( map((status) => status === Status.Connected), + tap((v) => logger.info(`membershipConnected$: ${v}`)), ); // This is basically notProbablyLeft$ @@ -77,10 +105,23 @@ export function createHomeserverConnected$( ).pipe( startWith(null), map(() => matrixRTCSession.probablyLeft !== true), + tap((v) => logger.info(`certainlyConnected$ (notProbablyLeft): ${v}`)), ); const combined$ = scope.behavior( and$(syncing$, membershipConnected$, certainlyConnected$).pipe( + // Don't immediately report disconnection for brief hiccups. + // Stay "connected" during a 2s grace period so that momentary sync + // errors or delayed-leave-event timeouts don't flap the UI. + switchMap((connected) => + connected + ? of(true) + : timer(2000).pipe( + map(() => false), + startWith(true), + ), + ), + distinctUntilChanged(), tap((connected) => { logger.info(`Homeserver connected update: ${connected}`); }), From 4675a5457ba0e7396f67a440fdb4112e5ac5961a Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:22:06 +0000 Subject: [PATCH 3/6] Debounce reconnecting$ and deduplicate local connection updates - Debounce reconnecting$ with 1.5s delay so brief disconnections don't flash the "Reconnecting..." toast to the user - Add distinctUntilChanged to localConnection$ to prevent redundant "Local connection updated" log spam (was firing 26+ times per event) - Add diagnostic logging on localConnectionState$ Co-Authored-By: Claude Opus 4.6 --- src/state/CallViewModel/localMember/LocalMember.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index eb641ca7cd..adbc0f6038 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -34,6 +34,7 @@ import { startWith, switchMap, tap, + timer, } from "rxjs"; import { type Logger } from "matrix-js-sdk/lib/logger"; import { deepCompare } from "matrix-js-sdk/lib/utils"; @@ -238,6 +239,7 @@ export const createLocalMembership$ = ({ return connectionData.getConnectionForTransport(localTransport); }), + distinctUntilChanged(), tap((connection) => { logger.info( `Local connection updated: ${connection?.transport?.livekit_service_url}`, @@ -393,6 +395,7 @@ export const createLocalMembership$ = ({ const localConnectionState$ = localConnection$.pipe( switchMap((connection) => (connection ? connection.state$ : of(null))), + tap((state) => logger.debug(`localConnectionState$: ${state}`)), ); const mediaState$: Behavior = scope.behavior( @@ -488,11 +491,18 @@ export const createLocalMembership$ = ({ /** * Whether we should tell the user that we're reconnecting to the call. + * Debounced so that brief (<1.5s) disconnections don't flash the UI. */ const reconnecting$ = scope.behavior( matrixAndLivekitConnected$.pipe( - pairwise(), - map(([prev, current]) => prev === true && current === false), + switchMap((connected) => + connected + ? of(false) + : timer(1500).pipe( + map(() => true), + startWith(false), + ), + ), ), false, ); From cb3b1855325c0c183f8ccb6cc87250a72b11c7af Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:55:13 +0000 Subject: [PATCH 4/6] Add E2EE key exchange timing diagnostics to MatrixKeyProvider Record a join timestamp when setRTCSession is called and log per-key timing on each EncryptionKeyChanged event: time since join (key delivery latency) and crypto.subtle.importKey duration. These diagnostics help identify whether slow media decryption after joining a call is caused by key delivery (Olm/sync) or key processing (Web Crypto). Filter console by [MatrixKeyProvider] to see the new logs. Co-Authored-By: Claude Opus 4.6 --- src/e2ee/matrixKeyProvider.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/e2ee/matrixKeyProvider.ts b/src/e2ee/matrixKeyProvider.ts index 63a96755fc..153541cceb 100644 --- a/src/e2ee/matrixKeyProvider.ts +++ b/src/e2ee/matrixKeyProvider.ts @@ -16,6 +16,7 @@ const logger = rootLogger.getChild("[MatrixKeyProvider]"); export class MatrixKeyProvider extends BaseKeyProvider { private rtcSession?: MatrixRTCSession; + private joinedAt?: number; public constructor() { super({ ratchetWindowSize: 10, keyringSize: 256 }); @@ -30,6 +31,8 @@ export class MatrixKeyProvider extends BaseKeyProvider { } this.rtcSession = rtcSession; + this.joinedAt = Date.now(); + logger.info(`setRTCSession called, recording join timestamp`); this.rtcSession.on( MatrixRTCSessionEvent.EncryptionKeyChanged, @@ -47,6 +50,14 @@ export class MatrixKeyProvider extends BaseKeyProvider { membershipParts: CallMembershipIdentityParts, rtcBackendIdentity: string, ): void => { + const keyReceivedAt = Date.now(); + const timeSinceJoin = this.joinedAt + ? keyReceivedAt - this.joinedAt + : undefined; + logger.info( + `Key received ${timeSinceJoin !== undefined ? `${timeSinceJoin}ms after join` : "(no join timestamp)"} for ${membershipParts.userId}:${membershipParts.deviceId} index=${encryptionKeyIndex}`, + ); + crypto.subtle .importKey("raw", encryptionKey, "HKDF", false, [ "deriveBits", @@ -54,14 +65,15 @@ export class MatrixKeyProvider extends BaseKeyProvider { ]) .then( (keyMaterial) => { + const importDuration = Date.now() - keyReceivedAt; this.onSetEncryptionKey( keyMaterial, rtcBackendIdentity, encryptionKeyIndex, ); - logger.debug( - `Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`, + logger.info( + `Key imported in ${importDuration}ms and sent to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`, ); }, (e) => { From 95755935e7a11d34d5f87aea84d7c49bbe9e3fd2 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:55:39 +0000 Subject: [PATCH 5/6] Expose useKeyDelay and keyRotationGracePeriodMs config options Add use_key_delay_ms and key_rotation_grace_period_ms to the matrix_rtc_session config block, allowing operators to tune E2EE key exchange timing without code changes. - use_key_delay_ms: delay between sending a new key and encrypting with it, giving other participants time to receive the key (SDK default 1s) - key_rotation_grace_period_ms: grace period during which new joiners reuse the existing key instead of triggering another rotation (SDK default 10s) Both values are passed through to joinRTCSession and documented in the sample/devenv config files. Co-Authored-By: Claude Opus 4.6 --- config/config.devenv.json | 4 +++- config/config.sample.json | 4 +++- src/config/ConfigOptions.ts | 17 +++++++++++++++++ .../CallViewModel/localMember/LocalMember.ts | 3 +++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/config/config.devenv.json b/config/config.devenv.json index df0ff4c18d..d5b2af8d58 100644 --- a/config/config.devenv.json +++ b/config/config.devenv.json @@ -14,6 +14,8 @@ "membership_event_expiry_ms": 180000000, "delayed_leave_event_delay_ms": 18000, "delayed_leave_event_restart_ms": 4000, - "network_error_retry_ms": 100 + "network_error_retry_ms": 100, + "use_key_delay_ms": 1000, + "key_rotation_grace_period_ms": 10000 } } diff --git a/config/config.sample.json b/config/config.sample.json index 126d76265f..04a8eaef99 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -17,6 +17,8 @@ "membership_event_expiry_ms": 180000000, "delayed_leave_event_delay_ms": 18000, "delayed_leave_event_restart_ms": 4000, - "network_error_retry_ms": 100 + "network_error_retry_ms": 100, + "use_key_delay_ms": 1000, + "key_rotation_grace_period_ms": 10000 } } diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 4da633caf3..cfd85a257d 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -151,6 +151,23 @@ export interface ConfigOptions { * This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours. */ membership_event_expiry_ms?: number; + + /** + * The delay (in milliseconds) between sending a new encryption key and + * starting to encrypt media with it. This gives other participants time + * to receive the key before media is encrypted with it. + * SDK default: 1000 + */ + use_key_delay_ms?: number; + + /** + * The grace period (in milliseconds) after a key rotation during which + * new joiners can reuse the existing key instead of triggering another + * rotation. Higher values reduce redundant rotations when multiple + * participants join in quick succession. + * SDK default: 10000 + */ + key_rotation_grace_period_ms?: number; }; } diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index adbc0f6038..92482cf653 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -778,6 +778,9 @@ export function enterRTCSession( matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms, networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms, makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms, + useKeyDelay: matrixRtcSessionConfig?.use_key_delay_ms, + keyRotationGracePeriodMs: + matrixRtcSessionConfig?.key_rotation_grace_period_ms, membershipEventExpiryMs: matrixRtcSessionConfig?.membership_event_expiry_ms, useExperimentalToDeviceTransport: true, From 2a5a3eebb7420c46f71d4e473684a1ef4ede37cb Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:56:45 +0000 Subject: [PATCH 6/6] Pre-warm Olm sessions during lobby to speed up E2EE key exchange When a user is in the call lobby with per-participant E2EE enabled, call CryptoApi.prepareToEncrypt(room) to trigger /keys/claim for any devices we don't yet have Olm sessions with. This moves the Olm session establishment cost from join-time to lobby-time, so encryption keys can be delivered immediately when the user clicks Join. In testing, key delivery to some participants took ~48s after join due to /keys/claim round-trips. With pre-warming, the Olm sessions are already established by the time the call is joined. Co-Authored-By: Claude Opus 4.6 --- src/e2ee/usePrewarmOlmSessions.ts | 52 +++++++++++++++++++++++++++++++ src/room/GroupCallView.tsx | 10 ++++++ 2 files changed, 62 insertions(+) create mode 100644 src/e2ee/usePrewarmOlmSessions.ts diff --git a/src/e2ee/usePrewarmOlmSessions.ts b/src/e2ee/usePrewarmOlmSessions.ts new file mode 100644 index 0000000000..5978ac412d --- /dev/null +++ b/src/e2ee/usePrewarmOlmSessions.ts @@ -0,0 +1,52 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useEffect } from "react"; +import { type MatrixClient, type Room } from "matrix-js-sdk"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; + +const logger = rootLogger.getChild("[OlmPrewarm]"); + +/** + * Pre-warms Olm sessions with current call participants while the user is in + * the lobby. This avoids a `/keys/claim` round-trip when actually joining the + * call and distributing E2EE media encryption keys. + * + * Internally calls `CryptoApi.prepareToEncrypt(room)` which triggers + * `KeyClaimManager.ensureSessionsForUsers()` for all room members, establishing + * Olm sessions with any devices we haven't communicated with before. + * + * @param client - The Matrix client. + * @param room - The room the call is in. + * @param memberships - Current call memberships (used to trigger re-warming + * when new participants join while the user is still in the lobby). + * @param enabled - Pass `false` to disable (e.g. when the user has already + * joined the call or E2EE is not in use). + */ +export function usePrewarmOlmSessions( + client: MatrixClient, + room: Room, + memberships: CallMembership[], + enabled: boolean, +): void { + useEffect(() => { + if (!enabled) return; + if (memberships.length === 0) return; + + const crypto = client.getCrypto(); + if (!crypto) { + logger.debug("No crypto available, skipping Olm session pre-warming"); + return; + } + + logger.info( + `Pre-warming Olm sessions for room ${room.roomId} (${memberships.length} call members)`, + ); + crypto.prepareToEncrypt(room); + }, [client, room, memberships, enabled]); +} diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfd11ff32c..2f220cca30 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -56,6 +56,7 @@ import { useUrlParams, } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; +import { usePrewarmOlmSessions } from "../e2ee/usePrewarmOlmSessions"; import { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds, @@ -206,6 +207,15 @@ export const GroupCallView: FC = ({ }; }, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]); + // Pre-warm Olm sessions with call participants while in the lobby so that + // E2EE key exchange is faster when the user actually joins. + usePrewarmOlmSessions( + client, + room, + memberships, + !joined && e2eeSystem.kind === E2eeType.PER_PARTICIPANT, + ); + // Count each member only once, regardless of how many devices they use const participantCount = useMemo( () => new Set(memberships.map((m) => m.userId!)).size,