1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-09-10 17:31:53 +03:00
Files
matrix-js-sdk/spec/unit/matrixrtc/MembershipManager.spec.ts
Timo 9f9be701e7 MatrixRTC: New membership manager (#4726)
* WIP doodles on MembershipManager test cases

* .

* initial membership manager test setup.

* Updates from discussion

* revert renaming comments

* remove unused import

* fix leave delayed event resend test.
It was missing a flush.

* comment out and remove unused variables

* es lint

* use jsdom instead of node test environment

* remove unused variables

* remove unused export

* temp

* review

* fixup tests

* more review

* remove wait for expect dependency

* temp

* fix wrong mocked meberhsip template

* rename MembershipManager -> LegacyMembershipManager
And remove the IMembershipManager from it

* Add new memberhsip manager

* fix tests to be compatible with old and new membership manager

* Comment cleanup

* Allow join to throw
 - Add tests for throwing cases
 - Fixs based on tests

* introduce membershipExpiryTimeoutSlack

* more detailed comments and cleanup

* warn if slack is misconfigured and use default values instead

* fix action resets.

* flatten MembershipManager.spec.ts

* rename testEnvironment to memberManagerTestEnvironment

* allow configuring Legacy manager in the matrixRTC session

* deprecate LegacyMembershipManager

* remove usage of waitForExpect

* flatten tests and add comments

* clean up leave logic branch

* add more leave test cases

* use defer

* review ("Some minor tidying things for now.")

* add onError for join method and cleanup

* use pop instead of filter

* fixes

* simplify error handling and MembershipAction
Only use one membership action enum

* Add diagram

* fix new error api in rtc session

* fix up retry counter

* fix lints

* make unrecoverable errors more explicit

* fix tests

* Allow multiple retries on the rtc state event http requests.

* use then catch for startup

* no try catch 1

* update expire headroom logic
transition from try catch to .then .catch

* replace flushPromise with advanceTimersByTimeAsync

* fix leaving special cases

* more unrecoverable errors special cases

* move to MatrixRTCSessionManager logger

* add state reset and add another unhandleable error
The error occurs if we want to cancel the delayed event we still have an id for but get a non expected error.

* missed review fixes

* remove @jest/environment dependency

* Cleanup awaits and Make mock types more correct.
Make every mock return a Promise if the real implementation does return a pormise.

* remove flush promise dependency

* fix not recreating default state on reset
This broke all tests since we only created the state once and than passed by ref

* Use per action rate limit and retry counter
There can be multiple retries at once so we need to store counters per action
e.g. the send update membership and the restart delayed could be rate limited at the same time.

* add linting to matrixrtc tests

* Add fix async lints and use matrix rtc logger for test environment.

* prettier

* review step 1

* change to MatrixRTCSession logger

* review step 2

* make LoopHandler Private

* update config to use NewManager wording

* emit error on rtc session if the membership manager encounters one

* network error and throw refactor

* make accessing the full room deprecated

* remove deprecated usage of full room

* Clean up the deprecation

* add network error handler and cleanup

* better logging, another test, make maximumNetworkErrorRetryCount configurable

* more logging & refactor leave promise

* add ConnectionError as possible retry cause

* Make it work in embedded mode with a server that does not support delayed events

* review iteration 1

* review iteration 2

* first step in improving widget error handling

* make the embedded client throw ConnectionErrors where desired.

* fix tests

* delayed event sending widget mode stop gap fix.

* improve comment

* fix unrecoverable error joinState (and add JoinStateChanged) emission.

* check that we do not add multipe sendFirstDelayed Events

* also check insertions queue

* always log "Missing own membership: force re-join"

* Do not update the membership if we are in any (a later) state of sending our own state.
The scheduled states MembershipActionType.SendFirstDelayedEvent and MembershipActionType.SendJoinEvent both imply that we are already trying to send our own membership state event.

* make leave reset actually stop the manager.
The reset case was not covered properly. There are cases where it is not allowed to add additional events after a reset and cases where we want to add more events after the reset. We need to allow this as a reset property.

* fix tests (and implementation)

* Allow MembershipManger to be set at runtime via JoinConfig.membershipManagerFactory

* Map actions into status as a sanity check

* Log status change after applying actions

* Add todo

* Cleanup

* Log transition from earlier status

* remove redundant status implementation
also add TODO comment to not forget about this.

* More cleanup

* Consider insertions in status()

* Log duration for emitting MatrixRTCSessionEvent.MembershipsChanged

* add another valid condition for connected

* some TODO cleanup

* review add warning when using addAction while the scheduler is not running.

* es lint

* refactor to return based handler approach (remove insertions array)

* refactor: Move action scheduler

* refactor: move different handler cases into separate functions

* linter

* review: delayed events endpoint error

* review

* Suggestions from pair review

* resetState is actually only used internally

* Revert "resetState is actually only used internally"

This reverts commit 6af4730919.

* refactor: running is part of the scheduler (not state)

* refactor: move everything state related from schduler to manager.

* review

* Update src/matrixrtc/NewMembershipManager.ts

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>

* review

* public -> private + missed review fiexes (comment typos)

---------

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
2025-03-11 17:49:01 +00:00

703 lines
34 KiB
TypeScript

/**
* @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, UnsupportedDelayedEventsEndpointError, type Room } from "../../../src";
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
import { defer } from "../../../src/utils";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
});
}
function createAsyncHandle(method: MockedFunction<any>) {
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<any>).mockResolvedValue({ delay_id: "id" });
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
(client.sendStateEvent as Mock<any>).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<void> {
// 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<void>((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<void>((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<void>((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?.(
new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"sendDelayedStateEvent",
),
);
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<any>).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", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
expect(async () => await manager.leave()).not.toThrow();
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.
async function testExpires(expire: number, headroom?: number) {
const manager = new TestMembershipManager(
{ membershipExpiryTimeout: expire, membershipExpiryTimeoutHeadroom: headroom },
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(expire);
for (let i = 2; i <= 12; i++) {
await jest.advanceTimersByTimeAsync(expire);
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
expect(sentMembership.expires).toBe(expire * i);
}
}
it("extends `expires` when call still active !FailsForLegacy", async () => {
await testExpires(10_000);
});
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
await testExpires(10_000, 1_000);
});
});
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<any>).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 before sending state event !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 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<any>).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<any>).mockResolvedValue(undefined);
await jest.advanceTimersByTimeAsync(1000);
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
});
});
describe("unrecoverable errors", () => {
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
const delayEventSendError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
undefined,
undefined,
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
await jest.advanceTimersByTimeAsync(2000);
}
expect(delayEventSendError).toHaveBeenCalled();
});
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
const delayEventRestartError = jest.fn();
(client._unstable_updateDelayedEvent as Mock<any>).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, delayEventRestartError);
for (let i = 0; i < 10; i++) {
await jest.advanceTimersByTimeAsync(1000);
}
expect(delayEventRestartError).toHaveBeenCalled();
});
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("retries before failing in case its a network error !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
const manager = new TestMembershipManager(
{ callMemberEventRetryDelayMinimum: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
() => undefined,
);
manager.join([focus], focusActive, unrecoverableError);
for (let retries = 0; retries < 7; retries++) {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1);
await jest.advanceTimersByTimeAsync(1000);
}
expect(unrecoverableError).toHaveBeenCalled();
expect(unrecoverableError.mock.lastCall![0].message).toMatch(
"The MembershipManager shut down because of the end condition",
);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
expect(unrecoverableError).not.toHaveBeenCalled();
expect(client.sendStateEvent).toHaveBeenCalled();
});
});
});