diff --git a/config/config.sample.json b/config/config.sample.json index 126d7626..5fc63667 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -12,6 +12,7 @@ "feature_use_device_session_member_events": true }, "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "sync_disconnect_grace_period_ms": 60000, "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 1b120546..d5839fb3 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -97,6 +97,13 @@ export interface ConfigOptions { enable_video?: boolean; }; + /** + * Grace period in milliseconds to wait before reporting the sync loop as disconnected. + * This allows brief sync interruptions without triggering a reconnection message. + * Default is 60000ms (60 seconds). Set to 0 to disable the grace period. + */ + sync_disconnect_grace_period_ms?: number; + /** * These are low level options that are used to configure the MatrixRTC session. * Take care when changing these options. @@ -168,5 +175,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = { features: { feature_use_device_session_member_events: true, }, + sync_disconnect_grace_period_ms: 60000, ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", }; diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfd..8677f2d0 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -83,6 +83,7 @@ import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; import { getUrlParams, HeaderStyle } from "../../UrlParams"; +import { Config } from "../../config/Config"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { @@ -536,6 +537,7 @@ export function createCallViewModel$( scope, client, matrixRTCSession, + Config.get().sync_disconnect_grace_period_ms, ), muteStates, joinMatrixRTC: (transport: LivekitTransportConfig) => { diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts index 87ca35d0..e8861641 100644 --- a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts +++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts @@ -96,19 +96,20 @@ describe("createHomeserverConnected$", () => { // LLM generated test cases. They are a bit overkill but I improved the mocking so it is // easy enough to read them so I think they can stay. + // Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests it("is false when sync state is not Syncing", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); expect(hsConnected.combined$.value).toBe(false); }); it("remains false while membership status is not Connected even if sync is Syncing", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); expect(hsConnected.combined$.value).toBe(false); // membership still disconnected }); it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); // Make sync loop OK client.setSyncState(SyncState.Syncing); // Indicate probable leave before connection @@ -118,7 +119,7 @@ describe("createHomeserverConnected$", () => { }); it("becomes true only when all three conditions are satisfied", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); // 1. Sync loop connected client.setSyncState(SyncState.Syncing); expect(hsConnected.combined$.value).toBe(false); // not yet membership connected @@ -128,7 +129,7 @@ describe("createHomeserverConnected$", () => { }); it("drops back to false when sync loop leaves Syncing", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); // Reach connected state client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); @@ -140,7 +141,7 @@ describe("createHomeserverConnected$", () => { }); it("drops back to false when membership status becomes disconnected", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); expect(hsConnected.combined$.value).toBe(true); @@ -150,7 +151,7 @@ describe("createHomeserverConnected$", () => { }); it("drops to false when ProbablyLeft is emitted after being true", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); expect(hsConnected.combined$.value).toBe(true); @@ -160,7 +161,7 @@ describe("createHomeserverConnected$", () => { }); it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); expect(hsConnected.combined$.value).toBe(true); @@ -174,7 +175,7 @@ describe("createHomeserverConnected$", () => { }); it("composite sequence reflects each individual failure reason", () => { - const hsConnected = createHomeserverConnected$(scope, client, session); + const hsConnected = createHomeserverConnected$(scope, client, session, 0); // Initially false (sync error + disconnected + not probably left) expect(hsConnected.combined$.value).toBe(false); diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts index c8bcd021..84da6780 100644 --- a/src/state/CallViewModel/localMember/HomeserverConnected.ts +++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts @@ -12,9 +12,11 @@ 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, type Observable, debounceTime } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { Config } from "../../../config/Config"; + import { type ObservableScope } from "../../ObservableScope"; import { type Behavior } from "../../Behavior"; import { and$ } from "../../../utils/observable"; @@ -35,21 +37,29 @@ export interface HomeserverConnected { * for the purposes of a MatrixRTC session. * * Becomes FALSE if ANY sub-condition is fulfilled: - * 1. Sync loop is not in SyncState.Syncing + * 1. Sync loop is not in SyncState.Syncing (after grace period) * 2. membershipStatus !== Status.Connected * 3. probablyLeft === true + * + * @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect. + * If not provided, uses the config value (default 60000ms). */ export function createHomeserverConnected$( scope: ObservableScope, client: NodeStyleEventEmitter & Pick, matrixRTCSession: NodeStyleEventEmitter & Pick, + gracePeriodMs?: number, ): HomeserverConnected { + // Get grace period from parameter or config (default 60000ms) + const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000; + const syncing$ = ( fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> ).pipe( startWith([client.getSyncState()]), map(([state]) => state === SyncState.Syncing), + debounceTime(graceMs), ); const rtsSession$ = scope.behavior(