Skip to content

Commit 6f743bf

Browse files
toger5hughns
andauthored
MatrixRTC: Implement expiry logic for CallMembership and additional test 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 <[email protected]>
1 parent ffd3c95 commit 6f743bf

File tree

4 files changed

+188
-72
lines changed

4 files changed

+188
-72
lines changed

spec/unit/matrixrtc/CallMembership.spec.ts

+36-26
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ limitations under the License.
1515
*/
1616

1717
import { MatrixEvent } from "../../../src";
18-
import { CallMembership, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
18+
import { CallMembership, SessionMembershipData, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership";
19+
import { membershipTemplate } from "./mocks";
1920

2021
function makeMockEvent(originTs = 0): MatrixEvent {
2122
return {
@@ -74,6 +75,18 @@ describe("CallMembership", () => {
7475
expect(membership.createdTs()).toEqual(67890);
7576
});
7677

78+
it("considers memberships unexpired if local age low enough", () => {
79+
const fakeEvent = makeMockEvent(1000);
80+
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1));
81+
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false);
82+
});
83+
84+
it("considers memberships expired if local age large enough", () => {
85+
const fakeEvent = makeMockEvent(1000);
86+
fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1));
87+
expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true);
88+
});
89+
7790
it("returns preferred foci", () => {
7891
const fakeEvent = makeMockEvent();
7992
const mockFocus = { type: "this_is_a_mock_focus" };
@@ -85,29 +98,26 @@ describe("CallMembership", () => {
8598
});
8699
});
87100

88-
// TODO: re-enable this test when expiry is implemented
89-
// eslint-disable-next-line jest/no-commented-out-tests
90-
// describe("expiry calculation", () => {
91-
// let fakeEvent: MatrixEvent;
92-
// let membership: CallMembership;
93-
94-
// beforeEach(() => {
95-
// // server origin timestamp for this event is 1000
96-
// fakeEvent = makeMockEvent(1000);
97-
// membership = new CallMembership(fakeEvent!, membershipTemplate);
98-
99-
// jest.useFakeTimers();
100-
// });
101-
102-
// afterEach(() => {
103-
// jest.useRealTimers();
104-
// });
105-
106-
// eslint-disable-next-line jest/no-commented-out-tests
107-
// it("calculates time until expiry", () => {
108-
// jest.setSystemTime(2000);
109-
// // should be using absolute expiry time
110-
// expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
111-
// });
112-
// });
101+
describe("expiry calculation", () => {
102+
let fakeEvent: MatrixEvent;
103+
let membership: CallMembership;
104+
105+
beforeEach(() => {
106+
// server origin timestamp for this event is 1000
107+
fakeEvent = makeMockEvent(1000);
108+
membership = new CallMembership(fakeEvent!, membershipTemplate);
109+
110+
jest.useFakeTimers();
111+
});
112+
113+
afterEach(() => {
114+
jest.useRealTimers();
115+
});
116+
117+
it("calculates time until expiry", () => {
118+
jest.setSystemTime(2000);
119+
// should be using absolute expiry time
120+
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
121+
});
122+
});
113123
});

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

+136-29
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ limitations under the License.
1616

1717
import { encodeBase64, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
1818
import { KnownMembership } from "../../../src/@types/membership";
19-
import { SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
19+
import { SessionMembershipData, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership";
2020
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
2121
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
2222
import { randomString } from "../../../src/randomstring";
@@ -57,21 +57,19 @@ describe("MatrixRTCSession", () => {
5757
expect(sess?.callId).toEqual("");
5858
});
5959

60-
// TODO: re-enable this test when expiry is implemented
61-
// eslint-disable-next-line jest/no-commented-out-tests
62-
// it("ignores expired memberships events", () => {
63-
// jest.useFakeTimers();
64-
// const expiredMembership = Object.assign({}, membershipTemplate);
65-
// expiredMembership.expires = 1000;
66-
// expiredMembership.device_id = "EXPIRED";
67-
// const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
68-
69-
// jest.advanceTimersByTime(2000);
70-
// sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
71-
// expect(sess?.memberships.length).toEqual(1);
72-
// expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
73-
// jest.useRealTimers();
74-
// });
60+
it("ignores expired memberships events", () => {
61+
jest.useFakeTimers();
62+
const expiredMembership = Object.assign({}, membershipTemplate);
63+
expiredMembership.expires = 1000;
64+
expiredMembership.device_id = "EXPIRED";
65+
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]);
66+
67+
jest.advanceTimersByTime(2000);
68+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
69+
expect(sess?.memberships.length).toEqual(1);
70+
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
71+
jest.useRealTimers();
72+
});
7573

7674
it("ignores memberships events of members not in the room", () => {
7775
const mockRoom = makeMockRoom(membershipTemplate);
@@ -80,19 +78,17 @@ describe("MatrixRTCSession", () => {
8078
expect(sess?.memberships.length).toEqual(0);
8179
});
8280

83-
// TODO: re-enable this test when expiry is implemented
84-
// eslint-disable-next-line jest/no-commented-out-tests
85-
// it("honours created_ts", () => {
86-
// jest.useFakeTimers();
87-
// jest.setSystemTime(500);
88-
// const expiredMembership = Object.assign({}, membershipTemplate);
89-
// expiredMembership.created_ts = 500;
90-
// expiredMembership.expires = 1000;
91-
// const mockRoom = makeMockRoom([expiredMembership]);
92-
// sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
93-
// expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
94-
// jest.useRealTimers();
95-
// });
81+
it("honours created_ts", () => {
82+
jest.useFakeTimers();
83+
jest.setSystemTime(500);
84+
const expiredMembership = Object.assign({}, membershipTemplate);
85+
expiredMembership.created_ts = 500;
86+
expiredMembership.expires = 1000;
87+
const mockRoom = makeMockRoom([expiredMembership]);
88+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
89+
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
90+
jest.useRealTimers();
91+
});
9692

9793
it("returns empty session if no membership events are present", () => {
9894
const mockRoom = makeMockRoom([]);
@@ -273,6 +269,55 @@ describe("MatrixRTCSession", () => {
273269
});
274270
});
275271

272+
describe("getsActiveFocus", () => {
273+
const firstPreferredFocus = {
274+
type: "livekit",
275+
livekit_service_url: "https://active.url",
276+
livekit_alias: "!active:active.url",
277+
};
278+
it("gets the correct active focus with oldest_membership", () => {
279+
jest.useFakeTimers();
280+
jest.setSystemTime(3000);
281+
const mockRoom = makeMockRoom([
282+
Object.assign({}, membershipTemplate, {
283+
device_id: "foo",
284+
created_ts: 500,
285+
foci_preferred: [firstPreferredFocus],
286+
}),
287+
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
288+
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
289+
]);
290+
291+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
292+
293+
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
294+
type: "livekit",
295+
focus_selection: "oldest_membership",
296+
});
297+
expect(sess.getActiveFocus()).toBe(firstPreferredFocus);
298+
jest.useRealTimers();
299+
});
300+
it("does not provide focus if the selection method is unknown", () => {
301+
const mockRoom = makeMockRoom([
302+
Object.assign({}, membershipTemplate, {
303+
device_id: "foo",
304+
created_ts: 500,
305+
foci_preferred: [firstPreferredFocus],
306+
}),
307+
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
308+
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
309+
]);
310+
311+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
312+
313+
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
314+
type: "livekit",
315+
focus_selection: "unknown",
316+
});
317+
expect(sess.getActiveFocus()).toBe(undefined);
318+
});
319+
});
320+
276321
describe("joining", () => {
277322
let mockRoom: Room;
278323
let sendStateEventMock: jest.Mock;
@@ -323,6 +368,68 @@ describe("MatrixRTCSession", () => {
323368
expect(sess!.isJoined()).toEqual(true);
324369
});
325370

371+
it("sends a membership event when joining a call", async () => {
372+
const realSetTimeout = setTimeout;
373+
jest.useFakeTimers();
374+
sess!.joinRoomSession([mockFocus], mockFocus);
375+
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
376+
expect(client.sendStateEvent).toHaveBeenCalledWith(
377+
mockRoom!.roomId,
378+
EventType.GroupCallMemberPrefix,
379+
{
380+
application: "m.call",
381+
scope: "m.room",
382+
call_id: "",
383+
device_id: "AAAAAAA",
384+
expires: DEFAULT_EXPIRE_DURATION,
385+
foci_preferred: [mockFocus],
386+
focus_active: {
387+
focus_selection: "oldest_membership",
388+
type: "livekit",
389+
},
390+
},
391+
"_@alice:example.org_AAAAAAA",
392+
);
393+
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
394+
// Because we actually want to send the state
395+
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
396+
// For checking if the delayed event is still there or got removed while sending the state.
397+
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
398+
// For scheduling the delayed event
399+
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
400+
// This returns no error so we do not check if we reschedule the event again. this is done in another test.
401+
402+
jest.useRealTimers();
403+
});
404+
405+
it("uses membershipExpiryTimeout from join config", async () => {
406+
const realSetTimeout = setTimeout;
407+
jest.useFakeTimers();
408+
sess!.joinRoomSession([mockFocus], mockFocus, { membershipExpiryTimeout: 60000 });
409+
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
410+
expect(client.sendStateEvent).toHaveBeenCalledWith(
411+
mockRoom!.roomId,
412+
EventType.GroupCallMemberPrefix,
413+
{
414+
application: "m.call",
415+
scope: "m.room",
416+
call_id: "",
417+
device_id: "AAAAAAA",
418+
expires: 60000,
419+
foci_preferred: [mockFocus],
420+
focus_active: {
421+
focus_selection: "oldest_membership",
422+
type: "livekit",
423+
},
424+
},
425+
426+
"_@alice:example.org_AAAAAAA",
427+
);
428+
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
429+
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
430+
jest.useRealTimers();
431+
});
432+
326433
describe("calls", () => {
327434
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
328435
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };

src/matrixrtc/CallMembership.ts

+15-14
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ import { deepCompare } from "../utils.ts";
1919
import { Focus } from "./focus.ts";
2020
import { isLivekitFocusActive } from "./LivekitFocus.ts";
2121

22+
/**
23+
* The default duration in milliseconds that a membership is considered valid for.
24+
* Ordinarily the client responsible for the session will update the membership before it expires.
25+
* We use this duration as the fallback case where stale sessions are present for some reason.
26+
*/
27+
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
28+
2229
type CallScope = "m.room" | "m.user";
2330

2431
/**
@@ -154,32 +161,26 @@ export class CallMembership {
154161
* Gets the absolute expiry timestamp of the membership.
155162
* @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable
156163
*/
157-
public getAbsoluteExpiry(): number | undefined {
158-
// TODO: implement this in a future PR. Something like:
164+
public getAbsoluteExpiry(): number {
159165
// TODO: calculate this from the MatrixRTCSession join configuration directly
160-
// return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
161-
162-
return undefined;
166+
return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION);
163167
}
164168

165169
/**
166170
* @returns The number of milliseconds until the membership expires or undefined if applicable
167171
*/
168-
public getMsUntilExpiry(): number | undefined {
169-
// TODO: implement this in a future PR. Something like:
170-
// return this.getAbsoluteExpiry() - Date.now();
171-
172-
return undefined;
172+
public getMsUntilExpiry(): number {
173+
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
174+
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
175+
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
176+
return this.getAbsoluteExpiry() - Date.now();
173177
}
174178

175179
/**
176180
* @returns true if the membership has expired, otherwise false
177181
*/
178182
public isExpired(): boolean {
179-
// TODO: implement this in a future PR. Something like:
180-
// return this.getMsUntilExpiry() <= 0;
181-
182-
return false;
183+
return this.getMsUntilExpiry() <= 0;
183184
}
184185

185186
public getPreferredFoci(): Focus[] {

src/matrixrtc/MatrixRTCSession.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { Room } from "../models/room.ts";
2121
import { MatrixClient } from "../client.ts";
2222
import { EventType } from "../@types/event.ts";
2323
import { UpdateDelayedEventAction } from "../@types/requests.ts";
24-
import { CallMembership, SessionMembershipData } from "./CallMembership.ts";
24+
import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from "./CallMembership.ts";
2525
import { RoomStateEvent } from "../models/room-state.ts";
2626
import { Focus } from "./focus.ts";
2727
import { secureRandomBase64Url } from "../randomstring.ts";
@@ -33,8 +33,6 @@ import { MatrixEvent } from "../models/event.ts";
3333
import { isLivekitFocusActive } from "./LivekitFocus.ts";
3434
import { sleep } from "../utils.ts";
3535

36-
const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; // 4 hours
37-
3836
const logger = rootLogger.getChild("MatrixRTCSession");
3937

4038
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;

0 commit comments

Comments
 (0)