Merge disconnectReason$ into combined$; rename HomeserverDisconnectReason items

This commit is contained in:
fkwp
2026-05-11 19:27:52 +02:00
parent 45f39033ae
commit 0ae7dc9a55
6 changed files with 259 additions and 194 deletions

View File

@@ -94,9 +94,9 @@ describe("CallEnded", () => {
roomEventEncryptionKeysReceived: 5, roomEventEncryptionKeysReceived: 5,
roomEventEncryptionKeysReceivedAverageAge: 100, roomEventEncryptionKeysReceivedAverageAge: 100,
callReconnectingCount: 0, callReconnectingCount: 0,
callReconnectingCountSyncing: 0, callReconnectingCountSync: 0,
callReconnectingCountMembershipConnected: 0, callReconnectingCountMembership: 0,
callReconnectingCountCertainlyConnected: 0, callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 0, callReconnectingCountLivekit: 0,
}, },
{ send_instantly: true }, { send_instantly: true },
@@ -174,18 +174,18 @@ describe("CallEnded", () => {
const mockSession = createMockRtcSession(); const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date()); tracker.cacheStartCall(new Date());
tracker.cacheReconnecting("syncing"); tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("syncing"); tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit"); tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membershipConnected"); tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mockSession); tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
callReconnectingCount: 4, callReconnectingCount: 4,
callReconnectingCountSyncing: 2, callReconnectingCountSync: 2,
callReconnectingCountMembershipConnected: 1, callReconnectingCountMembership: 1,
callReconnectingCountCertainlyConnected: 0, callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 1, callReconnectingCountLivekit: 1,
}), }),
expect.anything(), expect.anything(),
@@ -211,20 +211,20 @@ describe("CallReconnecting", () => {
it("tracks event with correct shape", () => { it("tracks event with correct shape", () => {
const tracker = new CallReconnectingTracker(); const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", "syncing", 3.5); tracker.track("!room:example.org", "sync", 3.5);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({ expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "CallReconnecting", eventName: "CallReconnecting",
callId: "!room:example.org", callId: "!room:example.org",
reason: "syncing", reason: "sync",
reconnectDuration: 3.5, reconnectDuration: 3.5,
}); });
}); });
it.each([ it.each([
"syncing", "sync",
"membershipConnected", "membership",
"certainlyConnected", "probablyLeft",
"livekit", "livekit",
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => { ] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
const tracker = new CallReconnectingTracker(); const tracker = new CallReconnectingTracker();

View File

@@ -25,9 +25,9 @@ interface CallEnded extends IPosthogEvent {
roomEventEncryptionKeysReceived: number; roomEventEncryptionKeysReceived: number;
roomEventEncryptionKeysReceivedAverageAge: number; roomEventEncryptionKeysReceivedAverageAge: number;
callReconnectingCount: number; callReconnectingCount: number;
callReconnectingCountSyncing: number; callReconnectingCountSync: number;
callReconnectingCountMembershipConnected: number; callReconnectingCountMembership: number;
callReconnectingCountCertainlyConnected: number; callReconnectingCountProbablyLeft: number;
callReconnectingCountLivekit: number; callReconnectingCountLivekit: number;
} }
@@ -42,9 +42,9 @@ export class CallEndedTracker {
maxParticipantsCount: 0, maxParticipantsCount: 0,
reconnectingCount: 0, reconnectingCount: 0,
reconnectingCountByReason: { reconnectingCountByReason: {
syncing: 0, sync: 0,
membershipConnected: 0, membership: 0,
certainlyConnected: 0, probablyLeft: 0,
livekit: 0, livekit: 0,
}, },
}; };
@@ -55,9 +55,9 @@ export class CallEndedTracker {
maxParticipantsCount: 0, maxParticipantsCount: 0,
reconnectingCount: 0, reconnectingCount: 0,
reconnectingCountByReason: { reconnectingCountByReason: {
syncing: 0, sync: 0,
membershipConnected: 0, membership: 0,
certainlyConnected: 0, probablyLeft: 0,
livekit: 0, livekit: 0,
}, },
}; };
@@ -100,12 +100,11 @@ export class CallEndedTracker {
rtcSession.statistics.counters.roomEventEncryptionKeysReceived rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0, : 0,
callReconnectingCount: this.cache.reconnectingCount, callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSyncing: callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
this.cache.reconnectingCountByReason.syncing, callReconnectingCountMembership:
callReconnectingCountMembershipConnected: this.cache.reconnectingCountByReason.membership,
this.cache.reconnectingCountByReason.membershipConnected, callReconnectingCountProbablyLeft:
callReconnectingCountCertainlyConnected: this.cache.reconnectingCountByReason.probablyLeft,
this.cache.reconnectingCountByReason.certainlyConnected,
callReconnectingCountLivekit: callReconnectingCountLivekit:
this.cache.reconnectingCountByReason.livekit, this.cache.reconnectingCountByReason.livekit,
}, },
@@ -292,9 +291,9 @@ export class CallConnectDurationTracker {
} }
export type CallReconnectingReason = export type CallReconnectingReason =
| "syncing" | "sync"
| "membershipConnected" | "membership"
| "certainlyConnected" | "probablyLeft"
| "livekit"; | "livekit";
interface CallReconnecting extends IPosthogEvent { interface CallReconnecting extends IPosthogEvent {

View File

@@ -98,112 +98,112 @@ 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 // Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("is false 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).toBe(false); expect(hsConnected.combined$.value).toBe("sync");
}); });
it("remains false while membership status is not Connected even if sync is Syncing", () => { 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).toBe(false); // membership still disconnected expect(hsConnected.combined$.value).toBe("membership");
}); });
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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
session.setProbablyLeft(true); session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("probablyLeft");
}); });
it("becomes true 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).toBe(false); // not yet membership connected expect(hsConnected.combined$.value).toBe("membership"); // not yet membership connected
// 2. Membership connected // 2. Membership connected
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false expect(hsConnected.combined$.value).toBeNull(); // probablyLeft is false
}); });
it("drops back to false when sync loop leaves Syncing", () => { it("returns syncing reason when sync loop leaves Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0); 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);
expect(hsConnected.combined$.value).toBe(true); expect(hsConnected.combined$.value).toBeNull();
// Sync loop error => should flip false // Sync loop error => should report syncing reason
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("sync");
}); });
it("drops back to false 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).toBe(true); expect(hsConnected.combined$.value).toBeNull();
session.setMembershipStatus(Status.Disconnected); session.setMembershipStatus(Status.Disconnected);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("membership");
}); });
it("drops to false when ProbablyLeft is emitted after being true", () => { 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).toBe(true); expect(hsConnected.combined$.value).toBeNull();
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("probablyLeft");
}); });
it("recovers to true 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).toBe(true); expect(hsConnected.combined$.value).toBeNull();
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("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).toBe(true); expect(hsConnected.combined$.value).toBeNull();
}); });
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 false (sync error + disconnected + not probably left) // Initially: sync error + membership disconnected → syncing wins (highest priority)
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("sync");
// Fix sync only // Fix sync only → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("membership");
// Fix membership // Fix membership → all conditions satisfied
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true); expect(hsConnected.combined$.value).toBeNull();
// Introduce probablyLeft -> false // Introduce probablyLeft → certainlyConnected
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("probablyLeft");
// Restore notProbablyLeft -> true again // Restore notProbablyLeft → connected again
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toBe(true); expect(hsConnected.combined$.value).toBeNull();
// Drop sync -> false // Drop sync → syncing reason
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(false); expect(hsConnected.combined$.value).toBe("sync");
}); });
}); });
describe("createHomeserverConnected$ - disconnectReason$", () => { describe("createHomeserverConnected$ - combined$ reason values", () => {
let scope: ObservableScope; let scope: ObservableScope;
let client: MockMatrixClient; let client: MockMatrixClient;
let session: MockMatrixRTCSession; let session: MockMatrixRTCSession;
@@ -223,86 +223,56 @@ describe("createHomeserverConnected$ - disconnectReason$", () => {
}); });
it("is null when all three conditions are satisfied", () => { it("is null when all three conditions are satisfied", () => {
const { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(disconnectReason$.value).toBe(null); expect(combined$.value).toBeNull();
}); });
it("reports syncing when sync loop is not Syncing", () => { it("reports syncing when sync loop is not Syncing", () => {
const { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
scope,
client,
session,
0,
);
// client starts with SyncState.Error, membership also disconnected // client starts with SyncState.Error, membership also disconnected
expect(disconnectReason$.value).toBe("syncing"); expect(combined$.value).toBe("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 { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
scope,
client,
session,
0,
);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
// session still Status.Disconnected // session still Status.Disconnected
expect(disconnectReason$.value).toBe("membershipConnected"); expect(combined$.value).toBe("membership");
}); });
it("reports certainlyConnected when probablyLeft is true", () => { it("reports certainlyConnected when probablyLeft is true", () => {
const { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
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(disconnectReason$.value).toBe("certainlyConnected"); expect(combined$.value).toBe("probablyLeft");
}); });
it("prioritises syncing over membershipConnected when both fail", () => { it("prioritises syncing over membershipConnected when both fail", () => {
const { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
scope,
client,
session,
0,
);
// Both sync (Error) and membership (Disconnected) are failing // Both sync (Error) and membership (Disconnected) are failing
expect(disconnectReason$.value).toBe("syncing"); expect(combined$.value).toBe("sync");
}); });
it("updates reason as conditions change", () => { it("updates reason as conditions change", () => {
const { disconnectReason$ } = createHomeserverConnected$( const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
scope,
client,
session,
0,
);
// Initially: syncing fails // Initially: syncing fails
expect(disconnectReason$.value).toBe("syncing"); expect(combined$.value).toBe("sync");
// Fix sync → membershipConnected is now the blocker // Fix sync → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(disconnectReason$.value).toBe("membershipConnected"); expect(combined$.value).toBe("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(disconnectReason$.value).toBe("certainlyConnected"); expect(combined$.value).toBe("probablyLeft");
// Clear probablyLeft → all conditions satisfied // Clear probablyLeft → all conditions satisfied
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(disconnectReason$.value).toBe(null); expect(combined$.value).toBeNull();
}); });
}); });
@@ -334,8 +304,8 @@ describe("createHomeserverConnected$ - Grace Period", () => {
GRACE_PERIOD, GRACE_PERIOD,
); );
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, { expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
y: true, y: null,
n: false, n: "sync",
}); });
}); });
} }

View File

@@ -29,7 +29,6 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config"; 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 { type NodeStyleEventEmitter } from "../../../utils/test"; import { type NodeStyleEventEmitter } from "../../../utils/test";
/** /**
@@ -37,25 +36,26 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
*/ */
const logger = rootLogger.getChild("[HomeserverConnected]"); const logger = rootLogger.getChild("[HomeserverConnected]");
export type HomeserverDisconnectReason = export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
| "syncing"
| "membershipConnected"
| "certainlyConnected";
export interface HomeserverConnected { export interface HomeserverConnected {
combined$: Behavior<boolean>; /**
* Emits `null` when the homeserver connection is healthy, or the reason for
* disconnection when one of the three sub-conditions fails.
*/
combined$: Behavior<HomeserverDisconnectReason | null>;
rtsSession$: Behavior<Status>; rtsSession$: Behavior<Status>;
disconnectReason$: Behavior<HomeserverDisconnectReason | null>;
} }
/** /**
* 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.
* *
* Becomes FALSE if ANY sub-condition is fulfilled: * `combined$` emits `null` when all conditions are satisfied, or the first failing
* 1. Sync loop is not in SyncState.Syncing (after grace period) * reason (priority: syncing > membershipConnected > certainlyConnected):
* 2. membershipStatus !== Status.Connected * 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
* 3. probablyLeft === true * 2. membershipStatus !== Status.Connected → "membership"
* 3. probablyLeft === true → "probablyLeft"
* *
* @param scope - The observable scope for lifecycle management. * @param scope - The observable scope for lifecycle management.
* @param client - The Matrix client to monitor sync state. * @param client - The Matrix client to monitor sync state.
@@ -116,24 +116,18 @@ export function createHomeserverConnected$(
); );
const combined$ = scope.behavior( const combined$ = scope.behavior(
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
tap((connected) => {
logger.info(`Homeserver connected update: ${connected}`);
}),
),
);
const disconnectReason$ = scope.behavior(
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe( combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
map(([syncing, membership, certainly]) => { map(([syncing, membership, certainly]) => {
if (!syncing) return "syncing" as const; if (!syncing) return "sync" as const;
if (!membership) return "membershipConnected" as const; if (!membership) return "membership" as const;
if (!certainly) return "certainlyConnected" as const; if (!certainly) return "probablyLeft" as const;
return null; return null;
}), }),
tap((reason) => {
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
}),
), ),
null,
); );
return { combined$, rtsSession$, disconnectReason$ }; return { combined$, rtsSession$ };
} }

View File

@@ -225,9 +225,8 @@ describe("LocalMembership", () => {
createPublisherFactory: vi.fn(), createPublisherFactory: vi.fn(),
joinMatrixRTC: async (): Promise<void> => {}, joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected: { homeserverConnected: {
combined$: constant(true), combined$: constant(null),
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
disconnectReason$: constant(null),
}, },
callId: "!test-room-id:example.org", callId: "!test-room-id:example.org",
}; };
@@ -693,16 +692,17 @@ describe("LocalMembership", () => {
PosthogAnalytics.resetInstance(); PosthogAnalytics.resetInstance();
}); });
it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => { it("does not fire CallReconnecting for the initial non-connected state at startup", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const trackSpy = vi.spyOn( const trackSpy = vi.spyOn(
PosthogAnalytics.instance.eventCallReconnecting, PosthogAnalytics.instance.eventCallReconnecting,
"track", "track",
); );
const hsConnected$ = new BehaviorSubject<boolean>(true); // Simulate startup where membership isn't established yet
const disconnectReason$ = const hsReason$ = new BehaviorSubject<HomeserverDisconnectReason | null>(
new BehaviorSubject<HomeserverDisconnectReason | null>(null); "membership",
);
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []); connectionManagerData.add(connectionTransportAConnected, []);
@@ -711,9 +711,8 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
homeserverConnected: { homeserverConnected: {
combined$: hsConnected$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
disconnectReason$,
}, },
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
@@ -726,15 +725,52 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Disconnect with syncing reason, then reconnect // Membership is established — call is now connected
disconnectReason$.next("syncing"); hsReason$.next(null);
hsConnected$.next(false);
disconnectReason$.next(null); expect(trackSpy).not.toHaveBeenCalled();
hsConnected$.next(true);
scope.end();
});
it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => {
const scope = new ObservableScope();
const trackSpy = vi.spyOn(
PosthogAnalytics.instance.eventCallReconnecting,
"track",
);
const hsReason$ = new BehaviorSubject<HomeserverDisconnectReason | null>(
null,
);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
homeserverConnected: {
combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: new BehaviorSubject({
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
}),
});
await flushPromises();
hsReason$.next("sync");
hsReason$.next(null);
expect(trackSpy).toHaveBeenCalledWith( expect(trackSpy).toHaveBeenCalledWith(
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"syncing", "sync",
expect.any(Number), expect.any(Number),
); );
@@ -763,10 +799,10 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
homeserverConnected: { homeserverConnected: {
combined$: new BehaviorSubject(true), combined$: new BehaviorSubject<HomeserverDisconnectReason | null>(
null,
),
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
disconnectReason$:
new BehaviorSubject<HomeserverDisconnectReason | null>(null),
}, },
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
@@ -779,7 +815,6 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Livekit drops then recovers
connectionState$.next(ConnectionState.LivekitDisconnected); connectionState$.next(ConnectionState.LivekitDisconnected);
connectionState$.next(ConnectionState.LivekitConnected); connectionState$.next(ConnectionState.LivekitConnected);
@@ -799,9 +834,9 @@ describe("LocalMembership", () => {
"track", "track",
); );
const hsConnected$ = new BehaviorSubject<boolean>(true); const hsReason$ = new BehaviorSubject<HomeserverDisconnectReason | null>(
const disconnectReason$ = null,
new BehaviorSubject<HomeserverDisconnectReason | null>(null); );
const connectionManagerData = new ConnectionManagerData(); const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []); connectionManagerData.add(connectionTransportAConnected, []);
@@ -810,9 +845,8 @@ describe("LocalMembership", () => {
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
homeserverConnected: { homeserverConnected: {
combined$: hsConnected$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
disconnectReason$,
}, },
connectionManager: { connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)), connectionManagerData$: constant(new Epoch(connectionManagerData)),
@@ -825,28 +859,81 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// First full reconnect cycle hsReason$.next("membership");
disconnectReason$.next("membershipConnected"); hsReason$.next(null);
hsConnected$.next(false);
disconnectReason$.next(null); hsReason$.next("probablyLeft");
hsConnected$.next(true); hsReason$.next(null);
// Second full reconnect cycle
disconnectReason$.next("certainlyConnected");
hsConnected$.next(false);
disconnectReason$.next(null);
hsConnected$.next(true);
expect(trackSpy).toHaveBeenCalledTimes(2); expect(trackSpy).toHaveBeenCalledTimes(2);
expect(trackSpy).toHaveBeenNthCalledWith( expect(trackSpy).toHaveBeenNthCalledWith(
1, 1,
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"membershipConnected", "membership",
expect.any(Number), expect.any(Number),
); );
expect(trackSpy).toHaveBeenNthCalledWith( expect(trackSpy).toHaveBeenNthCalledWith(
2, 2,
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"certainlyConnected", "probablyLeft",
expect.any(Number),
);
scope.end();
});
it("uses the first-disconnect reason when a brief intermediate reconnect occurs (multi-phase livekit reconnect)", async () => {
const scope = new ObservableScope();
const trackSpy = vi.spyOn(
PosthogAnalytics.instance.eventCallReconnecting,
"track",
);
const hsReason$ = new BehaviorSubject<HomeserverDisconnectReason | null>(
null,
);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
homeserverConnected: {
combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: new BehaviorSubject({
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
}),
});
await flushPromises();
// Disconnect → brief reconnect → disconnect again → stable reconnect
// This simulates a multi-phase LiveKit reconnect. Two CallReconnecting
// events are emitted (one per null transition) but both carry the
// reason from the first disconnect.
hsReason$.next("sync");
hsReason$.next(null); // brief reconnect
hsReason$.next("sync"); // re-disconnect (reconnectStart already set, keeps original reason)
hsReason$.next(null); // stable reconnect
expect(trackSpy).toHaveBeenCalledTimes(2);
expect(trackSpy).toHaveBeenNthCalledWith(
1,
defaultCreateLocalMemberValues.callId,
"sync",
expect.any(Number),
);
expect(trackSpy).toHaveBeenNthCalledWith(
2,
defaultCreateLocalMemberValues.callId,
"sync",
expect.any(Number), expect.any(Number),
); );

View File

@@ -53,7 +53,6 @@ import {
import { ElementWidgetActions, widget } from "../../../widget.ts"; import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts"; import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { type CallReconnectingReason } from "../../../analytics/PosthogEvents.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import {
@@ -62,7 +61,6 @@ import {
type FailedToStartError, type FailedToStartError,
} from "../remoteMembers/Connection.ts"; } from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { and$ } from "../../../utils/observable.ts";
import { type LocalTransport } from "./LocalTransport.ts"; import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
@@ -498,20 +496,35 @@ export const createLocalMembership$ = ({
); );
/** /**
* Whether we are "fully" connected to the call. Accounts for both the * The disconnect reason for the combined Matrix + LiveKit connection, or null
* connection to the MatrixRTC session and the LiveKit publish connection. * when fully connected. Homeserver reasons take priority over livekit.
* Both connectivity state and reason come from the same combineLatest emission,
* avoiding any race between the two.
*/ */
const matrixAndLivekitConnected$ = scope.behavior( const connectionDisconnectReason$ = scope.behavior(
and$( combineLatest([
homeserverConnected.combined$, homeserverConnected.combined$,
localConnectionState$.pipe( localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected), map((state) => state === ConnectionState.LivekitConnected),
), ),
).pipe( ]).pipe(
map(([homeserverReason, livekitConnected]) => {
if (homeserverReason !== null) return homeserverReason;
if (!livekitConnected) return "livekit" as const;
return null;
}),
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)), tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
), ),
); );
/**
* Whether we are "fully" connected to the call. Accounts for both the
* connection to the MatrixRTC session and the LiveKit publish connection.
*/
const matrixAndLivekitConnected$ = scope.behavior(
connectionDisconnectReason$.pipe(map((reason) => reason === null)),
);
/** /**
* Whether we should tell the user that we're reconnecting to the call. * Whether we should tell the user that we're reconnecting to the call.
*/ */
@@ -523,23 +536,25 @@ export const createLocalMembership$ = ({
false, false,
); );
let reconnectStart: { time: number; reason: CallReconnectingReason } | null = let reconnectStart: {
null; time: number;
reconnecting$ reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>;
.pipe(distinctUntilChanged(), scope.bind()) } | null = null;
.subscribe((reconnecting) => { connectionDisconnectReason$
if (reconnecting) { .pipe(distinctUntilChanged(), pairwise(), scope.bind())
const homeserverReason = homeserverConnected.disconnectReason$.value; .subscribe(([prev, reason]) => {
reconnectStart = { if (reason !== null) {
time: Date.now(), // Only begin tracking when transitioning FROM connected (null → non-null).
reason: homeserverReason !== null ? homeserverReason : "livekit", // This prevents the initial startup phase — where we may be non-null before
}; // the first real connection — from being counted as a reconnect.
if (prev === null) {
reconnectStart ??= { time: Date.now(), reason };
}
} else if (reconnectStart !== null) { } else if (reconnectStart !== null) {
const duration = (Date.now() - reconnectStart.time) / 1000;
PosthogAnalytics.instance.eventCallReconnecting.track( PosthogAnalytics.instance.eventCallReconnecting.track(
callId, callId,
reconnectStart.reason, reconnectStart.reason,
duration, (Date.now() - reconnectStart.time) / 1000,
); );
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting( PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(
reconnectStart.reason, reconnectStart.reason,