Skip to content

Commit

Permalink
MatrixRTC: Implement expiry logic for CallMembership and additional t…
Browse files Browse the repository at this point in the history
…est coverage (#4587)

* remove all legacy call related code and adjust tests.
We actually had a bit of tests just for legacy and not for session events. All those tests got ported over so we do not remove any tests.

* dont adjust tests but remove legacy tests

* Remove deprecated CallMembership.getLocalExpiry()

* Remove references to legacy in test case names

* Clean up SessionMembershipData tsdoc

* Remove CallMembership.expires

* Use correct expire duration.

* make expiration methods not return optional values and update docstring

* add docs to `SessionMembershipData`

* Add new tests for session type member events that before only existed for legacy member events.

This reverts commit 795a3cf.

* remove code we do not need yet.

* Cleanup

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
  • Loading branch information
toger5 and hughns authored Jan 7, 2025
1 parent ffd3c95 commit 6f743bf
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 72 deletions.
62 changes: 36 additions & 26 deletions spec/unit/matrixrtc/CallMembership.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" };
Expand All @@ -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;

// beforeEach(() => {
// // server origin timestamp for this event is 1000
// fakeEvent = makeMockEvent(1000);
// membership = new CallMembership(fakeEvent!, membershipTemplate);

// jest.useFakeTimers();
// });

// 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);
// });
// });
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);

jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it("calculates time until expiry", () => {
jest.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
});
165 changes: 136 additions & 29 deletions spec/unit/matrixrtc/MatrixRTCSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);

// 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 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();
});

it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom(membershipTemplate);
Expand All @@ -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([]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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" };
Expand Down
29 changes: 15 additions & 14 deletions src/matrixrtc/CallMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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[] {
Expand Down
4 changes: 1 addition & 3 deletions src/matrixrtc/MatrixRTCSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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}`;
Expand Down

0 comments on commit 6f743bf

Please sign in to comment.