Diagnostics for duplicate participant matches and mid-delivery scope teardown

Warn when ConnectionManagerData merges participants from a second
connection to the same SFU URL, when more than one LiveKit participant
matches a member's backend identity, and when a scope is ended while one
of its behaviors is still delivering a value (which strands the later
bound subscribers on the old value). The per-member match log now also
names the connection the participant came from.
This commit is contained in:
Matthew Hodgson
2026-09-03 18:43:18 +01:00
parent b1393998a7
commit dee4b05695
6 changed files with 74 additions and 11 deletions

View File

@@ -14,7 +14,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
import { import {
createConnectionManager$, createConnectionManager$,
type ConnectionManagerData, ConnectionManagerData,
} from "./ConnectionManager.ts"; } from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
@@ -203,6 +203,24 @@ describe("connections$ stream", () => {
}); });
}); });
describe("ConnectionManagerData", () => {
test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection;
const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
"second connection to https://lk.example.org: existing [a], adding [b]",
),
);
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
});
});
describe("connectionManagerData$ stream", () => { describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipants$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;

View File

@@ -30,7 +30,7 @@ export class ConnectionManagerData {
{ connection: Connection; participants: RemoteParticipant[] } { connection: Connection; participants: RemoteParticipant[] }
> = new Map(); > = new Map();
public constructor() {} public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void { public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport); const key = this.getKey(connection.transport);
@@ -38,6 +38,11 @@ export class ConnectionManagerData {
if (!existing) { if (!existing) {
this.store.set(key, { connection, participants }); this.store.set(key, { connection, participants });
} else { } else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants); existing.participants.push(...participants);
} }
} }
@@ -239,7 +244,7 @@ export function createConnectionManager$({
lists.reduce((data, { connection, participants }) => { lists.reduce((data, { connection, participants }) => {
data.add(connection, participants); data.add(connection, participants);
return data; return data;
}, new ConnectionManagerData()), }, new ConnectionManagerData(logger)),
epoch, epoch,
), ),
), ),

View File

@@ -111,7 +111,7 @@ test("should signal participant not yet connected to livekit", async () => {
}, },
); );
expect(info).toHaveBeenCalledWith( expect(info).toHaveBeenCalledWith(
"[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing", "[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection",
); );
}); });

View File

@@ -111,13 +111,17 @@ export function createRemoteMatrixLivekitMembers$({
const participants = transport const participants = transport
? managerData.getParticipantsForTransport(transport) ? managerData.getParticipantsForTransport(transport)
: []; : [];
const participant = const matches = participants.filter(
participants.find(
(p) => p.identity == membership.rtcBackendIdentity, (p) => p.identity == membership.rtcBackendIdentity,
) ?? null; );
const participant = matches[0] ?? null;
const connection = transport const connection = transport
? managerData.getConnectionForTransport(transport) ? managerData.getConnectionForTransport(transport)
: null; : null;
if (matches.length > 1)
logger.warn(
`[RemoteMatrixLivekitMembers] ${membership.rtcBackendIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`,
);
yield { yield {
// This could just be the backend identity without the other keys. // This could just be the backend identity without the other keys.
@@ -139,8 +143,9 @@ export function createRemoteMatrixLivekitMembers$({
// Log whether the member could be matched to a LiveKit participant, // Log whether the member could be matched to a LiveKit participant,
// since a tile shows "waiting for media" for as long as it cannot. // since a tile shows "waiting for media" for as long as it cannot.
participant$.pipe(scope.bind()).subscribe((p) => { participant$.pipe(scope.bind()).subscribe((p) => {
const url = data$.value.connection?.transport.livekit_service_url;
logger.info( logger.info(
`[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"}`, `[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`,
); );
}); });
// will only get called once per backend identity. // will only get called once per backend identity.

View File

@@ -269,4 +269,26 @@ describe("behavior", () => {
expect(seen.at(-1)).toBe(1); expect(seen.at(-1)).toBe(1);
scope.end(); scope.end();
}); });
it("warns when the scope is ended while a behavior is mid-delivery", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = new ObservableScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0);
behavior$.subscribe((v) => {
if (v === 1) scope.end();
});
// A derived behavior is bound to the scope, so ending the scope
// mid-delivery unsubscribes it before the value reaches it.
const derived$ = scope.behavior(behavior$);
source$.next(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Scope ended while 1 of its behaviors"),
expect.any(String),
);
expect(behavior$.value).toBe(1);
expect(derived$.value).toBe(0);
});
}); });

View File

@@ -36,6 +36,12 @@ const nothing = Symbol("nothing");
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
export class ObservableScope { export class ObservableScope {
/**
* Number of this scope's behaviors currently mid-delivery. Used to detect a
* scope being torn down synchronously from within one of its own emissions.
*/
private delivering = 0;
private readonly ended$ = new BehaviorSubject(false); private readonly ended$ = new BehaviorSubject(false);
private readonly bindImpl: MonoTypeOperator = takeUntil( private readonly bindImpl: MonoTypeOperator = takeUntil(
@@ -89,7 +95,7 @@ export class ObservableScope {
let depth = 0; let depth = 0;
let maxReportedDepth = 1; let maxReportedDepth = 1;
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next(value) { next: (value) => {
if (depth > 0 && depth + 1 > maxReportedDepth) { if (depth > 0 && depth + 1 > maxReportedDepth) {
maxReportedDepth = depth + 1; maxReportedDepth = depth + 1;
logger.warn( logger.warn(
@@ -100,13 +106,15 @@ export class ObservableScope {
); );
} }
depth++; depth++;
this.delivering++;
try { try {
subject$.next(value); subject$.next(value);
} finally { } finally {
depth--; depth--;
this.delivering--;
} }
}, },
error(err: unknown) { error: (err: unknown) => {
subject$.error(err); subject$.error(err);
}, },
}); });
@@ -119,6 +127,11 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete. * Ends the scope, causing any bound Observables to complete.
*/ */
public end(): void { public end(): void {
if (this.delivering > 0)
logger.warn(
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
this.ended$.next(true); this.ended$.next(true);
} }