diff --git a/playwright/errors.spec.ts b/playwright/errors.spec.ts new file mode 100644 index 00000000..7671c103 --- /dev/null +++ b/playwright/errors.spec.ts @@ -0,0 +1,74 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +test("Should show error screen if fails to get JWT token", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + await page.route( + "**/openid/request_token", + async (route) => + await route.fulfill({ + // 418 is a non retryable error, so test will fail immediately + status: 418, + }), + ); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // Should fail + await expect(page.getByText("Something went wrong")).toBeVisible(); + await expect(page.getByText("OPEN_ID_ERROR")).toBeVisible(); +}); + +test("Should automatically retry non fatal JWT errors", async ({ + page, + browserName, +}) => { + test.skip( + browserName === "firefox", + "The test to check the video visibility is not working in Firefox CI environment. looks like video is disabled?", + ); + await page.goto("/"); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + let firstCall = true; + let hasRetriedCallback: (value: PromiseLike | void) => void; + const hasRetriedPromise = new Promise((resolve) => { + hasRetriedCallback = resolve; + }); + await page.route("**/openid/request_token", async (route) => { + if (firstCall) { + firstCall = false; + await route.fulfill({ + status: 429, + }); + } else { + await route.continue(); + hasRetriedCallback(); + } + }); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + // Expect that the call has been retried + await hasRetriedPromise; + await expect(page.getByTestId("video").first()).toBeVisible(); +}); diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index 0f455a38..4a82de23 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -12,6 +12,9 @@ import { useEffect, useState } from "react"; import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus"; import { useActiveLivekitFocus } from "../room/useActiveFocus"; +import { useGroupCallErrorBoundary } from "../room/useCallErrorBoundary.ts"; +import { FailToGetOpenIdToken } from "../utils/errors.ts"; +import { doNetworkOperationWithRetry } from "../utils/matrix.ts"; export interface SFUConfig { url: string; @@ -38,6 +41,7 @@ export function useOpenIDSFU( const [sfuConfig, setSFUConfig] = useState(undefined); const activeFocus = useActiveLivekitFocus(rtcSession); + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); useEffect(() => { if (activeFocus) { @@ -46,13 +50,14 @@ export function useOpenIDSFU( setSFUConfig(sfuConfig); }, (e) => { + showGroupCallErrorBoundary(new FailToGetOpenIdToken(e)); logger.error("Failed to get SFU config", e); }, ); } else { setSFUConfig(undefined); } - }, [client, activeFocus]); + }, [client, activeFocus, showGroupCallErrorBoundary]); return sfuConfig; } @@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID( client: OpenIDClientParts, activeFocus: LivekitFocus, ): Promise { - const openIdToken = await client.getOpenIdToken(); + let openIdToken: IOpenIDToken; + try { + openIdToken = await doNetworkOperationWithRetry(async () => + client.getOpenIdToken(), + ); + } catch (error) { + throw new FailToGetOpenIdToken( + error instanceof Error ? error : new Error("Unknown error"), + ); + } logger.debug("Got openID token", openIdToken); try { diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 9ebd7e3b..1b3d0f20 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -67,6 +67,7 @@ import { useSetting, } from "../settings/settings"; import { useTypedEventEmitter } from "../useEvents"; +import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; declare global { interface Window { @@ -120,11 +121,13 @@ export const GroupCallView: FC = ({ }; }, [rtcSession]); + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); + useTypedEventEmitter( rtcSession, MatrixRTCSessionEvent.MembershipManagerError, (error) => { - setError( + showGroupCallErrorBoundary( new RTCSessionError( ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE, error.message ?? error, @@ -173,30 +176,32 @@ export const GroupCallView: FC = ({ const latestDevices = useLatest(deviceContext); const latestMuteStates = useLatest(muteStates); - const enterRTCSessionOrError = async ( - rtcSession: MatrixRTCSession, - perParticipantE2EE: boolean, - newMembershipManager: boolean, - ): Promise => { - try { - await enterRTCSession( - rtcSession, - perParticipantE2EE, - newMembershipManager, - ); - } catch (e) { - if (e instanceof ElementCallError) { - // e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL) - setError(e); - } else { - logger.error(`Unknown Error while entering RTC session`, e); - const error = new UnknownCallError( - e instanceof Error ? e : new Error("Unknown error", { cause: e }), + const enterRTCSessionOrError = useCallback( + async ( + rtcSession: MatrixRTCSession, + perParticipantE2EE: boolean, + newMembershipManager: boolean, + ): Promise => { + try { + await enterRTCSession( + rtcSession, + perParticipantE2EE, + newMembershipManager, ); - setError(error); + } catch (e) { + if (e instanceof ElementCallError) { + showGroupCallErrorBoundary(e); + } else { + logger.error(`Unknown Error while entering RTC session`, e); + const error = new UnknownCallError( + e instanceof Error ? e : new Error("Unknown error", { cause: e }), + ); + showGroupCallErrorBoundary(error); + } } - } - }; + }, + [showGroupCallErrorBoundary], + ); useEffect(() => { const defaultDeviceSetup = async ({ @@ -289,11 +294,12 @@ export const GroupCallView: FC = ({ perParticipantE2EE, latestDevices, latestMuteStates, + enterRTCSessionOrError, useNewMembershipManager, ]); const [left, setLeft] = useState(false); - const [error, setError] = useState(null); + const navigate = useNavigate(); const onLeave = useCallback( @@ -416,14 +422,7 @@ export const GroupCallView: FC = ({ ); let body: ReactNode; - if (error) { - // If an ElementCallError was recorded, then create a component that will fail to render and throw - // the error. This will then be handled by the ErrorBoundary component. - const ErrorComponent = (): ReactNode => { - throw error; - }; - body = ; - } else if (isJoined) { + if (isJoined) { body = ( <> {shareModal} diff --git a/src/room/useCallErrorBoundary.test.tsx b/src/room/useCallErrorBoundary.test.tsx new file mode 100644 index 00000000..eccb8039 --- /dev/null +++ b/src/room/useCallErrorBoundary.test.tsx @@ -0,0 +1,51 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { type ReactElement, useCallback } from "react"; +import userEvent from "@testing-library/user-event"; +import { BrowserRouter } from "react-router-dom"; + +import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; +import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts"; +import { ConnectionLostError } from "../utils/errors.ts"; + +it("should show async error", async () => { + const user = userEvent.setup(); + + const TestComponent = (): ReactElement => { + const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary(); + + const onClick = useCallback((): void => { + showGroupCallErrorBoundary(new ConnectionLostError()); + }, [showGroupCallErrorBoundary]); + + return ( +
+

HELLO

+ +
+ ); + }; + + render( + + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Click me" })); + + await screen.findByText("Connection lost"); + + await user.click(screen.getByRole("button", { name: "Reconnect" })); + + await screen.findByText("HELLO"); +}); diff --git a/src/room/useCallErrorBoundary.ts b/src/room/useCallErrorBoundary.ts new file mode 100644 index 00000000..f89abf77 --- /dev/null +++ b/src/room/useCallErrorBoundary.ts @@ -0,0 +1,31 @@ +/* +Copyright 2023, 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useMemo, useState } from "react"; + +import type { ElementCallError } from "../utils/errors.ts"; + +export type UseErrorBoundaryApi = { + showGroupCallErrorBoundary: (error: ElementCallError) => void; +}; + +export function useGroupCallErrorBoundary(): UseErrorBoundaryApi { + const [error, setError] = useState(null); + + const memoized: UseErrorBoundaryApi = useMemo( + () => ({ + showGroupCallErrorBoundary: (error: ElementCallError) => setError(error), + }), + [], + ); + + if (error) { + throw error; + } + + return memoized; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index f8afda81..a3df68d0 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -17,6 +17,7 @@ export enum ErrorCode { /** LiveKit indicates that the server has hit its track limits */ INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", + OPEN_ID_ERROR = "OPEN_ID_ERROR", UNKNOWN_ERROR = "UNKNOWN_ERROR", } @@ -43,7 +44,7 @@ export class ElementCallError extends Error { localisedTitle: string, code: ErrorCode, category: ErrorCategory, - localisedMessage: string, + localisedMessage?: string, cause?: Error, ) { super(localisedTitle, { cause }); @@ -88,7 +89,6 @@ export class RTCSessionError extends ElementCallError { super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message); } } - export class E2EENotSupportedError extends ElementCallError { public constructor() { super( @@ -113,6 +113,19 @@ export class UnknownCallError extends ElementCallError { } } +export class FailToGetOpenIdToken extends ElementCallError { + public constructor(error: Error) { + super( + t("error.generic"), + ErrorCode.OPEN_ID_ERROR, + ErrorCategory.CONFIGURATION_ISSUE, + undefined, + // Properly set it as a cause for a better reporting on sentry + error, + ); + } +} + export class InsufficientCapacityError extends ElementCallError { public constructor() { super( diff --git a/src/utils/matrix.ts b/src/utils/matrix.ts index f1801b97..a7813528 100644 --- a/src/utils/matrix.ts +++ b/src/utils/matrix.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb"; import { MemoryStore } from "matrix-js-sdk/src/store/memory"; import { + calculateRetryBackoff, createClient, type ICreateClientOpts, Preset, @@ -17,6 +18,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync"; import { logger } from "matrix-js-sdk/src/logger"; import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring"; +import { sleep } from "matrix-js-sdk/src/utils"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; @@ -335,3 +337,30 @@ export function getRelativeRoomUrl( : ""; return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`; } + +/** + * Perfom a network operation with retries on ConnectionError. + * If the error is not retryable, or the max number of retries is reached, the error is rethrown. + * Supports handling of matrix quotas. + */ +export async function doNetworkOperationWithRetry( + operation: () => Promise, +): Promise { + let currentRetryCount = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + return await operation(); + } catch (e) { + currentRetryCount++; + const backoff = calculateRetryBackoff(e, currentRetryCount, true); + if (backoff < 0) { + // Max number of retries reached, or error is not retryable. rethrow the error + throw e; + } + // wait for the specified time and then retry the request + await sleep(backoff); + } + } +}