diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 55c326d367..bc66a7408b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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", diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 1afca35e68..8d7bf41f54 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -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; let sentDelayedState: Promise; - let updatedDelayedEvent: Promise; 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 { - 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((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((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((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(); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 578c62ac0a..a2912b82c3 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -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(); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts new file mode 100644 index 0000000000..6bb4810d4f --- /dev/null +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -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, returnVal?: Promise) { + return new Promise((resolve) => { + method.mockImplementation(() => { + resolve(); + return returnVal ?? Promise.resolve(); + }); + }); +} + +function createAsyncHandle(method: MockedFunction) { + 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).mockResolvedValue({ delay_id: "id" }); + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client.sendStateEvent as Mock).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 { + // 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((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((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((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).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).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).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).mockResolvedValue(undefined); + await jest.advanceTimersByTimeAsync(1000); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/unit/matrixrtc/memberManagerTestEnvironment.ts b/spec/unit/matrixrtc/memberManagerTestEnvironment.ts new file mode 100644 index 0000000000..f6c6e18dd6 --- /dev/null +++ b/spec/unit/matrixrtc/memberManagerTestEnvironment.ts @@ -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; diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 428014384a..dc3949d9b9 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -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); +} diff --git a/src/client.ts b/src/client.ts index 972d3341d4..89ec7bcec9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3414,7 +3414,11 @@ export class MatrixClient extends TypedEventEmitter { + public async _unstable_updateDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + requestOptions: IRequestOpts = {}, + ): Promise { 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, + ): 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, + private roomSubset: Pick, 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, encryptionKeyIndex: number, participantId: string) => { this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); @@ -263,7 +299,7 @@ export class MatrixRTCSession extends TypedEventEmitter + this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () => this.getOldestMembership(), ); } @@ -311,11 +347,11 @@ export class MatrixRTCSession extends TypedEventEmitter { 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 !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); diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 1cc6b7cc13..bdd22944f4 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -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; + leave(timeout?: number): Promise; /** * 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;