1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-04-18 07:04:03 +03:00

MatrixRTC: MembershipManager test cases and deprecation of MatrixRTCSession.room (#4713)

* WIP doodles on MembershipManager test cases

* .

* initial membership manager test setup.

* Updates from discussion

* revert renaming comments

* remove unused import

* fix leave delayed event resend test.
It was missing a flush.

* comment out and remove unused variables

* es lint

* use jsdom instead of node test environment

* remove unused variables

* remove unused export

* temp

* review

* fixup tests

* more review

* remove wait for expect dependency

* flatten tests and add comments

* add more leave test cases

* use defer

* remove @jest/environment dependency

* Cleanup awaits and Make mock types more correct.
Make every mock return a Promise if the real implementation does return a pormise.

* remove flush promise dependency

* add linting to matrixrtc tests

* Add fix async lints and use matrix rtc logger for test environment.

* prettier

* change to MatrixRTCSession logger

* make accessing the full room deprecated

* remove deprecated usage of full room

* Clean up the deprecation

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Timo 2025-02-27 10:55:09 +01:00 committed by GitHub
parent d81929de4c
commit a3bbc49e02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 758 additions and 151 deletions

View File

@ -156,7 +156,7 @@ module.exports = {
},
{
// Enable stricter promise rules for the MatrixRTC codebase
files: ["src/matrixrtc/**/*.ts"],
files: ["src/matrixrtc/**/*.ts", "spec/unit/matrixrtc/*.ts"],
rules: {
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",

View File

@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { encodeBase64, EventType, MatrixClient, MatrixError, type MatrixEvent, type Room } from "../../../src";
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { flushPromises } from "../../test-utils/flushPromises";
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
const mockFocus = { type: "mock" };
@ -37,10 +36,10 @@ describe("MatrixRTCSession", () => {
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
});
afterEach(() => {
afterEach(async () => {
client.stopClient();
client.matrixRTC.stop();
if (sess) sess.stop();
if (sess) await sess.stop();
sess = undefined;
});
@ -322,11 +321,9 @@ describe("MatrixRTCSession", () => {
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sendEventMock: jest.Mock;
let updateDelayedEventMock: jest.Mock;
let sentStateEvent: Promise<void>;
let sentDelayedState: Promise<void>;
let updatedDelayedEvent: Promise<void>;
beforeEach(() => {
sentStateEvent = new Promise((resolve) => {
@ -340,15 +337,12 @@ describe("MatrixRTCSession", () => {
};
});
});
updatedDelayedEvent = new Promise((r) => {
updateDelayedEventMock = jest.fn(r);
});
sendEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = updateDelayedEventMock;
client._unstable_updateDelayedEvent = jest.fn();
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
@ -432,120 +426,6 @@ describe("MatrixRTCSession", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
describe("calls", () => {
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
async function testJoin(useOwnedStateEvents: boolean): Promise<void> {
if (useOwnedStateEvents) {
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
}
jest.useFakeTimers();
// preparing the delayed disconnect should handle the delay being too long
const sendDelayedStateExceedAttempt = new Promise<void>((resolve) => {
const error = new MatrixError({
"errcode": "M_UNKNOWN",
"org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED",
"org.matrix.msc4140.max_delay": 7500,
});
sendDelayedStateMock.mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
// preparing the delayed disconnect should handle ratelimiting
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
sendDelayedStateMock.mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
// setting the membership state should handle ratelimiting (also with a retry-after value)
const sendStateEventAttempt = new Promise<void>((resolve) => {
const error = new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
);
sendStateEventMock.mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
sess!.joinRoomSession([activeFocusConfig], activeFocus, {
membershipServerSideExpiryTimeout: 9000,
});
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt;
const callProps = (d: number) => {
return [mockRoom!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey];
};
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000));
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500));
jest.advanceTimersByTime(5000);
await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches
jest.advanceTimersByTime(1000);
await sentStateEvent;
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
expires: 14400000,
device_id: "AAAAAAA",
foci_preferred: [activeFocusConfig],
focus_active: activeFocus,
} satisfies SessionMembershipData,
userStateKey,
);
await sentDelayedState;
// should have prepared the heartbeat to keep delaying the leave event while still connected
await updatedDelayedEvent;
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
await flushPromises();
jest.advanceTimersByTime(5000);
// should update delayed disconnect
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
jest.useRealTimers();
}
it("sends a membership event with session payload when joining a call", async () => {
await testJoin(false);
});
it("does not prefix the state key with _ for rooms that support user-owned state events", async () => {
await testJoin(true);
});
});
it("does nothing if join called when already joined", async () => {
sess!.joinRoomSession([mockFocus], mockFocus);
await sentStateEvent;
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("onMembershipsChanged", () => {
@ -616,9 +496,9 @@ describe("MatrixRTCSession", () => {
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
});
afterEach(() => {
afterEach(async () => {
// stop the timers
sess!.leaveRoomSession();
await sess!.leaveRoomSession();
});
it("creates a key when joining", () => {
@ -715,7 +595,7 @@ describe("MatrixRTCSession", () => {
}
});
it("cancels key send event that fail", async () => {
it("cancels key send event that fail", () => {
const eventSentinel = {} as unknown as MatrixEvent;
client.cancelPendingEvent = jest.fn();

View File

@ -32,7 +32,7 @@ import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
describe("MatrixRTCSessionManager", () => {
let client: MatrixClient;
beforeEach(async () => {
beforeEach(() => {
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
});

View File

@ -0,0 +1,606 @@
/**
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
*/
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type MockedFunction, type Mock } from "jest-mock";
import { EventType, HTTPError, MatrixError, type Room } from "../../../src";
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/MembershipManager";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { defer } from "../../../src/utils";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
});
}
function createAsyncHandle(method: MockedFunction<any>) {
const { reject, resolve, promise } = defer();
method.mockImplementation(() => promise);
return { reject, resolve };
}
/**
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
*/
describe.each([
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
// { TestMembershipManager: MembershipManager, description: "MembershipManager" },
])("$description", ({ TestMembershipManager }) => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
focus_selection: "oldest_membership",
type: "livekit",
};
const focus: Focus = {
type: "livekit",
livekit_service_url: "https://active.url",
livekit_alias: "!active:active.url",
};
beforeEach(() => {
// Default to fake timers.
jest.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom(membershipTemplate);
// Provide a default mock that is like the default "non error" server behaviour.
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
});
afterEach(() => {
jest.useRealTimers();
// There is no need to clean up mocks since we will recreate the client.
});
describe("isJoined()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(manager.isJoined()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([]);
expect(manager.isJoined()).toEqual(true);
});
});
describe("join()", () => {
describe("sends a membership event", () => {
it("sends a membership event and schedules delayed leave when joining a call", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
memberManager.join([focus], focusActive);
// expects
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "",
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA",
);
updateDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
);
});
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
async function testJoin(useOwnedStateEvents: boolean): Promise<void> {
// TODO: this test does quiet a bit. Its more a like a test story summarizing to:
// - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten)
// - run into rate limit for sending delayed event
// - run into rate limit when setting membership state.
if (useOwnedStateEvents) {
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
}
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
const sentDelayedState = waitForMockCall(
client._unstable_sendDelayedStateEvent,
Promise.resolve({
delay_id: "id",
}),
);
// preparing the delayed disconnect should handle the delay being too long
const sendDelayedStateExceedAttempt = new Promise<void>((resolve) => {
const error = new MatrixError({
"errcode": "M_UNKNOWN",
"org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED",
"org.matrix.msc4140.max_delay": 7500,
});
(client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
// preparing the delayed disconnect should handle ratelimiting
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
(client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
// setting the membership state should handle ratelimiting (also with a retry-after value)
const sendStateEventAttempt = new Promise<void>((resolve) => {
const error = new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
);
(client.sendStateEvent as Mock).mockImplementationOnce(() => {
resolve();
return Promise.reject(error);
});
});
const manager = new TestMembershipManager(
{
membershipServerSideExpiryTimeout: 9000,
},
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
await sendDelayedStateAttempt;
const callProps = (d: number) => {
return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey];
};
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000));
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500));
await jest.advanceTimersByTimeAsync(5000);
await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches
await jest.advanceTimersByTimeAsync(1000);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
expires: 14400000,
device_id: "AAAAAAA",
foci_preferred: [focus],
focus_active: focusActive,
} satisfies SessionMembershipData,
userStateKey,
);
await sentDelayedState;
// should have prepared the heartbeat to keep delaying the leave event while still connected
await updatedDelayedEvent;
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
await jest.advanceTimersByTimeAsync(5000);
// should update delayed disconnect
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
}
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
await testJoin(false);
});
it("does not prefix the state key with _ for rooms that support user-owned state events", async () => {
await testJoin(true);
});
});
});
describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
delayedHandle.reject?.(Error("Server does not support the delayed events API"));
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses membershipServerSideExpiryTimeout from config", () => {
const manager = new TestMembershipManager(
{ membershipServerSideExpiryTimeout: 123456 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 123456 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
);
});
});
it("uses membershipExpiryTimeout from config", async () => {
const manager = new TestMembershipManager(
{ membershipExpiryTimeout: 1234567 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: 1234567,
foci_preferred: [focus],
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
);
});
it("does nothing if join called when already joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
manager.join([focus], focusActive);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
await manager.leave();
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA",
);
});
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
it("does nothing if not joined !FailsForLegacy", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
await manager.leave();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
});
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
// After joining we want our own focus to be the one we select.
getOldestMembership.mockReturnValue(
mockCallMembership(
{
...membershipTemplate,
foci_preferred: [
{
livekit_alias: "!active:active.url",
livekit_service_url: "https://active.url",
type: "livekit",
},
],
device_id: client.getDeviceId(),
created_ts: 1000,
},
room.roomId,
client.getUserId()!,
),
);
expect(manager.getActiveFocus()).toStrictEqual(focus);
getOldestMembership.mockReturnValue(
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
room.roomId,
),
);
// If there is an older member we use its focus.
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
});
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
]);
await jest.advanceTimersByTimeAsync(1);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
(client.sendStateEvent as Mock).mockClear();
(client._unstable_updateDelayedEvent as Mock).mockClear();
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
// Our own membership is removed:
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersByTimeAsync(1);
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
});
});
// TODO: Not sure about this name
describe("background timers", () => {
it("sends only one keep-alive for delayed leave event per `membershipKeepAlivePeriod`", async () => {
const manager = new TestMembershipManager(
{ membershipKeepAlivePeriod: 10_000, membershipServerSideExpiryTimeout: 30_000 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
// The first call is from checking id the server deleted the delayed event
// so it does not need a `advanceTimersByTime`
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
for (let i = 2; i <= 12; i++) {
// flush promises before advancing the timers to make sure schedulers are setup
await jest.advanceTimersByTimeAsync(10_000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
// TODO: Check that update delayed event is called with the correct HTTP request timeout
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
}
});
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
// Delayed events should replace it entirely but before they have wide adoption
// the expiration logic still makes sense.
// TODO: add git commit when we removed it.
it("extends `expires` when call still active !FailsForLegacy", async () => {
const manager = new TestMembershipManager(
{ membershipExpiryTimeout: 10_000 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
expect(sentMembership.expires).toBe(10_000);
for (let i = 2; i <= 12; i++) {
await jest.advanceTimersByTimeAsync(10_000);
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
expect(sentMembership.expires).toBe(10_000 * i);
}
});
});
describe("server error handling", () => {
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
describe("retries sending delayed leave event", () => {
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
handle.reject?.(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
),
);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
// Remove our own membership so that there is no reason the send the delayed leave anymore.
// the membership is no longer present on the homeserver
await manager.onRTCSessionMemberUpdate([]);
// Wait for all timers to be setup
await jest.advanceTimersByTimeAsync(1000);
// We should send the first own membership and a new delayed event after the rate limit timeout.
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop if leave() was called !FailsForLegacy", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
),
);
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
// the user terminated the call locally
await manager.leave();
// Wait for all timers to be setup
// await flushPromises();
await jest.advanceTimersByTimeAsync(1000);
// No new events should have been sent:
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event !FailsForLegacy", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
// Hit rate limit
await jest.advanceTimersByTimeAsync(1);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// Hit second rate limit.
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
// Setup resolve
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@ -0,0 +1,54 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
This file adds a custom test environment for the MembershipManager.spec.ts
It can be used with the comment at the top of the file:
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
in their test name.
*/
import { TestEnvironment } from "jest-environment-jsdom";
import { logger as rootLogger } from "../../../src/logger";
const logger = rootLogger.getChild("MatrixRTCSession");
class MemberManagerTestEnvironment extends TestEnvironment {
handleTestEvent(event: any) {
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
let parent = event.test.parent;
let isLegacy = false;
while (parent) {
if (parent.name === "LegacyMembershipManager") {
isLegacy = true;
break;
} else {
parent = parent.parent;
}
}
if (isLegacy) {
logger.info("skip test: ", event.test.name);
event.test.mode = "skip";
}
}
}
}
module.exports = MemberManagerTestEnvironment;

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType, type MatrixEvent, type Room } from "../../../src";
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { secureRandomString } from "../../../src/randomstring";
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
@ -40,6 +40,31 @@ export const membershipTemplate: SessionMembershipData = {
],
};
export type MockClient = Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendEvent"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "cancelPendingEvent"
>;
/**
* Mocks a object that has all required methods for a MatrixRTC session client.
*/
export function makeMockClient(userId: string, deviceId: string): MockClient {
return {
getDeviceId: () => deviceId,
getUserId: () => userId,
sendEvent: jest.fn(),
sendStateEvent: jest.fn(),
cancelPendingEvent: jest.fn(),
_unstable_updateDelayedEvent: jest.fn(),
_unstable_sendDelayedStateEvent: jest.fn(),
};
}
export function makeMockRoom(membershipData: MembershipData): Room {
const roomId = secureRandomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
@ -88,16 +113,17 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
};
}
export function mockRTCEvent(membershipData: MembershipData, roomId: string): MatrixEvent {
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
const sender = customSender ?? "@mock:user.example";
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue(membershipData),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getSender: jest.fn().mockReturnValue(sender),
getTs: jest.fn().mockReturnValue(Date.now()),
getRoomId: jest.fn().mockReturnValue(roomId),
sender: {
userId: "@mock:user.example",
},
isDecryptionFailure: jest.fn().mockReturnValue(false),
} as unknown as MatrixEvent;
}
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
}

View File

@ -3414,7 +3414,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
*/
// eslint-disable-next-line
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
public async _unstable_updateDelayedEvent(
delayId: string,
action: UpdateDelayedEventAction,
requestOptions: IRequestOpts = {},
): Promise<EmptyObject> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw Error("Server does not support the delayed events API");
}
@ -3426,6 +3430,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
action,
};
return await this.http.authedRequest(Method.Post, path, undefined, data, {
...requestOptions,
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
});
}

View File

@ -58,6 +58,8 @@ export interface MembershipConfig {
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
*
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membershipExpiryTimeout?: number;
@ -89,6 +91,7 @@ export interface MembershipConfig {
*/
callMemberEventRetryJitter?: number;
}
export interface EncryptionConfig {
/**
* If true, generate and share a media key for this participant,
@ -153,7 +156,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/**
* Returns all the call memberships for a room, oldest first
*/
public static callMembershipsForRoom(room: Room): CallMembership[] {
public static callMembershipsForRoom(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
): CallMembership[] {
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) {
logger.warn("Couldn't get state for room " + room.roomId);
@ -225,20 +230,51 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
return new MatrixRTCSession(client, room, callMemberships);
}
private constructor(
private readonly client: MatrixClient,
public readonly room: Room,
/**
* WARN: this can in theory only be a subset of the room with the properties required by
* this class.
* Outside of tests this most likely will be a full room, however.
* @deprecated Relying on a full Room object being available here is an anti-pattern. You should be tracking
* the room object in your own code and passing it in when needed.
*/
public get room(): Room {
return this.roomSubset as Room;
}
/**
* This constructs a room session. When using MatrixRTC inside the js-sdk this is expected
* to be used with the MatrixRTCSessionManager exclusively.
*
* In cases where you don't use the js-sdk but build on top of another Matrix stack this class can be used standalone
* to manage a joined MatrixRTC session.
*
* @param client A subset of the {@link MatrixClient} that lets the session interact with the Matrix room.
* @param roomSubset The room this session is attached to. A subset of a js-sdk Room that the session needs.
* @param memberships The list of memberships this session currently has.
*/
public constructor(
private readonly client: Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
| "sendEvent"
| "cancelPendingEvent"
>,
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
public memberships: CallMembership[],
) {
super();
this._callId = memberships[0]?.callId;
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.setExpiryTimer();
this.encryptionManager = new EncryptionManager(
this.client,
this.room,
this.roomSubset,
() => this.memberships,
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
@ -263,7 +299,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
clearTimeout(this.expiryTimeout);
this.expiryTimeout = undefined;
}
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
}
@ -284,10 +320,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
// create MembershipManager
if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
return;
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.room, this.client, () =>
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
}
@ -311,11 +347,11 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
*/
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
if (!this.isJoined()) {
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
return false;
}
logger.info(`Leaving call session in room ${this.room.roomId}`);
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
this.encryptionManager.leave();
@ -455,7 +491,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
if (changed) {
logger.info(`Memberships for call in room ${this.room.roomId} have changed: emitting`);
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);

View File

@ -38,7 +38,7 @@ export interface IMembershipManager {
* @returns It resolves with true in case the leave was sent successfully.
* It resolves with false in case we hit the timeout before sending successfully.
*/
leave(timeout: number | undefined): Promise<boolean>;
leave(timeout?: number): Promise<boolean>;
/**
* Call this if the MatrixRTC session members have changed.
*/
@ -117,7 +117,6 @@ export class LegacyMembershipManager implements IMembershipManager {
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
>,
@ -312,6 +311,7 @@ export class LegacyMembershipManager implements IMembershipManager {
if (this.disconnectDelayId !== undefined) {
this.scheduleDelayDisconnection();
}
// TODO throw or log an error if this.disconnectDelayId === undefined
} else {
// Not joined
let sentDelayedDisconnect = false;