From 4f9ca2c69706881e9b73e03a4004951bb572f542 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 26 Jun 2025 14:19:51 +0200 Subject: [PATCH] Remove `LegacyMembershipManager` (#4862) * Remove `LegacyMemberhsipManager` * remove tests from rtc session Those tests were only run with the legacy membership manager and are redundant with the memberhsip manager test spec. * fix tests * dont use non existing TestManager anymore * remove fails for legacy * fix another test --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 150 +------ spec/unit/matrixrtc/MembershipManager.spec.ts | 128 +++--- .../matrixrtc/memberManagerTestEnvironment.ts | 54 --- src/matrixrtc/LegacyMembershipManager.ts | 414 ------------------ src/matrixrtc/MatrixRTCSession.ts | 26 +- ...bershipManager.ts => MembershipManager.ts} | 6 +- ...ts => MembershipManagerActionScheduler.ts} | 2 +- 7 files changed, 77 insertions(+), 703 deletions(-) delete mode 100644 spec/unit/matrixrtc/memberManagerTestEnvironment.ts delete mode 100644 src/matrixrtc/LegacyMembershipManager.ts rename src/matrixrtc/{NewMembershipManager.ts => MembershipManager.ts} (99%) rename src/matrixrtc/{NewMembershipManagerActionScheduler.ts => MembershipManagerActionScheduler.ts} (98%) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 423f5f13e..b36a7eb74 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, type MatrixError, type MatrixEvent, type Room } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; -import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { secureRandomString } from "../../../src/randomstring"; @@ -201,58 +201,6 @@ describe("MatrixRTCSession", () => { }); }); - describe("updateCallMembershipEvent", () => { - const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; - const joinSessionConfig = {}; - - const sessionMembershipData: SessionMembershipData = { - call_id: "", - scope: "m.room", - application: "m.call", - device_id: "AAAAAAA_session", - focus_active: mockFocus, - foci_preferred: [mockFocus], - }; - - let sendStateEventMock: jest.Mock; - let sendDelayedStateMock: jest.Mock; - - let sentStateEvent: Promise; - let sentDelayedState: Promise; - - beforeEach(() => { - sentStateEvent = new Promise((resolve) => { - sendStateEventMock = jest.fn(resolve); - }); - sentDelayedState = new Promise((resolve) => { - sendDelayedStateMock = jest.fn(() => { - resolve(); - return { - delay_id: "id", - }; - }); - }); - client.sendStateEvent = sendStateEventMock; - client._unstable_sendDelayedStateEvent = sendDelayedStateMock; - }); - - async function testSession(membershipData: SessionMembershipData): Promise { - sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData)); - - sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig); - await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]); - - expect(sendStateEventMock).toHaveBeenCalledTimes(1); - - await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]); - expect(sendDelayedStateMock).toHaveBeenCalledTimes(1); - } - - it("sends events", async () => { - await testSession(sessionMembershipData); - }); - }); - describe("getOldestMembership", () => { it("returns the oldest membership event", () => { jest.useFakeTimers(); @@ -320,28 +268,10 @@ describe("MatrixRTCSession", () => { describe("joining", () => { let mockRoom: Room; - let sendStateEventMock: jest.Mock; - let sendDelayedStateMock: jest.Mock; let sendEventMock: jest.Mock; - let sentStateEvent: Promise; - let sentDelayedState: Promise; - beforeEach(() => { - sentStateEvent = new Promise((resolve) => { - sendStateEventMock = jest.fn(resolve); - }); - sentDelayedState = new Promise((resolve) => { - sendDelayedStateMock = jest.fn(() => { - resolve(); - return { - delay_id: "id", - }; - }); - }); sendEventMock = jest.fn(); - client.sendStateEvent = sendStateEventMock; - client._unstable_sendDelayedStateEvent = sendDelayedStateMock; client.sendEvent = sendEventMock; client._unstable_updateDelayedEvent = jest.fn(); @@ -367,67 +297,6 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus); 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 membershipEventExpiryMs from join config", async () => { - const realSetTimeout = setTimeout; - jest.useFakeTimers(); - sess!.joinRoomSession([mockFocus], mockFocus, { membershipEventExpiryMs: 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("onMembershipsChanged", () => { @@ -489,9 +358,9 @@ describe("MatrixRTCSession", () => { let sendToDeviceMock: jest.Mock; beforeEach(() => { - sendStateEventMock = jest.fn(); - sendDelayedStateMock = jest.fn(); - sendEventMock = jest.fn(); + sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" }); + sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" }); + sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" }); sendToDeviceMock = jest.fn(); client.sendStateEvent = sendStateEventMock; client._unstable_sendDelayedStateEvent = sendDelayedStateMock; @@ -569,24 +438,22 @@ describe("MatrixRTCSession", () => { let firstEventSent = false; try { - const eventSentPromise = new Promise((resolve) => { + const eventSentPromise = new Promise<{ event_id: string }>((resolve) => { sendEventMock.mockImplementation(() => { if (!firstEventSent) { - jest.advanceTimersByTime(10000); - firstEventSent = true; const e = new Error() as MatrixError; e.data = {}; throw e; } else { - resolve(); + resolve({ event_id: "id" }); } }); }); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - await jest.runAllTimersAsync(); - + // wait for the encryption event to get sent + await jest.advanceTimersByTimeAsync(5000); await eventSentPromise; expect(sendEventMock).toHaveBeenCalledTimes(2); @@ -993,7 +860,6 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, - useNewMembershipManager: true, useExperimentalToDeviceTransport: true, }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 91d15f56e..18b2fb630 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts - */ /* Copyright 2025 The Matrix.org Foundation C.I.C. @@ -27,10 +24,9 @@ import { 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 { logger } from "../../../src/logger.ts"; +import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -68,15 +64,7 @@ function createAsyncHandle(method: MockedFunction) { 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 }) => { +describe("MembershipManager", () => { let client: MockClient; let room: Room; const focusActive: LivekitFocusActive = { @@ -107,12 +95,12 @@ describe.each([ describe("isActivated()", () => { it("defaults to false", () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); expect(manager.isActivated()).toEqual(false); }); it("returns true after join()", () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([]); expect(manager.isActivated()).toEqual(true); }); @@ -126,7 +114,7 @@ describe.each([ const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); // Test - const memberManager = new TestMembershipManager(undefined, room, client, () => undefined); + const memberManager = new MembershipManager(undefined, room, client, () => undefined); memberManager.join([focus], focusActive); // expects await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); @@ -156,7 +144,7 @@ describe.each([ }); it("reschedules delayed leave event if sending state cancels it", async () => { - const memberManager = new TestMembershipManager(undefined, room, client, () => undefined); + const memberManager = new MembershipManager(undefined, room, client, () => undefined); const waitForSendState = waitForMockCall(client.sendStateEvent); const waitForUpdateDelaye = waitForMockCallOnce( client._unstable_updateDelayedEvent, @@ -225,7 +213,7 @@ describe.each([ return Promise.reject(error); }); }); - const manager = new TestMembershipManager( + const manager = new MembershipManager( { delayedLeaveEventDelayMs: 9000, }, @@ -288,7 +276,7 @@ describe.each([ 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); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); delayedHandle.reject?.( new UnsupportedDelayedEventsEndpointError( @@ -300,14 +288,14 @@ describe.each([ }); 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); + const manager = new MembershipManager({}, 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 delayedLeaveEventDelayMs from config", () => { - const manager = new TestMembershipManager( + const manager = new MembershipManager( { delayedLeaveEventDelayMs: 123456 }, room, client, @@ -324,9 +312,9 @@ describe.each([ }); }); - it("rejoins if delayed event is not found (404) !FailsForLegacy", async () => { + it("rejoins if delayed event is not found (404)", async () => { const RESTART_DELAY = 15000; - const manager = new TestMembershipManager( + const manager = new MembershipManager( { delayedLeaveEventRestartMs: RESTART_DELAY }, room, client, @@ -363,12 +351,7 @@ describe.each([ }); it("uses membershipEventExpiryMs from config", async () => { - const manager = new TestMembershipManager( - { membershipEventExpiryMs: 1234567 }, - room, - client, - () => undefined, - ); + const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined); manager.join([focus], focusActive); await waitForMockCall(client.sendStateEvent); @@ -392,7 +375,7 @@ describe.each([ }); it("does nothing if join called when already joined", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); @@ -404,7 +387,7 @@ describe.each([ describe("leave()", () => { // TODO add rate limit cases. it("resolves delayed leave event when leave is called", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); await manager.leave(); @@ -412,7 +395,7 @@ describe.each([ 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); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); @@ -426,9 +409,8 @@ describe.each([ "_@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); + it("does nothing if not joined", () => { + const manager = new MembershipManager({}, room, client, () => undefined); expect(async () => await manager.leave()).not.toThrow(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -438,7 +420,7 @@ describe.each([ describe("getsActiveFocus", () => { it("gets the correct active focus with oldest_membership", () => { const getOldestMembership = jest.fn(); - const manager = new TestMembershipManager({}, room, client, getOldestMembership); + const manager = new MembershipManager({}, room, client, getOldestMembership); // Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession) expect(manager.getActiveFocus()).toBe(undefined); manager.join([focus], focusActive); @@ -473,7 +455,7 @@ describe.each([ }); it("does not provide focus if the selection method is unknown", () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], Object.assign(focusActive, { type: "unknown_type" })); expect(manager.getActiveFocus()).toBe(undefined); }); @@ -481,7 +463,7 @@ describe.each([ describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -489,7 +471,7 @@ describe.each([ expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; @@ -510,7 +492,7 @@ describe.each([ expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` @@ -531,7 +513,7 @@ describe.each([ // TODO: Not sure about this name describe("background timers", () => { it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => { - const manager = new TestMembershipManager( + const manager = new MembershipManager( { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, room, client, @@ -557,12 +539,12 @@ describe.each([ } }); - // !FailsForLegacy because the expires logic was removed for the legacy call manager. + // 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( + const manager = new MembershipManager( { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, room, client, @@ -580,23 +562,23 @@ describe.each([ expect(sentMembership.expires).toBe(expire * i); } } - it("extends `expires` when call still active !FailsForLegacy", async () => { + it("extends `expires` when call still active", async () => { await testExpires(10_000); }); - it("extends `expires` using headroom configuration !FailsForLegacy", async () => { + it("extends `expires` using headroom configuration", async () => { await testExpires(10_000, 1_000); }); }); describe("status updates", () => { - it("starts 'Disconnected' !FailsForLegacy", () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + it("starts 'Disconnected'", () => { + const manager = new MembershipManager({}, room, client, () => undefined); expect(manager.status).toBe(Status.Disconnected); }); - it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => { + it("emits 'Connection' and 'Connected' after join", async () => { const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); const handleStateEvent = createAsyncHandle(client.sendStateEvent); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); expect(manager.status).toBe(Status.Disconnected); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); @@ -609,8 +591,8 @@ describe.each([ await jest.advanceTimersByTimeAsync(1); expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); }); - it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => { - const manager = new TestMembershipManager({}, room, client, () => undefined); + it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { + const manager = new MembershipManager({}, room, client, () => undefined); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.join([focus], focusActive); @@ -626,7 +608,7 @@ describe.each([ 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); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); @@ -643,8 +625,7 @@ describe.each([ 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 () => { + it("abandons retry loop and sends new own membership if not present anymore", async () => { (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, @@ -654,7 +635,7 @@ describe.each([ new Headers({ "Retry-After": "1" }), ), ); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the // RateLimit error. manager.join([focus], focusActive); @@ -671,11 +652,10 @@ describe.each([ 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 () => { + it("abandons retry loop if leave() was called before sending state event", async () => { const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); handle.reject?.( new MatrixError( @@ -700,7 +680,7 @@ describe.each([ }); }); describe("retries sending update delayed leave event restart", () => { - it("resends the initial check delayed update event !FailsForLegacy", async () => { + it("resends the initial check delayed update event", async () => { (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, @@ -710,7 +690,7 @@ describe.each([ new Headers({ "Retry-After": "1" }), ), ); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive); // Hit rate limit @@ -731,8 +711,8 @@ describe.each([ }); }); 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 () => { + // 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", async () => { const delayEventSendError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new MatrixError( @@ -743,7 +723,7 @@ describe.each([ new Headers({ "Retry-After": "2" }), ), ); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive, delayEventSendError); for (let i = 0; i < 10; i++) { @@ -751,8 +731,8 @@ describe.each([ } 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 () => { + // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. + it("throws, when reaching maximum number of retries", async () => { const delayEventRestartError = jest.fn(); (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( new MatrixError( @@ -763,7 +743,7 @@ describe.each([ new Headers({ "Retry-After": "1" }), ), ); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive, delayEventRestartError); for (let i = 0; i < 10; i++) { @@ -771,19 +751,19 @@ describe.each([ } expect(delayEventRestartError).toHaveBeenCalled(); }); - it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => { + it("falls back to using pure state events when some error occurs while sending delayed events", async () => { const unrecoverableError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, 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 () => { + it("retries before failing in case its a network error", async () => { const unrecoverableError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 501)); - const manager = new TestMembershipManager( + const manager = new MembershipManager( { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, room, client, @@ -800,12 +780,12 @@ describe.each([ ); expect(client.sendStateEvent).not.toHaveBeenCalled(); }); - it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => { + it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => { const unrecoverableError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), ); - const manager = new TestMembershipManager({}, room, client, () => undefined); + const manager = new MembershipManager({}, room, client, () => undefined); manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); @@ -828,5 +808,5 @@ it("Should prefix log with MembershipManager used", () => { expect(spy).toHaveBeenCalled(); const logline: string = spy.mock.calls[0][0]; - expect(logline.startsWith("[NewMembershipManager]")).toBe(true); + expect(logline.startsWith("[MembershipManager]")).toBe(true); }); diff --git a/spec/unit/matrixrtc/memberManagerTestEnvironment.ts b/spec/unit/matrixrtc/memberManagerTestEnvironment.ts deleted file mode 100644 index 65ca4204b..000000000 --- a/spec/unit/matrixrtc/memberManagerTestEnvironment.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* -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. -*/ - -/* -This file adds a custom test environment for the MembershipManager.spec.ts -It can be used with the comment at the top of the file: - -@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts - -It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour: - - The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager` - - It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy" - in their test name. -*/ - -import { TestEnvironment } from "jest-environment-jsdom"; - -import { logger as rootLogger } from "../../../src/logger"; -const logger = rootLogger.getChild("[MatrixRTCSession]"); - -class MemberManagerTestEnvironment extends TestEnvironment { - handleTestEvent(event: any) { - if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) { - let parent = event.test.parent; - let isLegacy = false; - while (parent) { - if (parent.name === "LegacyMembershipManager") { - isLegacy = true; - break; - } else { - parent = parent.parent; - } - } - if (isLegacy) { - logger.info("skip test: ", event.test.name); - event.test.mode = "skip"; - } - } - } -} -module.exports = MemberManagerTestEnvironment; diff --git a/src/matrixrtc/LegacyMembershipManager.ts b/src/matrixrtc/LegacyMembershipManager.ts deleted file mode 100644 index 691980ad4..000000000 --- a/src/matrixrtc/LegacyMembershipManager.ts +++ /dev/null @@ -1,414 +0,0 @@ -/* -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 { EventType } from "../@types/event.ts"; -import { UpdateDelayedEventAction } from "../@types/requests.ts"; -import type { MatrixClient } from "../client.ts"; -import { HTTPError, MatrixError } from "../http-api/errors.ts"; -import { logger } from "../logger.ts"; -import { EventTimeline } from "../models/event-timeline.ts"; -import { type Room } from "../models/room.ts"; -import { sleep } from "../utils.ts"; -import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; -import { type Focus } from "./focus.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; -import { type MembershipConfig } from "./MatrixRTCSession.ts"; -import { type EmptyObject } from "../@types/common.ts"; -import { Status } from "./types.ts"; -import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts"; - -/** - * This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session. - * - * Its responsibitiy is to manage the locals user membership: - * - send that sate event - * - send the delayed leave event - * - update the delayed leave event while connected - * - update the state event when it times out (for calls longer than membershipExpiryTimeout ~ 4h) - * - * It is possible to test this class on its own. The api surface (to use for tests) is - * defined in `MembershipManagerInterface`. - * - * It is recommended to only use this interface for testing to allow replacing this class. - * - * @internal - * @deprecated Use {@link MembershipManager} instead - */ -export class LegacyMembershipManager implements IMembershipManager { - private relativeExpiry: number | undefined; - - private memberEventTimeout?: ReturnType; - - /** - * This is a Foci array that contains the Focus objects this user is aware of and proposes to use. - */ - private ownFociPreferred?: Focus[]; - /** - * This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit") - */ - private ownFocusActive?: Focus; - - private updateCallMembershipRunning = false; - private needCallMembershipUpdate = false; - /** - * If the server disallows the configured {@link delayedLeaveEventDelayMs}, - * this stores a delay that the server does allow. - */ - private delayedLeaveEventDelayMsOverride?: number; - private disconnectDelayId: string | undefined; - - private get networkErrorRetryMs(): number { - return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000; - } - private get membershipEventExpiryMs(): number { - return ( - this.joinConfig?.membershipEventExpiryMs ?? - this.joinConfig?.membershipExpiryTimeout ?? - DEFAULT_EXPIRE_DURATION - ); - } - private get delayedLeaveEventDelayMs(): number { - return ( - this.delayedLeaveEventDelayMsOverride ?? - this.joinConfig?.delayedLeaveEventDelayMs ?? - this.joinConfig?.membershipServerSideExpiryTimeout ?? - 8_000 - ); - } - private get delayedLeaveEventRestartMs(): number { - return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000; - } - - public constructor( - private joinConfig: MembershipConfig | undefined, - private room: Pick, - private client: Pick< - MatrixClient, - | "getUserId" - | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" - >, - private getOldestMembership: () => CallMembership | undefined, - ) {} - - public off( - event: MembershipManagerEvent.StatusChanged, - listener: (oldStatus: Status, newStatus: Status) => void, - ): this { - logger.error("off is not implemented on LegacyMembershipManager"); - return this; - } - - public on( - event: MembershipManagerEvent.StatusChanged, - listener: (oldStatus: Status, newStatus: Status) => void, - ): this { - logger.error("on is not implemented on LegacyMembershipManager"); - return this; - } - - public isJoined(): boolean { - return this.relativeExpiry !== undefined; - } - public isActivated(): boolean { - return this.isJoined(); - } - /** - * Unimplemented - * @returns Status.Unknown - */ - public get status(): Status { - return Status.Unknown; - } - - public join(fociPreferred: Focus[], fociActive?: Focus): void { - this.ownFocusActive = fociActive; - this.ownFociPreferred = fociPreferred; - this.relativeExpiry = this.membershipEventExpiryMs; - // We don't wait for this, mostly because it may fail and schedule a retry, so this - // function returning doesn't really mean anything at all. - void this.triggerCallMembershipEventUpdate(); - } - - public async leave(timeout: number | undefined = undefined): Promise { - this.relativeExpiry = undefined; - this.ownFocusActive = undefined; - - if (this.memberEventTimeout) { - clearTimeout(this.memberEventTimeout); - this.memberEventTimeout = undefined; - } - if (timeout) { - // The sleep promise returns the string 'timeout' and the membership update void - // A success implies that the membership update was quicker then the timeout. - const raceResult = await Promise.race([this.triggerCallMembershipEventUpdate(), sleep(timeout, "timeout")]); - return raceResult !== "timeout"; - } else { - await this.triggerCallMembershipEventUpdate(); - return true; - } - } - - public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise { - const isMyMembership = (m: CallMembership): boolean => - m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); - - if (this.isJoined() && !memberships.some(isMyMembership)) { - logger.warn("Missing own membership: force re-join"); - // TODO: Should this be awaited? And is there anything to tell the focus? - return this.triggerCallMembershipEventUpdate(); - } - } - - public getActiveFocus(): Focus | undefined { - if (this.ownFocusActive) { - // A livekit active focus - if (isLivekitFocusActive(this.ownFocusActive)) { - if (this.ownFocusActive.focus_selection === "oldest_membership") { - const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; - } - } else { - logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU."); - } - } else { - // We do not understand the membership format (could be legacy). We default to oldestMembership - // Once there are other methods this is a hard error! - const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; - } - } - - private triggerCallMembershipEventUpdate = async (): Promise => { - // TODO: Should this await on a shared promise? - if (this.updateCallMembershipRunning) { - this.needCallMembershipUpdate = true; - return; - } - - this.updateCallMembershipRunning = true; - try { - // if anything triggers an update while the update is running, do another update afterwards - do { - this.needCallMembershipUpdate = false; - await this.updateCallMembershipEvent(); - } while (this.needCallMembershipUpdate); - } finally { - this.updateCallMembershipRunning = false; - } - }; - private makeNewMembership(deviceId: string): SessionMembershipData | EmptyObject { - // If we're joined, add our own - if (this.isJoined()) { - return this.makeMyMembership(deviceId); - } - return {}; - } - - /** - * Constructs our own membership - */ - private makeMyMembership(deviceId: string): SessionMembershipData { - return { - call_id: "", - scope: "m.room", - application: "m.call", - device_id: deviceId, - expires: this.relativeExpiry, - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - foci_preferred: this.ownFociPreferred ?? [], - }; - } - - private async updateCallMembershipEvent(): Promise { - if (this.memberEventTimeout) { - clearTimeout(this.memberEventTimeout); - this.memberEventTimeout = undefined; - } - - const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS); - if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId); - - const localUserId = this.client.getUserId(); - const localDeviceId = this.client.getDeviceId(); - if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!"); - - let newContent: EmptyObject | SessionMembershipData = {}; - // TODO: add back expiary logic to non-legacy events - // previously we checked here if the event is timed out and scheduled a check if not. - // maybe there is a better way. - newContent = this.makeNewMembership(localDeviceId); - - try { - if (this.isJoined()) { - const stateKey = this.makeMembershipStateKey(localUserId, localDeviceId); - const prepareDelayedDisconnection = async (): Promise => { - try { - const res = await resendIfRateLimited(() => - this.client._unstable_sendDelayedStateEvent( - this.room.roomId, - { - delay: this.delayedLeaveEventDelayMs, - }, - EventType.GroupCallMemberPrefix, - {}, // leave event - stateKey, - ), - ); - this.disconnectDelayId = res.delay_id; - } catch (e) { - if ( - e instanceof MatrixError && - e.errcode === "M_UNKNOWN" && - e.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED" - ) { - const maxDelayAllowed = e.data["org.matrix.msc4140.max_delay"]; - if ( - typeof maxDelayAllowed === "number" && - this.delayedLeaveEventDelayMs > maxDelayAllowed - ) { - this.delayedLeaveEventDelayMsOverride = maxDelayAllowed; - return prepareDelayedDisconnection(); - } - } - logger.error("Failed to prepare delayed disconnection event:", e); - } - }; - - await prepareDelayedDisconnection(); - // Send join event _after_ preparing the delayed disconnection event - await resendIfRateLimited(() => - this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, stateKey), - ); - // If sending state cancels your own delayed state, prepare another delayed state - // TODO: Remove this once MSC4140 is stable & doesn't cancel own delayed state - if (this.disconnectDelayId !== undefined) { - try { - const knownDisconnectDelayId = this.disconnectDelayId; - await resendIfRateLimited(() => - this.client._unstable_updateDelayedEvent( - knownDisconnectDelayId, - UpdateDelayedEventAction.Restart, - ), - ); - } catch (e) { - if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") { - // If we get a M_NOT_FOUND we prepare a new delayed event. - // In other error cases we do not want to prepare anything since we do not have the guarantee, that the - // future is not still running. - logger.warn("Failed to update delayed disconnection event, prepare it again:", e); - this.disconnectDelayId = undefined; - await prepareDelayedDisconnection(); - } - } - } - if (this.disconnectDelayId !== undefined) { - this.scheduleDelayDisconnection(); - } - // TODO throw or log an error if this.disconnectDelayId === undefined - } else { - // Not joined - let sentDelayedDisconnect = false; - if (this.disconnectDelayId !== undefined) { - try { - const knownDisconnectDelayId = this.disconnectDelayId; - await resendIfRateLimited(() => - this.client._unstable_updateDelayedEvent( - knownDisconnectDelayId, - UpdateDelayedEventAction.Send, - ), - ); - sentDelayedDisconnect = true; - } catch (e) { - logger.error("Failed to send our delayed disconnection event:", e); - } - this.disconnectDelayId = undefined; - } - if (!sentDelayedDisconnect) { - await resendIfRateLimited(() => - this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - {}, - this.makeMembershipStateKey(localUserId, localDeviceId), - ), - ); - } - } - logger.info("Sent updated call member event."); - } catch (e) { - const resendDelay = this.networkErrorRetryMs; - logger.warn(`Failed to send call member event (retrying in ${resendDelay}): ${e}`); - await sleep(resendDelay); - await this.triggerCallMembershipEventUpdate(); - } - } - - private scheduleDelayDisconnection(): void { - this.memberEventTimeout = setTimeout(() => void this.delayDisconnection(), this.delayedLeaveEventRestartMs); - } - - private readonly delayDisconnection = async (): Promise => { - try { - const knownDisconnectDelayId = this.disconnectDelayId!; - await resendIfRateLimited(() => - this.client._unstable_updateDelayedEvent(knownDisconnectDelayId, UpdateDelayedEventAction.Restart), - ); - this.scheduleDelayDisconnection(); - } catch (e) { - logger.error("Failed to delay our disconnection event:", e); - } - }; - - private makeMembershipStateKey(localUserId: string, localDeviceId: string): string { - const stateKey = `${localUserId}_${localDeviceId}`; - if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) { - return stateKey; - } else { - return `_${stateKey}`; - } - } -} - -async function resendIfRateLimited(func: () => Promise, numRetriesAllowed: number = 1): Promise { - // eslint-disable-next-line no-constant-condition - while (true) { - try { - return await func(); - } catch (e) { - if (numRetriesAllowed > 0 && e instanceof HTTPError && e.isRateLimitError()) { - numRetriesAllowed--; - let resendDelay: number; - const defaultMs = 5000; - try { - resendDelay = e.getRetryAfterMs() ?? defaultMs; - logger.info(`Rate limited by server, retrying in ${resendDelay}ms`); - } catch (e) { - logger.warn( - `Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`, - e, - ); - resendDelay = defaultMs; - } - await sleep(resendDelay); - } else { - throw e; - } - } - } -} diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 07e89baa3..1951b676a 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,9 +24,8 @@ import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type Focus } from "./focus.ts"; import { KnownMembership } from "../@types/membership.ts"; -import { MembershipManager } from "./NewMembershipManager.ts"; +import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; -import { LegacyMembershipManager } from "./LegacyMembershipManager.ts"; import { logDurationSync } from "../utils.ts"; import { type Statistics } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; @@ -77,6 +76,7 @@ export interface MembershipConfig { * Use the new Manager. * * Default: `false`. + * @deprecated does nothing anymore we always default to the new memberhip manager. */ useNewMembershipManager?: boolean; @@ -387,19 +387,15 @@ export class MatrixRTCSession extends TypedEventEmitter< return; } else { // Create MembershipManager and pass the RTCSession logger (with room id info) - if (joinConfig?.useNewMembershipManager ?? false) { - this.membershipManager = new MembershipManager( - joinConfig, - this.roomSubset, - this.client, - () => this.getOldestMembership(), - this.logger, - ); - } else { - this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () => - this.getOldestMembership(), - ); - } + + this.membershipManager = new MembershipManager( + joinConfig, + this.roomSubset, + this.client, + () => this.getOldestMembership(), + this.logger, + ); + // Create Encryption manager let transport; if (joinConfig?.useExperimentalToDeviceTransport) { diff --git a/src/matrixrtc/NewMembershipManager.ts b/src/matrixrtc/MembershipManager.ts similarity index 99% rename from src/matrixrtc/NewMembershipManager.ts rename to src/matrixrtc/MembershipManager.ts index 1250335d4..6839469c4 100644 --- a/src/matrixrtc/NewMembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -26,7 +26,7 @@ import { type Focus } from "./focus.ts"; import { isMyMembership, Status } from "./types.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { type MembershipConfig } from "./MatrixRTCSession.ts"; -import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts"; +import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { MembershipManagerEvent, @@ -233,7 +233,7 @@ export class MembershipManager if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) { this.logger.error( - "NewMembershipManger tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:", + "tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:", this.scheduler.actions, ); } else { @@ -285,7 +285,7 @@ export class MembershipManager parentLogger?: Logger, ) { super(); - this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`); + this.logger = (parentLogger ?? rootLogger).getChild(`[MembershipManager]`); const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()]; if (userId === null) throw Error("Missing userId in client"); if (deviceId === null) throw Error("Missing deviceId in client"); diff --git a/src/matrixrtc/NewMembershipManagerActionScheduler.ts b/src/matrixrtc/MembershipManagerActionScheduler.ts similarity index 98% rename from src/matrixrtc/NewMembershipManagerActionScheduler.ts rename to src/matrixrtc/MembershipManagerActionScheduler.ts index 28e2d3d22..10572dfec 100644 --- a/src/matrixrtc/NewMembershipManagerActionScheduler.ts +++ b/src/matrixrtc/MembershipManagerActionScheduler.ts @@ -1,7 +1,7 @@ import { type Logger, logger as rootLogger } from "../logger.ts"; import { type EmptyObject } from "../matrix.ts"; import { sleep } from "../utils.ts"; -import { MembershipActionType } from "./NewMembershipManager.ts"; +import { MembershipActionType } from "./MembershipManager.ts"; /** @internal */ export interface Action {