Add support for a grace period for /sync (aka homeserver disconnected) interruptions

This commit is contained in:
fkwp
2026-04-22 15:18:50 +02:00
parent 2e149ad08c
commit 9e2eef09d4
5 changed files with 33 additions and 11 deletions

View File

@@ -12,6 +12,7 @@
"feature_use_device_session_member_events": true "feature_use_device_session_member_events": true
}, },
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"sync_disconnect_grace_period_ms": 60000,
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,

View File

@@ -97,6 +97,13 @@ export interface ConfigOptions {
enable_video?: boolean; 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. * These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options. * Take care when changing these options.
@@ -168,5 +175,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: { features: {
feature_use_device_session_member_events: true, 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", ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
}; };

View File

@@ -83,6 +83,7 @@ import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates"; import { type MuteStates } from "../MuteStates";
import { getUrlParams, HeaderStyle } from "../../UrlParams"; import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { Config } from "../../config/Config";
import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget"; import { ElementWidgetActions, widget } from "../../widget";
import { import {
@@ -536,6 +537,7 @@ export function createCallViewModel$(
scope, scope,
client, client,
matrixRTCSession, matrixRTCSession,
Config.get().sync_disconnect_grace_period_ms,
), ),
muteStates, muteStates,
joinMatrixRTC: (transport: LivekitTransportConfig) => { joinMatrixRTC: (transport: LivekitTransportConfig) => {

View File

@@ -96,19 +96,20 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is // 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. // 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", () => { 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); expect(hsConnected.combined$.value).toBe(false);
}); });
it("remains false while membership status is not Connected even if sync is Syncing", () => { 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); client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
}); });
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { 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 // Make sync loop OK
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection // Indicate probable leave before connection
@@ -118,7 +119,7 @@ describe("createHomeserverConnected$", () => {
}); });
it("becomes true only when all three conditions are satisfied", () => { 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 // 1. Sync loop connected
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected 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", () => { 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 // Reach connected state
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
@@ -140,7 +141,7 @@ describe("createHomeserverConnected$", () => {
}); });
it("drops back to false when membership status becomes disconnected", () => { 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); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
@@ -150,7 +151,7 @@ describe("createHomeserverConnected$", () => {
}); });
it("drops to false when ProbablyLeft is emitted after being true", () => { 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); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true); 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", () => { 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); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
@@ -174,7 +175,7 @@ describe("createHomeserverConnected$", () => {
}); });
it("composite sequence reflects each individual failure reason", () => { 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) // Initially false (sync error + disconnected + not probably left)
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);

View File

@@ -12,9 +12,11 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk"; 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 { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config";
import { type ObservableScope } from "../../ObservableScope"; import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { and$ } from "../../../utils/observable"; import { and$ } from "../../../utils/observable";
@@ -35,21 +37,29 @@ export interface HomeserverConnected {
* for the purposes of a MatrixRTC session. * for the purposes of a MatrixRTC session.
* *
* Becomes FALSE if ANY sub-condition is fulfilled: * 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 * 2. membershipStatus !== Status.Connected
* 3. probablyLeft === true * 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$( export function createHomeserverConnected$(
scope: ObservableScope, scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">, client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter & matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">, Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected { ): HomeserverConnected {
// Get grace period from parameter or config (default 60000ms)
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000;
const syncing$ = ( const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe( ).pipe(
startWith([client.getSyncState()]), startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing), map(([state]) => state === SyncState.Syncing),
debounceTime(graceMs),
); );
const rtsSession$ = scope.behavior<Status>( const rtsSession$ = scope.behavior<Status>(