diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 52e6682e5..e330ef1d8 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -15,7 +15,8 @@ limitations under the License. */ import { MatrixEvent } from "../../../src"; -import { CallMembership, SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { CallMembership, SessionMembershipData, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; +import { membershipTemplate } from "./mocks"; function makeMockEvent(originTs = 0): MatrixEvent { return { @@ -74,6 +75,18 @@ describe("CallMembership", () => { expect(membership.createdTs()).toEqual(67890); }); + it("considers memberships unexpired if local age low enough", () => { + const fakeEvent = makeMockEvent(1000); + fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1)); + expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false); + }); + + it("considers memberships expired if local age large enough", () => { + const fakeEvent = makeMockEvent(1000); + fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1)); + expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true); + }); + it("returns preferred foci", () => { const fakeEvent = makeMockEvent(); const mockFocus = { type: "this_is_a_mock_focus" }; @@ -85,29 +98,26 @@ describe("CallMembership", () => { }); }); - // TODO: re-enable this test when expiry is implemented - // eslint-disable-next-line jest/no-commented-out-tests - // describe("expiry calculation", () => { - // let fakeEvent: MatrixEvent; - // let membership: CallMembership; + describe("expiry calculation", () => { + let fakeEvent: MatrixEvent; + let membership: CallMembership; - // beforeEach(() => { - // // server origin timestamp for this event is 1000 - // fakeEvent = makeMockEvent(1000); - // membership = new CallMembership(fakeEvent!, membershipTemplate); + beforeEach(() => { + // server origin timestamp for this event is 1000 + fakeEvent = makeMockEvent(1000); + membership = new CallMembership(fakeEvent!, membershipTemplate); - // jest.useFakeTimers(); - // }); + jest.useFakeTimers(); + }); - // afterEach(() => { - // jest.useRealTimers(); - // }); + afterEach(() => { + jest.useRealTimers(); + }); - // eslint-disable-next-line jest/no-commented-out-tests - // it("calculates time until expiry", () => { - // jest.setSystemTime(2000); - // // should be using absolute expiry time - // expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); - // }); - // }); + it("calculates time until expiry", () => { + jest.setSystemTime(2000); + // should be using absolute expiry time + expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); + }); + }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index d37557441..4bcd23ae8 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import { encodeBase64, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; -import { SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { SessionMembershipData, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { randomString } from "../../../src/randomstring"; @@ -57,21 +57,19 @@ describe("MatrixRTCSession", () => { expect(sess?.callId).toEqual(""); }); - // TODO: re-enable this test when expiry is implemented - // eslint-disable-next-line jest/no-commented-out-tests - // it("ignores expired memberships events", () => { - // jest.useFakeTimers(); - // const expiredMembership = Object.assign({}, membershipTemplate); - // expiredMembership.expires = 1000; - // expiredMembership.device_id = "EXPIRED"; - // const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); + it("ignores expired memberships events", () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); - // jest.advanceTimersByTime(2000); - // sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - // expect(sess?.memberships.length).toEqual(1); - // expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - // jest.useRealTimers(); - // }); + jest.advanceTimersByTime(2000); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); it("ignores memberships events of members not in the room", () => { const mockRoom = makeMockRoom(membershipTemplate); @@ -80,19 +78,17 @@ describe("MatrixRTCSession", () => { expect(sess?.memberships.length).toEqual(0); }); - // TODO: re-enable this test when expiry is implemented - // eslint-disable-next-line jest/no-commented-out-tests - // it("honours created_ts", () => { - // jest.useFakeTimers(); - // jest.setSystemTime(500); - // const expiredMembership = Object.assign({}, membershipTemplate); - // expiredMembership.created_ts = 500; - // expiredMembership.expires = 1000; - // const mockRoom = makeMockRoom([expiredMembership]); - // sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - // expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - // jest.useRealTimers(); - // }); + it("honours created_ts", () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); it("returns empty session if no membership events are present", () => { const mockRoom = makeMockRoom([]); @@ -273,6 +269,55 @@ describe("MatrixRTCSession", () => { }); }); + describe("getsActiveFocus", () => { + const firstPreferredFocus = { + type: "livekit", + livekit_service_url: "https://active.url", + livekit_alias: "!active:active.url", + }; + it("gets the correct active focus with oldest_membership", () => { + jest.useFakeTimers(); + jest.setSystemTime(3000); + const mockRoom = makeMockRoom([ + Object.assign({}, membershipTemplate, { + device_id: "foo", + created_ts: 500, + foci_preferred: [firstPreferredFocus], + }), + Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + ]); + + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { + type: "livekit", + focus_selection: "oldest_membership", + }); + expect(sess.getActiveFocus()).toBe(firstPreferredFocus); + jest.useRealTimers(); + }); + it("does not provide focus if the selection method is unknown", () => { + const mockRoom = makeMockRoom([ + Object.assign({}, membershipTemplate, { + device_id: "foo", + created_ts: 500, + foci_preferred: [firstPreferredFocus], + }), + Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + ]); + + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { + type: "livekit", + focus_selection: "unknown", + }); + expect(sess.getActiveFocus()).toBe(undefined); + }); + }); + describe("joining", () => { let mockRoom: Room; let sendStateEventMock: jest.Mock; @@ -323,6 +368,68 @@ describe("MatrixRTCSession", () => { expect(sess!.isJoined()).toEqual(true); }); + it("sends a membership event when joining a call", async () => { + const realSetTimeout = setTimeout; + jest.useFakeTimers(); + sess!.joinRoomSession([mockFocus], mockFocus); + await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]); + expect(client.sendStateEvent).toHaveBeenCalledWith( + mockRoom!.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + device_id: "AAAAAAA", + expires: DEFAULT_EXPIRE_DURATION, + foci_preferred: [mockFocus], + focus_active: { + focus_selection: "oldest_membership", + type: "livekit", + }, + }, + "_@alice:example.org_AAAAAAA", + ); + await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]); + // Because we actually want to send the state + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + // For checking if the delayed event is still there or got removed while sending the state. + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + // For scheduling the delayed event + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + // This returns no error so we do not check if we reschedule the event again. this is done in another test. + + jest.useRealTimers(); + }); + + it("uses membershipExpiryTimeout from join config", async () => { + const realSetTimeout = setTimeout; + jest.useFakeTimers(); + sess!.joinRoomSession([mockFocus], mockFocus, { membershipExpiryTimeout: 60000 }); + await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]); + expect(client.sendStateEvent).toHaveBeenCalledWith( + mockRoom!.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + device_id: "AAAAAAA", + expires: 60000, + foci_preferred: [mockFocus], + focus_active: { + focus_selection: "oldest_membership", + type: "livekit", + }, + }, + + "_@alice:example.org_AAAAAAA", + ); + await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]); + 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" }; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ec6a2f4d7..ce8f2ab65 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -19,6 +19,13 @@ import { deepCompare } from "../utils.ts"; import { Focus } from "./focus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; +/** + * The default duration in milliseconds that a membership is considered valid for. + * Ordinarily the client responsible for the session will update the membership before it expires. + * We use this duration as the fallback case where stale sessions are present for some reason. + */ +export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; + type CallScope = "m.room" | "m.user"; /** @@ -154,32 +161,26 @@ export class CallMembership { * Gets the absolute expiry timestamp of the membership. * @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable */ - public getAbsoluteExpiry(): number | undefined { - // TODO: implement this in a future PR. Something like: + public getAbsoluteExpiry(): number { // TODO: calculate this from the MatrixRTCSession join configuration directly - // return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION); - - return undefined; + return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION); } /** * @returns The number of milliseconds until the membership expires or undefined if applicable */ - public getMsUntilExpiry(): number | undefined { - // TODO: implement this in a future PR. Something like: - // return this.getAbsoluteExpiry() - Date.now(); - - return undefined; + public getMsUntilExpiry(): number { + // Assume that local clock is sufficiently in sync with other clocks in the distributed system. + // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. + // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 + return this.getAbsoluteExpiry() - Date.now(); } /** * @returns true if the membership has expired, otherwise false */ public isExpired(): boolean { - // TODO: implement this in a future PR. Something like: - // return this.getMsUntilExpiry() <= 0; - - return false; + return this.getMsUntilExpiry() <= 0; } public getPreferredFoci(): Focus[] { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 8b65771ae..c31f3a176 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -21,7 +21,7 @@ import { Room } from "../models/room.ts"; import { MatrixClient } from "../client.ts"; import { EventType } from "../@types/event.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts"; -import { CallMembership, SessionMembershipData } from "./CallMembership.ts"; +import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { Focus } from "./focus.ts"; import { secureRandomBase64Url } from "../randomstring.ts"; @@ -33,8 +33,6 @@ import { MatrixEvent } from "../models/event.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { sleep } from "../utils.ts"; -const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; // 4 hours - const logger = rootLogger.getChild("MatrixRTCSession"); const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;