diff --git a/src/components/production-line/production-line.tsx b/src/components/production-line/production-line.tsx index 4b256aee..42f2abc8 100644 --- a/src/components/production-line/production-line.tsx +++ b/src/components/production-line/production-line.tsx @@ -42,6 +42,8 @@ import { useLinePolling } from "./use-line-polling.ts"; import { useMasterInputMute } from "./use-master-input-mute.ts"; import { useMuteInput } from "./use-mute-input.tsx"; import { useUpdateCallDevice } from "./use-update-call-device.tsx"; +import { useSessionRecovery } from "./use-session-recovery.ts"; +import { ReconnectBanner } from "./reconnect-banner.tsx"; import { useVolumeReducer } from "./use-volume-reducer.tsx"; import { UserControls } from "./user-controls.tsx"; import { UserList } from "./user-list.tsx"; @@ -426,6 +428,21 @@ export const ProductionLine = ({ const { loading, connectionError } = useIsLoading({ connectionState }); + const handleRejoin = useCallback(() => { + dispatch({ + type: "UPDATE_CALL", + payload: { id, updates: { connectionState: null } }, + }); + }, [dispatch, id]); + + const { + phase: recoveryPhase, + attempt: connectionAttempt, + rejoin, + reportHeartbeat, + reportEstablishError, + } = useSessionRecovery({ connectionState, onRejoin: handleRejoin }); + useCheckBadLineData({ joinProductionOptions, paramLineId, @@ -504,8 +521,21 @@ export const ProductionLine = ({ /> ))} + {joinProductionOptions && !loading && ( + + )} {connectionActive && ( 0). On the very first + // attempt there is no established call to recover, so a failure keeps + // the original fatal ERROR path in useEstablishSession, which surfaces + // the "Connection failed" screen instead of silently retrying. + onEstablishError={ + connectionAttempt > 0 ? reportEstablishError : undefined + } joinProductionOptions={joinProductionOptions} audiooutput={audiooutput || undefined} inputAudioStream={inputAudioStream} diff --git a/src/components/production-line/reconnect-banner.test.tsx b/src/components/production-line/reconnect-banner.test.tsx new file mode 100644 index 00000000..f4a3474f --- /dev/null +++ b/src/components/production-line/reconnect-banner.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ReconnectBanner } from "./reconnect-banner.tsx"; + +describe("ReconnectBanner", () => { + it("renders nothing when healthy", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a reconnecting status without a rejoin button", () => { + render(); + expect(screen.getByRole("status").textContent).toMatch(/Reconnecting/); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("shows an error with a manual rejoin button when failed", () => { + const onRejoin = vi.fn(); + render(); + expect(screen.getByRole("alert")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Rejoin" })); + expect(onRejoin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/production-line/reconnect-banner.tsx b/src/components/production-line/reconnect-banner.tsx new file mode 100644 index 00000000..68c6bb0d --- /dev/null +++ b/src/components/production-line/reconnect-banner.tsx @@ -0,0 +1,54 @@ +import styled from "@emotion/styled"; +import { TRecoveryPhase } from "./use-session-recovery.ts"; + +const Banner = styled.div<{ failed: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + justify-content: center; + align-items: center; + gap: 1.5rem; + padding: 1rem 2rem; + font-size: 1.6rem; + color: #fff; + background: ${({ failed }) => (failed ? "#b3261e" : "#8a5a00")}; +`; + +const RejoinButton = styled.button` + font-size: 1.4rem; + padding: 0.4rem 1.2rem; + border-radius: 0.4rem; + border: 1px solid #fff; + background: transparent; + color: #fff; + cursor: pointer; +`; + +type TProps = { + phase: TRecoveryPhase; + onRejoin: () => void; +}; + +export const ReconnectBanner = ({ phase, onRejoin }: TProps) => { + if (phase === "ok") return null; + + if (phase === "reconnecting") { + return ( + + Connection to the server was lost. Reconnecting... + + ); + } + + return ( + + Could not reconnect to the line. + + Rejoin + + + ); +}; diff --git a/src/components/production-line/symphony-rtc-connection-component.tsx b/src/components/production-line/symphony-rtc-connection-component.tsx index f0d825a8..1ba80bfc 100644 --- a/src/components/production-line/symphony-rtc-connection-component.tsx +++ b/src/components/production-line/symphony-rtc-connection-component.tsx @@ -2,7 +2,7 @@ import { TJoinProductionOptions } from "./types"; import { TGlobalStateAction } from "../../global-state/global-state-actions"; import { useEstablishSession } from "./use-establish-session"; import { useRtcConnection } from "./use-rtc-connection"; -import { useHeartbeat } from "./use-heartbeat"; +import { THeartbeatStatus, useHeartbeat } from "./use-heartbeat"; import { TUseAudioInputValues } from "./use-audio-input"; import { useCallStateSync } from "./use-call-state-sync"; @@ -12,6 +12,8 @@ type SymphonyRtcConnectionComponentProps = { inputAudioStream: TUseAudioInputValues; callId: string; dispatch: React.Dispatch; + onHeartbeatStatus?: (status: THeartbeatStatus) => void; + onEstablishError?: (error: Error) => void; }; export const SymphonyRtcConnectionComponent = ({ @@ -20,11 +22,14 @@ export const SymphonyRtcConnectionComponent = ({ inputAudioStream, callId, dispatch, + onHeartbeatStatus, + onEstablishError, }: SymphonyRtcConnectionComponentProps) => { const { sessionId, sdpOffer } = useEstablishSession({ joinProductionOptions, callId, dispatch, + onError: onEstablishError, }); const { connectionState, audioElements } = useRtcConnection({ @@ -36,7 +41,7 @@ export const SymphonyRtcConnectionComponent = ({ callId, }); - useHeartbeat({ sessionId }); + useHeartbeat({ sessionId, onStatusChange: onHeartbeatStatus }); // Sync all state properties with the global state useCallStateSync({ diff --git a/src/components/production-line/use-establish-session.ts b/src/components/production-line/use-establish-session.ts index 5f837395..2c3a3cfd 100644 --- a/src/components/production-line/use-establish-session.ts +++ b/src/components/production-line/use-establish-session.ts @@ -9,6 +9,9 @@ type TUseGetRtcOfferOptions = { joinProductionOptions: TJoinProductionOptions | null; callId: string; dispatch: Dispatch; + // When provided, a failed session request is reported here instead of being + // dispatched as a call error (used while rejoining after a connection loss). + onError?: (error: Error) => void; }; // A hook for fetching the web rtc sdp offer from the backend @@ -16,7 +19,10 @@ export const useEstablishSession = ({ joinProductionOptions, callId, dispatch, + onError, }: TUseGetRtcOfferOptions) => { + const onErrorRef = useRef(onError); + onErrorRef.current = onError; const [sdpOffer, setSdpOffer] = useState(null); const [sessionId, setSessionId] = useState(null); const reqRef = useRef<{ @@ -63,6 +69,14 @@ export const useEstablishSession = ({ .catch((e) => { if (!state.cancelled) { reqRef.current = null; + if (onErrorRef.current) { + onErrorRef.current( + e instanceof Error + ? e + : new Error("Failed to establish audio session.") + ); + return; + } dispatch({ type: "ERROR", payload: { diff --git a/src/components/production-line/use-heartbeat.test.ts b/src/components/production-line/use-heartbeat.test.ts index b990bed5..8b9269a5 100644 --- a/src/components/production-line/use-heartbeat.test.ts +++ b/src/components/production-line/use-heartbeat.test.ts @@ -126,6 +126,91 @@ describe("useHeartbeat", () => { expect(mockHeartbeat).toHaveBeenCalledTimes(2); }); + it("reports unreachable on network errors and ok once the manager is back", async () => { + const onStatusChange = vi.fn(); + mockHeartbeat + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue("ok"); + renderHook(() => useHeartbeat({ sessionId: "s1", onStatusChange }), { + wrapper, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(onStatusChange).toHaveBeenLastCalledWith("unreachable"); + + await act(async () => { + await vi.runOnlyPendingTimersAsync(); + }); + expect(onStatusChange).toHaveBeenLastCalledWith("ok"); + }); + + it("reports unreachable on 5xx responses", async () => { + const onStatusChange = vi.fn(); + mockHeartbeat.mockRejectedValue(error500()); + renderHook(() => useHeartbeat({ sessionId: "s1", onStatusChange }), { + wrapper, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(onStatusChange).toHaveBeenCalledWith("unreachable"); + }); + + it("reports unreachable on 429 (rate limited) responses", async () => { + const onStatusChange = vi.fn(); + mockHeartbeat.mockRejectedValue( + Object.assign(new Error("too many requests"), { status: 429 }) + ); + renderHook(() => useHeartbeat({ sessionId: "s1", onStatusChange }), { + wrapper, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(onStatusChange).toHaveBeenCalledWith("unreachable"); + }); + + it("reports lost and stops when the session is gone (404)", async () => { + const onStatusChange = vi.fn(); + mockHeartbeat.mockRejectedValue( + Object.assign(new Error("not found"), { status: 404 }) + ); + renderHook(() => useHeartbeat({ sessionId: "s1", onStatusChange }), { + wrapper, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(onStatusChange).toHaveBeenCalledWith("lost"); + + // The loop stops once the session is gone — no further heartbeats. + await act(async () => { + await vi.advanceTimersByTimeAsync(120000); + }); + expect(mockHeartbeat).toHaveBeenCalledTimes(1); + }); + + it("reports lost and stops when the session is gone (410)", async () => { + const onStatusChange = vi.fn(); + mockHeartbeat.mockRejectedValue( + Object.assign(new Error("gone"), { status: 410 }) + ); + renderHook(() => useHeartbeat({ sessionId: "s1", onStatusChange }), { + wrapper, + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10000); + }); + expect(onStatusChange).toHaveBeenCalledWith("lost"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(120000); + }); + expect(mockHeartbeat).toHaveBeenCalledTimes(1); + }); + it("stops and dispatches HEARTBEAT_ERROR after 3 consecutive 401s", async () => { mockHeartbeat.mockRejectedValue(error401()); renderHook(() => useHeartbeat({ sessionId: "s1" }), { wrapper }); diff --git a/src/components/production-line/use-heartbeat.ts b/src/components/production-line/use-heartbeat.ts index 1eba7ace..a69d56ed 100644 --- a/src/components/production-line/use-heartbeat.ts +++ b/src/components/production-line/use-heartbeat.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { API } from "../../api/api.ts"; import { noop } from "../../helpers.ts"; import logger from "../../utils/logger.ts"; @@ -6,13 +6,32 @@ import { useGlobalState } from "../../global-state/context-provider.tsx"; import { backoffDelayMs } from "../../utils/backoff.ts"; import { authCircuitBreaker } from "../../api/auth-circuit-breaker.ts"; -type TProps = { sessionId: string | null }; +// ok - the manager acknowledged the heartbeat. +// unreachable - the manager could not be reached (network error / 5xx), e.g. +// while it restarts. The loop keeps retrying with backoff. +// lost - the manager is up but no longer knows the session (404/410). +// The loop stops; the session has to be re-established. +export type THeartbeatStatus = "ok" | "unreachable" | "lost"; + +type TProps = { + sessionId: string | null; + onStatusChange?: (status: THeartbeatStatus) => void; +}; + +const isSessionGone = (status: number | undefined) => + status === 404 || status === 410; + +const isManagerUnreachable = (status: number | undefined) => + status === undefined || status === 429 || status >= 500; // Normal cadence between heartbeats while the session is healthy. const HEARTBEAT_INTERVAL_MS = 10_000; -export const useHeartbeat = ({ sessionId }: TProps) => { +export const useHeartbeat = ({ sessionId, onStatusChange }: TProps) => { const [, dispatch] = useGlobalState(); + // Kept in a ref so a new callback identity never restarts the loop. + const onStatusChangeRef = useRef(onStatusChange); + onStatusChangeRef.current = onStatusChange; useEffect(() => { if (!sessionId) return noop; @@ -67,11 +86,22 @@ export const useHeartbeat = ({ sessionId }: TProps) => { if (cancelled) return; failure401Count = 0; consecutiveFailureCount = 0; + onStatusChangeRef.current?.("ok"); schedule(HEARTBEAT_INTERVAL_MS); }) .catch((err) => { if (cancelled) return; + if (isSessionGone(err.status)) { + logger.red( + `Session ${sessionId} is no longer known by the server.` + ); + onStatusChangeRef.current?.("lost"); + return; // stop the loop — the session must be re-established + } consecutiveFailureCount += 1; + if (isManagerUnreachable(err.status)) { + onStatusChangeRef.current?.("unreachable"); + } if (err.status === 401) { failure401Count += 1; } diff --git a/src/components/production-line/use-is-loading.test.ts b/src/components/production-line/use-is-loading.test.ts new file mode 100644 index 00000000..b0512902 --- /dev/null +++ b/src/components/production-line/use-is-loading.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useIsLoading } from "./use-is-loading.ts"; + +type TState = string | null; + +const setup = (initial: TState = null) => + renderHook( + ({ connectionState }: { connectionState: TState }) => + useIsLoading({ connectionState }), + { initialProps: { connectionState: initial } } + ); + +describe("useIsLoading", () => { + it("starts loading with no error", () => { + const { result } = setup(); + expect(result.current.loading).toBe(true); + expect(result.current.connectionError).toBe(false); + }); + + it("stops loading once connected", () => { + const { result, rerender } = setup(); + rerender({ connectionState: "connected" }); + expect(result.current.loading).toBe(false); + expect(result.current.connectionError).toBe(false); + }); + + it("flags a connection error when the peer connection fails", () => { + const { result, rerender } = setup(); + rerender({ connectionState: "failed" }); + expect(result.current.connectionError).toBe(true); + }); + + it("clears the error after a successful rejoin so the call UI returns", () => { + const { result, rerender } = setup(); + // Connect, then the peer connection fails. + rerender({ connectionState: "connected" }); + rerender({ connectionState: "failed" }); + expect(result.current.connectionError).toBe(true); + + // Auto-rejoin: the connection resets and then connects again. + rerender({ connectionState: null }); + expect(result.current.connectionError).toBe(false); + rerender({ connectionState: "connected" }); + expect(result.current.connectionError).toBe(false); + expect(result.current.loading).toBe(false); + }); + + it("clears the error when the connection resets to null for a rejoin", () => { + const { result, rerender } = setup(); + rerender({ connectionState: "failed" }); + expect(result.current.connectionError).toBe(true); + rerender({ connectionState: null }); + expect(result.current.connectionError).toBe(false); + }); +}); diff --git a/src/components/production-line/use-is-loading.ts b/src/components/production-line/use-is-loading.ts index dfd49bfa..876cf4ae 100644 --- a/src/components/production-line/use-is-loading.ts +++ b/src/components/production-line/use-is-loading.ts @@ -11,6 +11,14 @@ export const useIsLoading = ({ connectionState }: TProps) => { useEffect(() => { if (connectionState === "connected") { setLoading(false); + // A successful (re)connection clears any earlier failure so the call UI + // is shown again after an auto-rejoin instead of staying blank. + setConnectionError(false); + } else if (connectionState === null) { + // Null marks the start of a rejoin (the connection is being torn down and + // remounted); clear the stale error so the fresh attempt is not treated + // as permanently failed. + setConnectionError(false); } else if (connectionState === "failed") { setConnectionError(true); } diff --git a/src/components/production-line/use-session-recovery.test.ts b/src/components/production-line/use-session-recovery.test.ts new file mode 100644 index 00000000..c91d2175 --- /dev/null +++ b/src/components/production-line/use-session-recovery.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + RECOVERY_WINDOW_MS, + useSessionRecovery, +} from "./use-session-recovery.ts"; + +type TState = RTCPeerConnectionState | null; + +const setup = (initial: TState = "connected") => { + const onRejoin = vi.fn(); + const hook = renderHook( + ({ connectionState }: { connectionState: TState }) => + useSessionRecovery({ connectionState, onRejoin }), + { initialProps: { connectionState: initial } } + ); + return { ...hook, onRejoin }; +}; + +describe("useSessionRecovery", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("is ok while the call is healthy", () => { + const { result } = setup(); + expect(result.current.phase).toBe("ok"); + expect(result.current.attempt).toBe(0); + }); + + it("shows reconnecting while the manager is unreachable and recovers without rejoining", () => { + const { result, onRejoin } = setup(); + act(() => result.current.reportHeartbeat("unreachable")); + expect(result.current.phase).toBe("reconnecting"); + + act(() => result.current.reportHeartbeat("ok")); + expect(result.current.phase).toBe("ok"); + expect(result.current.attempt).toBe(0); + expect(onRejoin).not.toHaveBeenCalled(); + }); + + it("automatically rejoins when the session is lost", async () => { + const { result, rerender, onRejoin } = setup(); + act(() => result.current.reportHeartbeat("lost")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(onRejoin).toHaveBeenCalledTimes(1); + expect(result.current.attempt).toBe(1); + expect(result.current.phase).toBe("reconnecting"); + + // New connection state is reset, then connects. + rerender({ connectionState: null }); + rerender({ connectionState: "connected" }); + expect(result.current.phase).toBe("ok"); + }); + + it("automatically rejoins when a connected peer connection fails", async () => { + const { result, rerender, onRejoin } = setup(); + rerender({ connectionState: "failed" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + expect(result.current.attempt).toBe(1); + }); + + it("does not rejoin when the peer connection fails before ever connecting", async () => { + const { result, onRejoin } = setup("failed"); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).not.toHaveBeenCalled(); + expect(result.current.phase).toBe("ok"); + }); + + it("retries with backoff when re-establishing the session fails", async () => { + const { result, onRejoin } = setup(); + act(() => result.current.reportHeartbeat("lost")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + act(() => result.current.reportEstablishError()); + // Not retried immediately. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(onRejoin).toHaveBeenCalledTimes(2); + expect(result.current.attempt).toBe(2); + }); + + it("backs off on repeated session loss instead of rejoining at 0ms every cycle", async () => { + const { result, onRejoin } = setup(); + // First loss rejoins immediately. + act(() => result.current.reportHeartbeat("lost")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + // The rejoined session is lost again before it ever reconnects. + act(() => result.current.reportHeartbeat("lost")); + // This time a backoff applies, so there is no immediate rejoin. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + // The rejoin fires once the backoff window elapses. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(onRejoin).toHaveBeenCalledTimes(2); + }); + + it("backs off on repeated peer-connection failure after connecting", async () => { + const { rerender, onRejoin } = setup(); + // A connected peer connection fails → immediate rejoin. + rerender({ connectionState: "failed" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + // The fresh connection resets, then fails again before it connects. + rerender({ connectionState: null }); + rerender({ connectionState: "failed" }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(onRejoin).toHaveBeenCalledTimes(2); + }); + + it("resets the backoff after a confirmed reconnect", async () => { + const { result, rerender, onRejoin } = setup(); + act(() => result.current.reportHeartbeat("lost")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(1); + + // The rejoin succeeds: reset state, then connect. + rerender({ connectionState: null }); + rerender({ connectionState: "connected" }); + expect(result.current.phase).toBe("ok"); + + // A later loss rejoins immediately again because the count was reset. + act(() => result.current.reportHeartbeat("lost")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(onRejoin).toHaveBeenCalledTimes(2); + }); + + it("gives up after the recovery window and allows a manual rejoin", async () => { + const { result, onRejoin } = setup(); + act(() => result.current.reportHeartbeat("unreachable")); + await act(async () => { + await vi.advanceTimersByTimeAsync(RECOVERY_WINDOW_MS); + }); + expect(result.current.phase).toBe("failed"); + expect(onRejoin).not.toHaveBeenCalled(); + + act(() => result.current.rejoin()); + expect(onRejoin).toHaveBeenCalledTimes(1); + expect(result.current.attempt).toBe(1); + expect(result.current.phase).toBe("reconnecting"); + }); + + it("clears the failed state if the manager comes back on its own", async () => { + const { result } = setup(); + act(() => result.current.reportHeartbeat("unreachable")); + await act(async () => { + await vi.advanceTimersByTimeAsync(RECOVERY_WINDOW_MS); + }); + expect(result.current.phase).toBe("failed"); + + act(() => result.current.reportHeartbeat("ok")); + expect(result.current.phase).toBe("ok"); + }); +}); diff --git a/src/components/production-line/use-session-recovery.ts b/src/components/production-line/use-session-recovery.ts new file mode 100644 index 00000000..f8c1d60a --- /dev/null +++ b/src/components/production-line/use-session-recovery.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { THeartbeatStatus } from "./use-heartbeat.ts"; +import { backoffDelayMs } from "../../utils/backoff.ts"; + +// ok - the call is healthy. +// reconnecting - the manager is unreachable, or the session is being +// re-established; nothing for the user to do. +// failed - recovery did not succeed within RECOVERY_WINDOW_MS; the user +// can trigger a manual rejoin. +export type TRecoveryPhase = "ok" | "reconnecting" | "failed"; + +// How long we keep trying automatically before asking the user to rejoin. +export const RECOVERY_WINDOW_MS = 60_000; + +type TProps = { + connectionState: RTCPeerConnectionState | null; + // Called right before a new session is established so the caller can reset + // per-connection state. + onRejoin: () => void; +}; + +// Orchestrates recovery when the manager restarts or the session is lost: +// - heartbeats failing with network/5xx errors show a reconnecting state while +// the loop keeps retrying; +// - a 404/410 heartbeat (session unknown), or a peer connection that fails +// after having been connected, triggers an automatic rejoin by bumping +// `attempt`, which callers use as a React key to remount the connection; +// - failures to re-establish the session are retried with backoff; +// - after RECOVERY_WINDOW_MS without recovery the phase becomes "failed" and +// `rejoin()` offers a manual retry. +export const useSessionRecovery = ({ connectionState, onRejoin }: TProps) => { + const [attempt, setAttempt] = useState(0); + const [heartbeatStatus, setHeartbeatStatus] = + useState("ok"); + // Pending: a failed session request that has not been retried yet. + const [establishFailed, setEstablishFailed] = useState(false); + // Consecutive failures since the last successful rejoin; drives the backoff. + const failureCount = useRef(0); + const [rejoining, setRejoining] = useState(false); + const [gaveUp, setGaveUp] = useState(false); + const [deadlineEpoch, setDeadlineEpoch] = useState(0); + const everConnected = useRef(false); + // The new peer connection starts from a reset (non-connected) state; a stale + // "connected" from the previous connection must not complete the rejoin. + const sawResetSinceRejoin = useRef(false); + const onRejoinRef = useRef(onRejoin); + + // Writing refs during render is unsafe under concurrent/StrictMode renders, + // so the latest callback and the "ever connected" flag are updated in effects. + useEffect(() => { + onRejoinRef.current = onRejoin; + }, [onRejoin]); + + useEffect(() => { + if (connectionState === "connected") { + everConnected.current = true; + } + }, [connectionState]); + + const connectionLost = everConnected.current && connectionState === "failed"; + const needsRejoin = + heartbeatStatus === "lost" || connectionLost || establishFailed; + const degraded = + heartbeatStatus === "unreachable" || needsRejoin || rejoining; + + const reportHeartbeat = useCallback((status: THeartbeatStatus) => { + setHeartbeatStatus(status); + }, []); + + const reportEstablishError = useCallback(() => { + failureCount.current += 1; + setEstablishFailed(true); + }, []); + + const startRejoin = useCallback(() => { + onRejoinRef.current(); + setHeartbeatStatus("ok"); + sawResetSinceRejoin.current = false; + setRejoining(true); + setAttempt((a) => a + 1); + }, []); + + // Manual rejoin from the error state: starts a fresh recovery window. + const rejoin = useCallback(() => { + setGaveUp(false); + failureCount.current = 0; + setEstablishFailed(false); + setDeadlineEpoch((e) => e + 1); + startRejoin(); + }, [startRejoin]); + + // Automatic rejoin (with backoff after failed attempts). + useEffect(() => { + if (!needsRejoin || gaveUp) return undefined; + const timer = setTimeout( + () => { + // The failure that triggered this rejoin is being handled now. + setEstablishFailed(false); + // Count this rejoin so repeated session loss / peer failure (which do + // not go through reportEstablishError) back off instead of churning + // create+delete session/peer connections every cycle at 0ms. The count + // is reset only on a confirmed "connected" (or a manual rejoin). + failureCount.current += 1; + startRejoin(); + }, + failureCount.current > 0 + ? backoffDelayMs(failureCount.current, { baseMs: 2000, maxMs: 10_000 }) + : 0 + ); + return () => clearTimeout(timer); + }, [needsRejoin, gaveUp, startRejoin]); + + // A rejoin has completed once the new peer connection is up. + useEffect(() => { + if (!rejoining) return; + if (connectionState !== "connected") { + sawResetSinceRejoin.current = true; + } else if (sawResetSinceRejoin.current) { + setRejoining(false); + failureCount.current = 0; + } + }, [rejoining, connectionState]); + + // Give up automatic recovery after the window has elapsed. + useEffect(() => { + if (!degraded) { + setGaveUp(false); + return undefined; + } + const timer = setTimeout(() => setGaveUp(true), RECOVERY_WINDOW_MS); + return () => clearTimeout(timer); + }, [degraded, deadlineEpoch]); + + let phase: TRecoveryPhase = "ok"; + if (degraded) phase = gaveUp ? "failed" : "reconnecting"; + + return { + phase, + attempt, + rejoin, + reportHeartbeat, + reportEstablishError, + }; +};