1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-07 23:02:56 +03:00

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 795a3cffb61d672941c49e8139eb1d7b15c87d73.

* remove code we do not need yet.

* Cleanup

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Timo
2025-01-07 11:14:09 +01:00
committed by GitHub
parent ffd3c9575e
commit 6f743bfa1f
4 changed files with 183 additions and 67 deletions

View File

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

View File

@@ -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" };

View File

@@ -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[] {

View File

@@ -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}`;