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,
+ };
+};