[SDK] update state structure (less duplication, one state enum)

- remove media from LocalMembership file
This commit is contained in:
Timo K.
2026-10-08 13:53:57 +02:00
parent f49c1cd3d4
commit 3cdb452d5d
12 changed files with 500 additions and 497 deletions
+24 -26
View File
@@ -132,7 +132,7 @@ participation split in
the sections below still describe `MatrixRTCClient` until that lands. the sections below still describe `MatrixRTCClient` until that lands.
| Word | Meaning here | Elsewhere | | Word | Meaning here | Elsewhere |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event | | slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `status$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot | | participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `status$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" | | member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
@@ -263,20 +263,26 @@ export function createMatrixRTCClient(
room: Room, room: Room,
options: MatrixRTCClientOptions, options: MatrixRTCClientOptions,
): MatrixRTCClient; ): MatrixRTCClient;
```` ```
### The client ### The client
```ts ```ts
export type ConnectionStatus = /** The homeserver link that is down, in the order they depend on each other. */
| "waitingForTransport" export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
/** Why the client is not connected: the first failing of its three links. */ /** Why the participation is not connected: the first failing link, in dependency order. */
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media"; export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
| { kind: "waitingForTransport" }
| { kind: "connecting" }
| { kind: "connected" }
| { kind: "reconnecting"; reason: DisconnectReason }
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** /**
* An error raised by the client. `code` and `category` say what went wrong in * An error raised by the client. `code` and `category` say what went wrong in
@@ -290,21 +296,12 @@ export class MatrixRTCError extends Error {
export interface MatrixRTCClient { export interface MatrixRTCClient {
join(): void; join(): void;
leave(): void; leave(): void;
/** The local member's state machine, collapsed to what a host shows. */
status$: Behavior<ConnectionStatus>;
/** Connected to the homeserver, the session and the local transport, all three. */
connected$: Behavior<boolean>;
/** Connected once, and currently not. */
reconnecting$: Behavior<boolean>;
/** Null while connected, the first failing link otherwise. */
disconnectReason$: Behavior<DisconnectReason | null>;
/** /**
* A transport, Matrix or connection error that stops the session. Null * The Matrix membership combined with the local media connection. One
* while fine. A failed publication is not fatal: the member can still * value: a reason exists only while `reconnecting`, an error only once
* receive, so it stays on the local member's media. * `failed`.
*/ */
fatalError$: Behavior<MatrixRTCError | null>; state$: Behavior<ParticipationState>;
/** Null until our own membership has been seen in the room. */ /** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>; localMember$: Behavior<LocalRTCMember | null>;
@@ -749,9 +746,10 @@ public API only.
join; creating the tracks earlier, for a lobby preview, is still missing. join; creating the tracks earlier, for a lobby preview, is still missing.
- **`applicationData` after the join.** The option is read once; there is no - **`applicationData` after the join.** The option is read once; there is no
way to update it on a live membership yet. way to update it on a live membership yet.
- **Raw local state.** The local member's state machine (`LocalMemberState`) stays - **Raw membership state.** The Matrix side's own state machine
internal; `status$`, `disconnectReason$` and `fatalError$` are its public view. (`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
A developer panel may want it as `LocalRTCMember.state$`. participation is its public view combined with the media connection. A
developer panel may want it as `LocalRTCMember.membershipState$`.
- **Developer panel.** `resolved$` on each connected transport covers what the - **Developer panel.** `resolved$` on each connected transport covers what the
panel shows today. Anything beyond that (LiveKit room state, connection panel shows today. Anything beyond that (LiveKit room state, connection
quality) needs an opaque `debug$` per transport. quality) needs an opaque `debug$` per transport.
+7 -6
View File
@@ -77,11 +77,9 @@ async function start(
const rtcParticipation = slot.join({ const rtcParticipation = slot.join({
publish: [{ source: "microphone" }, { source: "camera" }], publish: [{ source: "microphone" }, { source: "camera" }],
}); });
rtcParticipation.status$.pipe(scope.bind()).subscribe((s) => { rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
status.textContent = s; status.textContent =
}); state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
rtcParticipation.fatalError$.pipe(scope.bind()).subscribe((error) => {
if (error !== null) status.textContent = `Error: ${error.message}`;
}); });
showMembers(scope, rtcParticipation); showMembers(scope, rtcParticipation);
showMessages(scope, rtcParticipation); showMessages(scope, rtcParticipation);
@@ -179,7 +177,10 @@ function showMembers(
rtcParticipation: RTCParticipation, rtcParticipation: RTCParticipation,
): void { ): void {
const tiles = new Map<string, HTMLElement>(); const tiles = new Map<string, HTMLElement>();
combineLatest([rtcParticipation.localMember$, rtcParticipation.remoteMembers$]) combineLatest([
rtcParticipation.localMember$,
rtcParticipation.remoteMembers$,
])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([local, remote]) => { .subscribe(([local, remote]) => {
const current = local === null ? remote : [local, ...remote]; const current = local === null ? remote : [local, ...remote];
+27 -19
View File
@@ -14,15 +14,17 @@ import {
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
distinctUntilChanged,
filter, filter,
from, from,
fromEvent, fromEvent,
map, map,
Observable, Observable,
scan,
} from "rxjs"; } from "rxjs";
import { defaultSessionTimings, MatrixRTCMode } from "./config"; import { defaultSessionTimings, MatrixRTCMode } from "./config";
@@ -34,6 +36,7 @@ import { mapScoped } from "./utils/mapScoped";
import { import {
type DataMessage, type DataMessage,
type LocalRTCMember, type LocalRTCMember,
type ParticipationState,
type RemoteRTCMember, type RemoteRTCMember,
type RTCMember, type RTCMember,
type RTCParticipation, type RTCParticipation,
@@ -59,8 +62,8 @@ import {
createRemoteRTCMember, createRemoteRTCMember,
} from "./matrixrtc/Members"; } from "./matrixrtc/Members";
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors"; import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
import { fatalError, participationStatus } from "./matrixrtc/status";
import { type TransportRegistry } from "./matrixrtc/Transports"; import { type TransportRegistry } from "./matrixrtc/Transports";
import { participationState } from "./participationState";
/** What the slot lends a participation. */ /** What the slot lends a participation. */
export interface ParticipationDeps { export interface ParticipationDeps {
@@ -137,7 +140,6 @@ export function createRTCParticipation(
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
local: backend.local,
delegateDelayedLeave: async (delayId) => delegateDelayedLeave: async (delayId) =>
backend.delegateDelayedLeave(delayId), backend.delegateDelayedLeave(delayId),
preparedTransport$, preparedTransport$,
@@ -228,22 +230,32 @@ export function createRTCParticipation(
filter((message) => message !== null), filter((message) => message !== null),
); );
const left$ = new BehaviorSubject(false); // Nothing leaves this device while it may already have been dropped from
const status$ = scope.behavior( // the session: the member would show as away while still being heard
combineLatest( localMembership.state$
[ .pipe(
map((membership) => membership.kind === "joined"),
distinctUntilChanged(),
scope.bind(),
)
.subscribe((publish) => backend.local.setPublishing(publish));
const state$ = scope.behavior<ParticipationState>(
combineLatest([
localMembership.state$, localMembership.state$,
localMembership.connected$, backend.local.connectionState$,
localMembership.reconnecting$, ]).pipe(
left$, scan(
], (previous, [membership, media]) =>
participationStatus, participationState(previous, membership, media),
{ kind: "waitingForTransport" } as ParticipationState,
),
distinctUntilChanged(deepCompare),
), ),
); );
const leave = (): void => { const leave = (): void => {
if (left$.value) return; if (state$.value.kind === "left") return;
left$.next(true);
localMembership.leave(); localMembership.leave();
scope.end(); scope.end();
}; };
@@ -253,11 +265,7 @@ export function createRTCParticipation(
participation: { participation: {
slot, slot,
leave, leave,
status$, state$,
connected$: localMembership.connected$,
reconnecting$: localMembership.reconnecting$,
disconnectReason$: localMembership.disconnectReason$,
fatalError$: scope.behavior(localMembership.state$.pipe(map(fatalError))),
localMember$, localMember$,
remoteMembers$, remoteMembers$,
publish: async (request) => backend.local.publish(request), publish: async (request) => backend.local.publish(request),
+31 -18
View File
@@ -121,31 +121,44 @@ export interface RTCParticipationOptions {
backend?: MediaBackendFactory; backend?: MediaBackendFactory;
} }
export type ParticipationStatus = /**
| "waitingForTransport" * The homeserver link that is down, in the order they depend on each other:
| "connecting" * without sync the membership is unknowable, and without a confirmed
| "connected" * membership the delayed leave cannot be refreshed.
| "reconnecting" */
| "disconnected" export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
/** `leave()` was called; the participation is over and stays so. */
| "left";
/** Why the participation is not connected: the first failing of its three links. */ /**
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media"; * Why the participation is not connected: the first failing link, in the
* order they depend on each other. The media connection comes last because
* it is worth little while the Matrix side is down.
*/
export type DisconnectReason = MatrixDisconnectReason | "media";
export type ParticipationState =
/** Asking the homeserver for a transport, or preparing it. */
| { kind: "waitingForTransport" }
/** Transport known; joining the session and connecting the media for the first time. */
| { kind: "connecting" }
/** Membership joined and the local media connection up. */
| { kind: "connected" }
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
| { kind: "reconnecting"; reason: DisconnectReason }
/**
* The membership or the media connection ended with an error. Terminal
* unless `left`. A failed publication is not fatal: the member can still
* receive, so it stays on the local member's media.
*/
| { kind: "failed"; error: MatrixRTCError }
/** `leave()` was called. Terminal. */
| { kind: "left" };
/** The span between our `join()` of a slot and our `leave()`. */ /** The span between our `join()` of a slot and our `leave()`. */
export interface RTCParticipation { export interface RTCParticipation {
readonly slot: RTCSlot; readonly slot: RTCSlot;
/** Sends the leave, tears the connections down and ends the participation. */ /** Sends the leave, tears the connections down and ends the participation. */
leave(): void; leave(): void;
/** Collapsed view of the local member's state machine. */ state$: Behavior<ParticipationState>;
status$: Behavior<ParticipationStatus>;
connected$: Behavior<boolean>;
reconnecting$: Behavior<boolean>;
/** Null while connected, the first failing link otherwise. */
disconnectReason$: Behavior<DisconnectReason | null>;
/** A transport, Matrix or connection error that stops the participation. */
fatalError$: Behavior<MatrixRTCError | null>;
/** Null until our own membership has been seen in the room. */ /** Null until our own membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>; localMember$: Behavior<LocalRTCMember | null>;
+74 -41
View File
@@ -100,13 +100,13 @@ describe("createHomeserverConnected$", () => {
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests // Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("reports syncing reason when sync state is not Syncing", () => { it("reports syncing reason when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.combined$.value).toEqual([false, "sync"]); expect(hsConnected.disconnectReason$.value).toEqual("sync");
}); });
it("reports membership reason when sync is Syncing but membership is not Connected", () => { it("reports membership reason when sync is Syncing but membership is not Connected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]); expect(hsConnected.disconnectReason$.value).toEqual("membership");
}); });
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => { it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
@@ -116,17 +116,17 @@ describe("createHomeserverConnected$", () => {
// Indicate probable leave before connection // Indicate probable leave before connection
session.setProbablyLeft(true); session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
}); });
it("becomes null (connected) only when all three conditions are satisfied", () => { it("becomes null (connected) only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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).toEqual([false, "membership"]); // not yet membership connected expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
// 2. Membership connected // 2. Membership connected
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
}); });
it("returns syncing reason when sync loop leaves Syncing", () => { it("returns syncing reason when sync loop leaves Syncing", () => {
@@ -134,76 +134,76 @@ describe("createHomeserverConnected$", () => {
// Reach connected state // Reach connected state
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
// Sync loop error => should report syncing reason // Sync loop error => should report syncing reason
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]); expect(hsConnected.disconnectReason$.value).toEqual("sync");
}); });
it("returns membershipConnected reason when membership status becomes disconnected", () => { it("returns membershipConnected reason when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setMembershipStatus(Status.Disconnected); session.setMembershipStatus(Status.Disconnected);
expect(hsConnected.combined$.value).toEqual([false, "membership"]); expect(hsConnected.disconnectReason$.value).toEqual("membership");
}); });
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => { it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
}); });
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => { it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Simulate clearing the flag (in realistic scenario membership manager would update) // Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
}); });
it("composite sequence reflects each individual failure reason", () => { it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially: sync error + membership disconnected → syncing wins (highest priority) // Initially: sync error + membership disconnected → syncing wins (highest priority)
expect(hsConnected.combined$.value).toEqual([false, "sync"]); expect(hsConnected.disconnectReason$.value).toEqual("sync");
// Fix sync only → membershipConnected is now the blocker // Fix sync only → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]); expect(hsConnected.disconnectReason$.value).toEqual("membership");
// Fix membership → all conditions satisfied // Fix membership → all conditions satisfied
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
// Introduce probablyLeft → certainlyConnected // Introduce probablyLeft → certainlyConnected
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
// Restore notProbablyLeft → connected again // Restore notProbablyLeft → connected again
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]); expect(hsConnected.disconnectReason$.value).toEqual(null);
// Drop sync → syncing reason // Drop sync → syncing reason
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]); expect(hsConnected.disconnectReason$.value).toEqual("sync");
}); });
}); });
describe("createHomeserverConnected$ - combined$ reason values", () => { describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
let scope: ObservableScope; let scope: ObservableScope;
let client: MockMatrixClient; let client: MockMatrixClient;
let session: MockMatrixRTCSession; let session: MockMatrixRTCSession;
@@ -222,57 +222,87 @@ describe("createHomeserverConnected$ - combined$ reason values", () => {
scope.end(); scope.end();
}); });
it("is [true, null] when all three conditions are satisfied", () => { it("is null when all three conditions are satisfied", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([true, null]); expect(disconnectReason$.value).toEqual(null);
}); });
it("reports syncing when sync loop is not Syncing", () => { it("reports syncing when sync loop is not Syncing", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// client starts with SyncState.Error, membership also disconnected // client starts with SyncState.Error, membership also disconnected
expect(combined$.value).toEqual([false, "sync"]); expect(disconnectReason$.value).toEqual("sync");
}); });
it("reports membershipConnected when sync is fine but membership is not Connected", () => { it("reports membershipConnected when sync is fine but membership is not Connected", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
// session still Status.Disconnected // session still Status.Disconnected
expect(combined$.value).toEqual([false, "membership"]); expect(disconnectReason$.value).toEqual("membership");
}); });
it("reports certainlyConnected when probablyLeft is true", () => { it("reports certainlyConnected when probablyLeft is true", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(combined$.value).toEqual([false, "probablyLeft"]); expect(disconnectReason$.value).toEqual("probablyLeft");
}); });
it("prioritises syncing over membershipConnected when both fail", () => { it("prioritises syncing over membershipConnected when both fail", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Both sync (Error) and membership (Disconnected) are failing // Both sync (Error) and membership (Disconnected) are failing
expect(combined$.value).toEqual([false, "sync"]); expect(disconnectReason$.value).toEqual("sync");
}); });
it("updates reason as conditions change", () => { it("updates reason as conditions change", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0); const { disconnectReason$ } = createHomeserverConnected$(
scope,
client,
session,
0,
);
// Initially: syncing fails // Initially: syncing fails
expect(combined$.value).toEqual([false, "sync"]); expect(disconnectReason$.value).toEqual("sync");
// Fix sync → membershipConnected is now the blocker // Fix sync → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(combined$.value).toEqual([false, "membership"]); expect(disconnectReason$.value).toEqual("membership");
// Fix membership → probablyLeft makes certainlyConnected fail // Fix membership → probablyLeft makes certainlyConnected fail
session.setProbablyLeft(true); session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([false, "probablyLeft"]); expect(disconnectReason$.value).toEqual("probablyLeft");
// Clear probablyLeft → all conditions satisfied // Clear probablyLeft → all conditions satisfied
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(combined$.value).toEqual([true, null]); expect(disconnectReason$.value).toEqual(null);
}); });
}); });
@@ -303,10 +333,13 @@ describe("createHomeserverConnected$ - Grace Period", () => {
session, session,
GRACE_PERIOD, GRACE_PERIOD,
); );
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, { expectObservable(hsConnected.disconnectReason$).toBe(
y: [true, null], expectedConnectedMarbles,
n: [false, "sync"], {
}); y: null,
n: "sync",
},
);
}); });
} }
+14 -26
View File
@@ -29,6 +29,7 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type ObservableScope } from "../reactive/ObservableScope"; import { type ObservableScope } from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior"; import { type Behavior } from "../reactive/Behavior";
import { defaultSessionTimings } from "../config"; import { defaultSessionTimings } from "../config";
import { type MatrixDisconnectReason } from "../api";
type NodeEventHandler = (...args: unknown[]) => void; type NodeEventHandler = (...args: unknown[]) => void;
@@ -37,22 +38,16 @@ interface NodeStyleEventEmitter {
removeListener(eventName: string | symbol, handler: NodeEventHandler): this; removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
} }
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
export interface HomeserverConnected { export interface HomeserverConnected {
/** /** Null while the homeserver connection is healthy, the first failing link otherwise. */
* Emits `[true, null]` when the homeserver connection is healthy, or disconnectReason$: Behavior<MatrixDisconnectReason | null>;
* `[false, reason]` when one of the three sub-conditions fails.
*/
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
rtsSession$: Behavior<Status>;
} }
/** /**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver * Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session. * for the purposes of a MatrixRTC session.
* *
* `combined$` emits `null` when all conditions are satisfied, or the first failing * `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
* reason (priority: syncing > membershipConnected > certainlyConnected): * reason (priority: syncing > membershipConnected > certainlyConnected):
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync" * 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
* 2. membershipStatus !== Status.Connected → "membership" * 2. membershipStatus !== Status.Connected → "membership"
@@ -118,26 +113,19 @@ export function createHomeserverConnected$(
map(() => matrixRTCSession.probablyLeft !== true), map(() => matrixRTCSession.probablyLeft !== true),
); );
const combined$ = scope.behavior( const disconnectReason$ = scope.behavior(
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe( combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
map( map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
([syncing, membership, certainly]): [ if (!syncing) return "sync";
boolean, if (!membership) return "membership";
HomeserverDisconnectReason | null, if (!certainly) return "probablyLeft";
] => { return null;
if (!syncing) return [false, "sync"]; }),
if (!membership) return [false, "membership"]; tap((reason) => {
if (!certainly) return [false, "probablyLeft"]; logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
return [true, null];
},
),
tap(([connected, reason]) => {
logger.info(
`Homeserver connected update: ${connected ? "connected" : reason}`,
);
}), }),
), ),
); );
return { combined$, rtsSession$ }; return { disconnectReason$ };
} }
+58 -104
View File
@@ -5,77 +5,57 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/lib/logger";
import { Status, type Transport } from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
EMPTY,
type Observable,
Subject,
throwError,
} from "rxjs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
import { defaultSessionTimings } from "../config"; import { defaultSessionTimings } from "../config";
import { MatrixRTCError } from "../errors"; import { MatrixRTCError } from "../errors";
import {
type LocalMediaBackend,
MediaConnectionState,
} from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected"; import { type HomeserverConnected } from "./HomeserverConnected";
import { import {
createLocalMembership$, createLocalMembership$,
type LocalMembership, type LocalMembership,
type PreparedTransport, type PreparedTransport,
PublishState,
TransportState,
} from "./LocalMembership"; } from "./LocalMembership";
import { flushPromises, testScope } from "../utils/test"; import { flushPromises, testScope } from "../utils/test";
const transport: Transport = { type: "livekit", livekit_service_url: "u" }; const transport: Transport = { type: "livekit", livekit_service_url: "u" };
describe("createLocalMembership$", () => { describe("createLocalMembership$", () => {
it("waits for the transport, then reports the media connection and the join", async () => { it("waits for the transport, joins, and leaves on leave()", async () => {
const { membership, local, prepared$ } = setup(); const { membership, homeserver, prepared$ } = setup();
expect(membership.state$.value).toBe(TransportState.Waiting); expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
homeserver.disconnectReason$.next("membership");
prepared$.next({ transport, canDelegateDelayedLeave: false }); prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ expect(membership.state$.value).toEqual({ kind: "joining" });
media: { connection: MediaConnectionState.Initialized },
matrix: Status.Connected,
});
local.connectionState$.next(MediaConnectionState.Connected); homeserver.disconnectReason$.next(null);
await flushPromises(); expect(membership.state$.value).toEqual({ kind: "joined" });
expect(membership.state$.value).toEqual({
media: PublishState.Publishing,
matrix: Status.Connected,
});
expect(membership.connected$.value).toBe(true);
membership.leave(); membership.leave();
expect(membership.state$.value).toEqual({
media: PublishState.WaitingForUser,
matrix: Status.Connected,
});
});
it("publishes only while joined and the homeserver is reachable", async () => {
const { membership, local, homeserver } = setup();
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
homeserver.combined$.next([false, "sync"]);
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
homeserver.combined$.next([true, null]);
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
membership.leave();
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
// A sync outage after leaving must not bring the media back
homeserver.combined$.next([false, "sync"]);
homeserver.combined$.next([true, null]);
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
await flushPromises(); await flushPromises();
expect(membership.state$.value).toEqual({ kind: "left" });
});
it("reconnects only after it was joined once", () => {
const { membership, homeserver, prepared$ } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({ kind: "joined" });
homeserver.disconnectReason$.next("sync");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "sync",
});
homeserver.disconnectReason$.next("probablyLeft");
expect(membership.state$.value).toEqual({
kind: "reconnecting",
reason: "probablyLeft",
});
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toEqual({ kind: "joined" });
}); });
it("joins with the delegated timings and hands over each delay id where the backend can", async () => { it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
@@ -107,57 +87,54 @@ describe("createLocalMembership$", () => {
expect(delegate).not.toHaveBeenCalled(); expect(delegate).not.toHaveBeenCalled();
}); });
it("reports a transport that could not be prepared as fatal", () => { it("reports a transport that could not be prepared as failed", () => {
const error = new MatrixRTCError("no token"); const error = new MatrixRTCError("no token");
const { membership } = setup(throwError(() => error)); const { membership } = setup(throwError(() => error));
expect(membership.state$.value).toBe(error); expect(membership.state$.value).toEqual({ kind: "failed", error });
}); });
it("reports a connection loss and a publish failure in the media state", () => { it("reports the membership manager giving up as failed, and stays failed", () => {
const { membership, local, prepared$ } = setup(); const { membership, homeserver, prepared$, membershipManagerError$ } =
setup();
prepared$.next({ transport, canDelegateDelayedLeave: false }); prepared$.next({ transport, canDelegateDelayedLeave: false });
local.connectionState$.next(MediaConnectionState.Connected); membershipManagerError$.next(new Error("gave up"));
local.connectionState$.next(MediaConnectionState.Reconnecting);
expect(membership.state$.value).toEqual({
media: { connection: MediaConnectionState.Reconnecting },
matrix: Status.Connected,
});
expect(membership.disconnectReason$.value).toBe("media");
expect(membership.reconnecting$.value).toBe(true);
const failure = new MatrixRTCError("no camera");
local.publishError$.next(failure);
const state = membership.state$.value; const state = membership.state$.value;
expect(typeof state === "object" && "media" in state && state.media).toBe( expect(state.kind).toBe("failed");
failure,
); homeserver.disconnectReason$.next("sync");
homeserver.disconnectReason$.next(null);
expect(membership.state$.value).toBe(state);
});
it("is left after a failure once leave() is called", () => {
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
membership.leave();
expect(membership.state$.value).toEqual({ kind: "left" });
}); });
}); });
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): { function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
membership: LocalMembership; membership: LocalMembership;
local: FakeLocalMediaBackend;
homeserver: FakeHomeserverConnected; homeserver: FakeHomeserverConnected;
prepared$: Subject<PreparedTransport>; prepared$: Subject<PreparedTransport>;
delayId$: BehaviorSubject<string | null>; delayId$: BehaviorSubject<string | null>;
membershipManagerError$: Subject<unknown>;
joinMatrixRTC: ReturnType<typeof vi.fn>; joinMatrixRTC: ReturnType<typeof vi.fn>;
delegate: ReturnType<typeof vi.fn>; delegate: ReturnType<typeof vi.fn>;
} { } {
const prepared$ = new Subject<PreparedTransport>(); const prepared$ = new Subject<PreparedTransport>();
const local = fakeLocalMediaBackend();
const homeserver = fakeHomeserverConnected(); const homeserver = fakeHomeserverConnected();
const delayId$ = new BehaviorSubject<string | null>(null); const delayId$ = new BehaviorSubject<string | null>(null);
const membershipManagerError$ = new Subject<unknown>();
const joinMatrixRTC = vi.fn(); const joinMatrixRTC = vi.fn();
const delegate = vi.fn(async () => Promise.resolve()); const delegate = vi.fn(async () => Promise.resolve());
const membership = createLocalMembership$({ const membership = createLocalMembership$({
scope: testScope(), scope: testScope(),
local,
delegateDelayedLeave: delegate, delegateDelayedLeave: delegate,
preparedTransport$: preparedTransportWithErrors$ ?? prepared$, preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
homeserverConnected: homeserver, homeserverConnected: homeserver,
joinMatrixRTC, joinMatrixRTC,
membershipManagerError$: new Subject(), membershipManagerError$,
matrixRTCSession: { matrixRTCSession: {
leaveRoomSession: vi.fn(async () => Promise.resolve(true)), leaveRoomSession: vi.fn(async () => Promise.resolve(true)),
}, },
@@ -167,48 +144,25 @@ function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
}); });
return { return {
membership, membership,
local,
homeserver, homeserver,
prepared$, prepared$,
delayId$, delayId$,
membershipManagerError$,
joinMatrixRTC, joinMatrixRTC,
delegate, delegate,
}; };
} }
type FakeLocalMediaBackend = Omit< type FakeHomeserverConnected = {
LocalMediaBackend, disconnectReason$: BehaviorSubject<
"connectionState$" | "publishError$" | "setPublishing" HomeserverConnected["disconnectReason$"]["value"]
> & { >;
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
publishError$: BehaviorSubject<Error | null>;
setPublishing: ReturnType<typeof vi.fn<LocalMediaBackend["setPublishing"]>>;
};
function fakeLocalMediaBackend(): FakeLocalMediaBackend {
return {
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
MediaConnectionState.Initialized,
),
tracks$: new BehaviorSubject(null),
encryptionError$: EMPTY,
setPublishing: vi.fn<LocalMediaBackend["setPublishing"]>(),
publishError$: new BehaviorSubject<Error | null>(null),
publish: vi.fn<LocalMediaBackend["publish"]>(),
unpublish: vi.fn<LocalMediaBackend["unpublish"]>(),
};
}
type FakeHomeserverConnected = Omit<HomeserverConnected, "combined$"> & {
combined$: BehaviorSubject<HomeserverConnected["combined$"]["value"]>;
}; };
function fakeHomeserverConnected(): FakeHomeserverConnected { function fakeHomeserverConnected(): FakeHomeserverConnected {
return { return {
combined$: new BehaviorSubject<HomeserverConnected["combined$"]["value"]>([ disconnectReason$: new BehaviorSubject<
true, HomeserverConnected["disconnectReason$"]["value"]
null, >(null),
]),
rtsSession$: new BehaviorSubject(Status.Connected),
}; };
} }
+64 -120
View File
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { import {
type MatrixRTCSession as JsSdkRTCSession, type MatrixRTCSession as JsSdkRTCSession,
type Status as RTCSessionStatus,
type Transport, type Transport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -16,53 +15,40 @@ import {
BehaviorSubject, BehaviorSubject,
catchError, catchError,
combineLatest, combineLatest,
concat,
distinctUntilChanged, distinctUntilChanged,
map,
NEVER, NEVER,
type Observable, type Observable,
of, scan,
pairwise,
race,
Subject,
switchMap,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "../reactive/Behavior"; import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope"; import { type ObservableScope } from "../reactive/ObservableScope";
import { type DelayedLeaveTimings, type SessionTimings } from "../config"; import { type DelayedLeaveTimings, type SessionTimings } from "../config";
import { type DisconnectReason } from "../api"; import { type MatrixDisconnectReason } from "../api";
import { import {
type MatrixRTCError, type MatrixRTCError,
MembershipManagerError, MembershipManagerError,
toMatrixRTCError, toMatrixRTCError,
} from "../errors"; } from "../errors";
import { import {
type LocalMediaBackend,
type MediaBackend, type MediaBackend,
MediaConnectionState,
type TransportCapabilities, type TransportCapabilities,
} from "../media-backend/api"; } from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected"; import { type HomeserverConnected } from "./HomeserverConnected";
export enum TransportState { export type MembershipState =
Waiting = "transport_waiting", /** Asking the homeserver for a transport, or preparing it. */
} | { kind: "waitingForTransport" }
/** Transport known; sending the membership for the first time. */
export enum PublishState { | { kind: "joining" }
WaitingForUser = "publish_waiting_for_user", /** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
Publishing = "publish_publishing", | { kind: "joined" }
} /** Was joined and one homeserver link is down; the membership manager is retrying. */
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
export type LocalMemberMediaState = /** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
| { connection: MediaConnectionState | MatrixRTCError } | { kind: "failed"; error: MatrixRTCError }
| PublishState /** `leave()` was called. Terminal. */
| MatrixRTCError; | { kind: "left" };
export type LocalMemberState =
| MatrixRTCError
| TransportState.Waiting
| { media: LocalMemberMediaState; matrix: MatrixRTCError | RTCSessionStatus };
/** The local transport, found and prepared, with what the join needs to know about it. */ /** The local transport, found and prepared, with what the join needs to know about it. */
export interface PreparedTransport extends TransportCapabilities { export interface PreparedTransport extends TransportCapabilities {
@@ -71,7 +57,6 @@ export interface PreparedTransport extends TransportCapabilities {
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
local: LocalMediaBackend;
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"]; delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
preparedTransport$: Observable<PreparedTransport>; preparedTransport$: Observable<PreparedTransport>;
homeserverConnected: HomeserverConnected; homeserverConnected: HomeserverConnected;
@@ -89,23 +74,18 @@ interface Props {
} }
export interface LocalMembership { export interface LocalMembership {
/** Sends the leave and stops publishing. Final: there is no joining again. */ /** Sends the leave. Final: there is no joining again. */
leave: () => void; leave: () => void;
state$: Behavior<LocalMemberState>; state$: Behavior<MembershipState>;
/** Fully connected: to the homeserver, the session and the transport. */
connected$: Behavior<boolean>;
/** Connected once, and currently not. */
reconnecting$: Behavior<boolean>;
disconnectReason$: Behavior<DisconnectReason | null>;
} }
/** /**
* The local member's state machine: waits for the transport, then publishes * The Matrix side of the local member: waits for the transport, enters the
* and enters the MatrixRTC session, and leaves both in step on `leave()`. * MatrixRTC session, keeps the delayed leave delegated, and leaves on
* `leave()`. Knows nothing about the media.
*/ */
export function createLocalMembership$({ export function createLocalMembership$({
scope, scope,
local,
delegateDelayedLeave, delegateDelayedLeave,
preparedTransport$: preparedTransportWithErrors$, preparedTransport$: preparedTransportWithErrors$,
homeserverConnected, homeserverConnected,
@@ -118,30 +98,20 @@ export function createLocalMembership$({
}: Props): LocalMembership { }: Props): LocalMembership {
const logger = parentLogger.getChild("[LocalMembership]"); const logger = parentLogger.getChild("[LocalMembership]");
const fatalTransportError$ = new Subject<MatrixRTCError>(); const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
const preparedTransport$ = preparedTransportWithErrors$.pipe( const transport$ = scope.behavior<PreparedTransport | null>(
preparedTransportWithErrors$.pipe(
catchError((e: unknown) => { catchError((e: unknown) => {
fatalTransportError$.next(toMatrixRTCError(e)); transportError$.next(toMatrixRTCError(e));
return NEVER; return NEVER;
}), }),
); ),
const transport$ = scope.behavior<PreparedTransport | null>(
preparedTransport$,
null, null,
); );
const joined$ = new BehaviorSubject(true); const joined$ = new BehaviorSubject(true);
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null); const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
// Nothing leaves this device while it may already have been dropped from
// the session: the member would show as away while still being heard
combineLatest(
[joined$, homeserverConnected.combined$],
(joined, [connected]) => joined && connected,
)
.pipe(distinctUntilChanged(), scope.bind())
.subscribe((publish) => local.setPublishing(publish));
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([transport$, joined$])), scope.behavior(combineLatest([transport$, joined$])),
async ([prepared, joined]) => { async ([prepared, joined]) => {
@@ -189,78 +159,52 @@ export function createLocalMembership$({
); );
}); });
const mediaState$ = scope.behavior<LocalMemberMediaState>( const state$ = scope.behavior<MembershipState>(
combineLatest([local.connectionState$, joined$]).pipe( combineLatest([
map(([connectionState, joined]) => { joined$,
if (connectionState !== MediaConnectionState.Connected) transportError$,
return { matrixError$,
connection: transport$,
connectionState instanceof Error homeserverConnected.disconnectReason$,
? toMatrixRTCError(connectionState) ]).pipe(
: connectionState, scan(
}; (previous, [joined, transportError, matrixError, transport, reason]) =>
return joined ? PublishState.Publishing : PublishState.WaitingForUser; membershipState(previous, {
joined,
error: transportError ?? matrixError,
hasTransport: transport !== null,
reason,
}), }),
{ kind: "waitingForTransport" } as MembershipState,
),
distinctUntilChanged(deepCompare), distinctUntilChanged(deepCompare),
), ),
); );
const state$ = scope.behavior<LocalMemberState>(
concat(
of(TransportState.Waiting),
race(
fatalTransportError$,
preparedTransport$.pipe(
switchMap(() =>
combineLatest(
[
mediaState$,
homeserverConnected.rtsSession$,
matrixError$,
local.publishError$,
],
(media, sessionStatus, matrixError, publishError) => ({
matrix: matrixError ?? sessionStatus,
media: publishError ? toMatrixRTCError(publishError) : media,
}),
),
),
),
),
),
);
const disconnectReason$ = scope.behavior(
combineLatest(
[homeserverConnected.combined$, local.connectionState$],
(
[homeserverConnected, reason],
connectionState,
): DisconnectReason | null => {
if (!homeserverConnected) return reason ?? "sync";
if (connectionState !== MediaConnectionState.Connected) return "media";
return null;
},
),
);
const connected$ = scope.behavior(
disconnectReason$.pipe(map((reason) => reason === null)),
);
const reconnecting$ = scope.behavior(
connected$.pipe(
pairwise(),
map(([was, is]) => was && !is),
),
false,
);
return { return {
leave: () => joined$.next(false), leave: () => joined$.next(false),
state$, state$,
connected$,
reconnecting$,
disconnectReason$,
}; };
} }
interface MembershipInputs {
joined: boolean;
error: MatrixRTCError | null;
hasTransport: boolean;
reason: MatrixDisconnectReason | null;
}
/** The next state, in priority order; `left` and `failed` are absorbing. */
function membershipState(
previous: MembershipState,
{ joined, error, hasTransport, reason }: MembershipInputs,
): MembershipState {
if (!joined) return { kind: "left" };
if (previous.kind === "failed") return previous;
if (error !== null) return { kind: "failed", error };
if (!hasTransport) return { kind: "waitingForTransport" };
if (reason === null) return { kind: "joined" };
const wasJoined =
previous.kind === "joined" || previous.kind === "reconnecting";
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
}
-85
View File
@@ -1,85 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { Status } from "matrix-js-sdk/lib/matrixrtc";
import { MediaConnectionState } from "../media-backend/api";
import { MatrixRTCError } from "../api";
import {
type LocalMemberState,
PublishState,
TransportState,
} from "./LocalMembership";
import { fatalError, participationStatus } from "./status";
const publishing: LocalMemberState = {
media: PublishState.Publishing,
matrix: Status.Connected,
};
const connecting: LocalMemberState = {
media: { connection: MediaConnectionState.Connecting },
matrix: Status.Connecting,
};
const error = new MatrixRTCError("gone");
describe("participationStatus", () => {
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
[
"no transport yet",
TransportState.Waiting,
false,
false,
false,
"waitingForTransport",
],
["joining", connecting, false, false, false, "connecting"],
["connected", publishing, true, false, false, "connected"],
[
"dropped after connecting",
connecting,
false,
true,
false,
"reconnecting",
],
["a transport error", error, false, false, false, "disconnected"],
[
"a matrix error",
{ ...publishing, matrix: error },
true,
false,
false,
"disconnected",
],
["left", publishing, true, false, true, "left"],
["left after an error", error, false, false, true, "left"],
])("%s", (_name, state, connected, reconnecting, left, expected) => {
expect(participationStatus(state, connected, reconnecting, left)).toBe(
expected,
);
});
});
describe("fatalError", () => {
it("is null while nothing is wrong", () => {
expect(fatalError(TransportState.Waiting)).toBeNull();
expect(fatalError(publishing)).toBeNull();
});
it("finds the error wherever the state holds it", () => {
expect(fatalError(error)).toBe(error);
expect(fatalError({ ...publishing, matrix: error })).toBe(error);
expect(
fatalError({ media: { connection: error }, matrix: Status.Connected }),
).toBe(error);
});
it("does not treat a publish error as fatal", () => {
expect(fatalError({ media: error, matrix: Status.Connected })).toBeNull();
});
});
-38
View File
@@ -1,38 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCError, type ParticipationStatus } from "../api";
import { type LocalMemberState, TransportState } from "./LocalMembership";
/** The local member's state, collapsed to what a host shows. */
export function participationStatus(
state: LocalMemberState,
connected: boolean,
reconnecting: boolean,
left: boolean,
): ParticipationStatus {
if (left) return "left";
if (fatalError(state) !== null) return "disconnected";
if (state === TransportState.Waiting) return "waitingForTransport";
if (connected) return "connected";
if (reconnecting) return "reconnecting";
return "connecting";
}
/** The error that stops the participation, if the state holds one. */
export function fatalError(state: LocalMemberState): MatrixRTCError | null {
if (state === TransportState.Waiting) return null;
if (state instanceof Error) return state;
if (state.matrix instanceof Error) return state.matrix;
if (
typeof state.media === "object" &&
"connection" in state.media &&
state.media.connection instanceof Error
)
return state.media.connection;
return null;
}
+145
View File
@@ -0,0 +1,145 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { MatrixRTCError, type ParticipationState } from "./api";
import { type MembershipState } from "./matrixrtc/LocalMembership";
import { MediaConnectionState } from "./media-backend/api";
import { participationState } from "./participationState";
const error = new MatrixRTCError("gone");
const waiting: ParticipationState = { kind: "waitingForTransport" };
const connecting: ParticipationState = { kind: "connecting" };
const connected: ParticipationState = { kind: "connected" };
const reconnecting: ParticipationState = {
kind: "reconnecting",
reason: "media",
};
const joined: MembershipState = { kind: "joined" };
describe("participationState", () => {
it.each<
[
string,
ParticipationState,
MembershipState,
MediaConnectionState | Error,
ParticipationState,
]
>([
[
"no transport yet",
waiting,
{ kind: "waitingForTransport" },
MediaConnectionState.Initialized,
waiting,
],
[
"joining",
waiting,
{ kind: "joining" },
MediaConnectionState.Connecting,
connecting,
],
[
"joined, media still connecting",
connecting,
joined,
MediaConnectionState.Connecting,
connecting,
],
[
"both links up",
connecting,
joined,
MediaConnectionState.Connected,
connected,
],
[
"media dropped after connecting",
connected,
joined,
MediaConnectionState.Reconnecting,
reconnecting,
],
[
"homeserver dropped after connecting",
connected,
{ kind: "reconnecting", reason: "sync" },
MediaConnectionState.Connected,
{ kind: "reconnecting", reason: "sync" },
],
[
"the matrix side wins over the media side",
reconnecting,
{ kind: "reconnecting", reason: "membership" },
MediaConnectionState.Reconnecting,
{ kind: "reconnecting", reason: "membership" },
],
[
"membership reconnecting before the media ever came up",
connecting,
{ kind: "reconnecting", reason: "sync" },
MediaConnectionState.Connecting,
connecting,
],
[
"back from a reconnect",
reconnecting,
joined,
MediaConnectionState.Connected,
connected,
],
[
"a membership error",
connected,
{ kind: "failed", error },
MediaConnectionState.Connected,
{ kind: "failed", error },
],
[
"a media connection error",
connected,
joined,
error,
{ kind: "failed", error },
],
[
"failed stays failed",
{ kind: "failed", error },
joined,
MediaConnectionState.Connected,
{ kind: "failed", error },
],
[
"left",
connected,
{ kind: "left" },
MediaConnectionState.Stopped,
{ kind: "left" },
],
[
"left after an error",
{ kind: "failed", error },
{ kind: "left" },
MediaConnectionState.Stopped,
{ kind: "left" },
],
])("%s", (_name, previous, membership, media, expected) => {
expect(participationState(previous, membership, media)).toEqual(expected);
});
it("wraps a plain media error", () => {
const state = participationState(connected, joined, new Error("boom"));
expect(state.kind).toBe("failed");
if (state.kind === "failed") {
expect(state.error).toBeInstanceOf(MatrixRTCError);
expect(state.error.cause).toBeInstanceOf(Error);
}
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ParticipationState } from "./api";
import { toMatrixRTCError } from "./errors";
import { type MembershipState } from "./matrixrtc/LocalMembership";
import { MediaConnectionState } from "./media-backend/api";
/**
* The membership combined with the local media connection, in priority
* order; `left` and `failed` are absorbing. "Connected once" is tracked here
* rather than in the membership because `connected` needs both links.
*/
export function participationState(
previous: ParticipationState,
membership: MembershipState,
media: MediaConnectionState | Error,
): ParticipationState {
if (membership.kind === "left") return { kind: "left" };
if (previous.kind === "failed") return previous;
if (membership.kind === "failed") return membership;
if (media instanceof Error)
return { kind: "failed", error: toMatrixRTCError(media) };
if (membership.kind === "waitingForTransport") return membership;
if (membership.kind === "joining") return { kind: "connecting" };
const reason =
membership.kind === "reconnecting"
? membership.reason
: media === MediaConnectionState.Connected
? null
: "media";
if (reason === null) return { kind: "connected" };
const wasConnected =
previous.kind === "connected" || previous.kind === "reconnecting";
return wasConnected
? { kind: "reconnecting", reason }
: { kind: "connecting" };
}