diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index ada8fbb2..be4bc6df 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -6,6 +6,7 @@ import { api } from '@/lib/api'; import { trackEvent } from '@/lib/appInsights'; import type { SignalRMessageName } from '@/lib/signalrMessages'; import { createGroupRegistry } from '@/lib/signalrGroupRegistry'; +import { createParkController, PARK_AFTER_HIDDEN_MS } from '@/lib/signalrPark'; import { refusedJoinStatus } from '@/lib/signalrGroupAccess'; import { useLatest } from '@/hooks/useLatest'; import { useAuth } from './AuthContext'; @@ -95,6 +96,10 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // Set in onreconnecting, read in onreconnected so we can report downtime as a measurement. const disconnectStartedAtRef = useRef(null); const maxRetries = 3; + // True while the hidden-tab rule (lib/signalrPark) holds the connection stopped. + const parkedRef = useRef(false); + // parkedMs of the resume whose start is still pending; null when the next start is no resume. + const resumeParkedMsRef = useRef(null); // Pushes only announce that data changed; after a gap in the connection every consumer re-reads // (useSignalRResync). A gap ends with a reconnect, or with a start of a connection that was up // before. Each end bumps resyncRequest; the epoch follows once the group joins of that moment have @@ -212,7 +217,8 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // Setup connection state change handlers newConnection.onclose((error) => { setConnectionState(HubConnectionState.Disconnected); - trackEvent("signalr_disconnected", { hasError: !!error }); + // A park is reported as signalr_parked, so this event keeps meaning "the connection closed". + if (!parkedRef.current) trackEvent("signalr_disconnected", { hasError: !!error }); // Memberships and references die with the connection: every consumer hook releases on the // Disconnected transition (a no-op against the reset registry) and re-joins on the next // Connected transition. @@ -304,11 +310,29 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { hasConnectedBeforeRef.current = true; // Surfaces clients stuck on a fallback transport (e.g. corporate proxies blocking // WebSockets) — those sessions get slower live updates and produce long-poll 404 - // noise in dependency telemetry. - trackEvent("signalr_connected", { transport: getTransportName(newConnection) }); + // noise in dependency telemetry. A resume reports itself, so signalr_connected keeps + // meaning "connection built". + if (resumeParkedMsRef.current !== null) { + trackEvent("signalr_resumed", { parkedMs: resumeParkedMsRef.current, transport: getTransportName(newConnection) }); + resumeParkedMsRef.current = null; + } else { + trackEvent("signalr_connected", { transport: getTransportName(newConnection) }); + } } catch (error) { - console.error('[SignalR] Failed to start connection:', error); setConnectionState(HubConnectionState.Disconnected); + // A park stopped this start; the resume starts over. + if (parkedRef.current) return; + console.error('[SignalR] Failed to start connection:', error); + + // A resumed connection keeps retrying like the automatic reconnect of a standing one: a + // standing connection never gives up, and parking must not make that worse. Only another + // park (the tab hidden long again) ends the retries. + if (resumeParkedMsRef.current !== null) { + retryCountRef.current++; + const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000); + retryTimeoutRef.current = setTimeout(startConnection, delay); + return; + } // Limited retry with exponential backoff if (retryCountRef.current < maxRetries) { @@ -350,8 +374,45 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // updates ~2s into the dashboard load for no benefit. startConnection(); + // A tab hidden for PARK_AFTER_HIDDEN_MS has its connection stopped, and restarted the moment it + // is visible again (lib/signalrPark). The restart ends a gap, so every consumer re-reads. + let parkStop: Promise | null = null; + const parkController = createParkController({ + parkAfterHiddenMs: PARK_AFTER_HIDDEN_MS, + park: (hiddenMs) => { + // Never in the middle of the accessRevoked restart; the controller asks again later. + if (revokeRestartInFlightRef.current) return false; + parkedRef.current = true; + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + console.info(`[SignalR] Tab hidden for ${Math.round(hiddenMs / 1000)} s, parking the connection`); + trackEvent("signalr_parked", { hiddenMs: Math.round(hiddenMs) }); + parkStop = newConnection.stop().catch(() => { /* nothing left to wait for */ }); + return true; + }, + resume: (parkedMs) => { + parkedRef.current = false; + retryCountRef.current = 0; + resumeParkedMsRef.current = Math.round(parkedMs); + console.info(`[SignalR] Tab visible after ${Math.round(parkedMs / 1000)} s parked, resuming the connection`); + const stopping = parkStop; + parkStop = null; + void (async () => { + await stopping; + await startConnection(); + })(); + }, + }); + const onVisibilityChange = () => parkController.setVisible(document.visibilityState === "visible"); + document.addEventListener("visibilitychange", onVisibilityChange); + onVisibilityChange(); + // Cleanup only when provider unmounts (app closes) return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + parkController.dispose(); // Clear any pending retry timeout to prevent reconnection after unmount if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/signalrPark.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/signalrPark.test.ts new file mode 100644 index 00000000..ab76d2c8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/signalrPark.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { createParkController, PARK_AFTER_HIDDEN_MS, type ParkTimers } from "../signalrPark"; + +/** + * The park controller decides when a hidden tab's SignalR connection is stopped and when it comes + * back. Timers and the clock are driven by hand (the injectable seam), so every threshold is exact. + */ + +const AFTER = 240_000; + +function harness(options: { refuse?: number } = {}) { + let now = 0; + let nextId = 1; + const pending = new Map void }>(); + const timers: ParkTimers = { + setTimeout: (fn, ms) => { + const id = nextId++; + pending.set(id, { at: now + ms, fn }); + return id; + }, + clearTimeout: (handle) => { pending.delete(handle as number); }, + now: () => now, + }; + const advance = (ms: number) => { + const until = now + ms; + for (;;) { + const due = Array.from(pending.entries()) + .filter(([, t]) => t.at <= until) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + pending.delete(due[0]); + now = due[1].at; + due[1].fn(); + } + now = until; + }; + const parks: number[] = []; + const resumes: number[] = []; + let refusals = options.refuse ?? 0; + const controller = createParkController({ + parkAfterHiddenMs: AFTER, + park: (hiddenMs) => { + parks.push(hiddenMs); + if (refusals > 0) { + refusals--; + return false; + } + return true; + }, + resume: (parkedMs) => { resumes.push(parkedMs); }, + timers, + }); + return { controller, parks, resumes, advance, pendingCount: () => pending.size }; +} + +describe("createParkController", () => { + it("never parks a visible tab", () => { + const { controller, parks, advance, pendingCount } = harness(); + controller.setVisible(true); + advance(10 * AFTER); + expect(parks).toEqual([]); + expect(pendingCount()).toBe(0); + expect(controller.isParked()).toBe(false); + }); + + it("leaves a short tab switch alone", () => { + const { controller, parks, resumes, advance, pendingCount } = harness(); + controller.setVisible(false); + advance(AFTER - 1); + controller.setVisible(true); + advance(10 * AFTER); + expect(parks).toEqual([]); + expect(resumes).toEqual([]); + expect(pendingCount()).toBe(0); + }); + + it("parks once the tab was hidden for the whole period", () => { + const { controller, parks, advance } = harness(); + controller.setVisible(false); + advance(AFTER); + expect(parks).toEqual([AFTER]); + expect(controller.isParked()).toBe(true); + advance(10 * AFTER); + expect(parks).toHaveLength(1); + }); + + it("resumes on the first visible signal after a park and reports how long it was parked", () => { + const { controller, resumes, advance } = harness(); + controller.setVisible(false); + advance(AFTER); + advance(90_000); + controller.setVisible(true); + controller.setVisible(true); + expect(resumes).toEqual([90_000]); + expect(controller.isParked()).toBe(false); + }); + + it("parks again after the next full hidden period", () => { + const { controller, parks, resumes, advance } = harness(); + controller.setVisible(false); + advance(AFTER); + controller.setVisible(true); + controller.setVisible(false); + advance(AFTER - 1); + expect(parks).toHaveLength(1); + advance(1); + expect(parks).toEqual([AFTER, AFTER]); + expect(resumes).toEqual([0]); + }); + + it("does not restart the period on a repeated hidden signal", () => { + const { controller, parks, advance } = harness(); + controller.setVisible(false); + advance(100_000); + controller.setVisible(false); + advance(AFTER - 100_000); + expect(parks).toEqual([AFTER]); + }); + + it("reports the real hidden time when a throttled timer fires late", () => { + // In a hidden tab the timer callback can run late; the report measures from the hide. + const timer: { due?: () => void } = {}; + let now = 0; + const parks: number[] = []; + const controller = createParkController({ + parkAfterHiddenMs: AFTER, + park: (hiddenMs) => { parks.push(hiddenMs); return true; }, + resume: () => {}, + timers: { setTimeout: (fn) => { timer.due = fn; return 1; }, clearTimeout: () => {}, now: () => now }, + }); + controller.setVisible(false); + now = AFTER + 55_000; + timer.due?.(); + expect(parks).toEqual([AFTER + 55_000]); + expect(controller.isParked()).toBe(true); + }); + + it("tries again after another period when the park is refused, measuring from the hide", () => { + const { controller, parks, advance } = harness({ refuse: 1 }); + controller.setVisible(false); + advance(AFTER); + expect(controller.isParked()).toBe(false); + advance(AFTER); + expect(parks).toEqual([AFTER, 2 * AFTER]); + expect(controller.isParked()).toBe(true); + }); + + it("drops a refused park's retry when the tab comes back", () => { + const { controller, parks, resumes, advance, pendingCount } = harness({ refuse: 1 }); + controller.setVisible(false); + advance(AFTER); + controller.setVisible(true); + advance(10 * AFTER); + expect(parks).toHaveLength(1); + expect(resumes).toEqual([]); + expect(pendingCount()).toBe(0); + }); + + it("dispose cancels a pending park and never resumes", () => { + const { controller, parks, resumes, advance, pendingCount } = harness(); + controller.setVisible(false); + controller.dispose(); + advance(10 * AFTER); + expect(parks).toEqual([]); + expect(pendingCount()).toBe(0); + controller.setVisible(true); + expect(resumes).toEqual([]); + }); + + it("rejects a non-positive period", () => { + expect(() => createParkController({ parkAfterHiddenMs: 0, park: () => true, resume: () => {} })).toThrow(); + expect(() => createParkController({ parkAfterHiddenMs: Number.NaN, park: () => true, resume: () => {} })).toThrow(); + }); + + it("parks below the five-minute timer throttling and above Collect Logs' three-minute wait", () => { + expect(PARK_AFTER_HIDDEN_MS).toBeLessThan(5 * 60_000); + expect(PARK_AFTER_HIDDEN_MS).toBeGreaterThan(3 * 60_000); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/signalrPark.ts b/src/Web/autopilot-monitor-web/lib/signalrPark.ts new file mode 100644 index 00000000..1e9adc8a --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/signalrPark.ts @@ -0,0 +1,107 @@ +/** + * When to park the shared SignalR connection of a hidden tab, and when to bring it back. + * + * Chrome and Edge run the timers of a tab hidden for five minutes only once a minute. The SignalR + * client then misses its keep-alive, the service closes the connection and the automatic reconnect + * restores it — a reconnect loop every minute that nobody reads. Parking stops the connection + * before that point; the tab reconnects the moment it is visible again and every consumer re-reads + * (useSignalRResync), so a parked tab loses no data, only pushes nobody would have seen. + * + * Pure state machine: the owner feeds visibility changes and carries out park and resume. The timers + * and the clock are injectable, so it is tested without fake timers. + */ + +/** + * Below the five-minute timer throttling (the loop never starts), above Collect Logs' three-minute + * wait for its upload toast, and far above an ordinary tab switch. + */ +export const PARK_AFTER_HIDDEN_MS = 4 * 60_000; + +export interface ParkTimers { + setTimeout: (fn: () => void, ms: number) => unknown; + clearTimeout: (handle: unknown) => void; + now: () => number; +} + +export interface ParkControllerOptions { + /** Hidden this long without a break → park. */ + parkAfterHiddenMs: number; + /** + * Stops the connection. Returns false when it cannot be parked right now (the owner is restarting + * it); the controller tries again after another full period while the tab stays hidden. + */ + park: (hiddenMs: number) => boolean; + /** Reconnects a parked connection; called on the first visible signal after a park. */ + resume: (parkedMs: number) => void; + /** Defaults to the global timers and Date.now. */ + timers?: ParkTimers; +} + +export interface ParkController { + /** Feed every visibility change; the first call may already be "hidden" (a tab opened in the background). */ + setVisible: (visible: boolean) => void; + isParked: () => boolean; + /** Cancels a pending park. Never resumes: the owner tears the connection down itself. */ + dispose: () => void; +} + +type ParkState = + | { kind: "active" } + | { kind: "hidden"; since: number; timer: unknown } + | { kind: "parked"; since: number }; + +const globalTimers: ParkTimers = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now(), +}; + +export function createParkController(options: ParkControllerOptions): ParkController { + const { parkAfterHiddenMs, park, resume } = options; + const timers = options.timers ?? globalTimers; + if (!(parkAfterHiddenMs > 0)) { + throw new Error(`createParkController: need parkAfterHiddenMs > 0, got ${parkAfterHiddenMs}`); + } + + let state: ParkState = { kind: "active" }; + + // `since` is when the tab went hidden: a refused park keeps it, so the next report measures the + // whole hidden stretch. + const armPark = (since: number) => { + const timer = timers.setTimeout(onParkDue, parkAfterHiddenMs); + state = { kind: "hidden", since, timer }; + }; + + function onParkDue() { + if (state.kind !== "hidden") return; + const since = state.since; + const now = timers.now(); + if (park(now - since)) { + state = { kind: "parked", since: now }; + } else { + armPark(since); + } + } + + return { + setVisible(visible) { + if (!visible) { + if (state.kind === "active") armPark(timers.now()); + return; + } + if (state.kind === "hidden") { + timers.clearTimeout(state.timer); + state = { kind: "active" }; + } else if (state.kind === "parked") { + const parkedMs = timers.now() - state.since; + state = { kind: "active" }; + resume(parkedMs); + } + }, + isParked: () => state.kind === "parked", + dispose() { + if (state.kind === "hidden") timers.clearTimeout(state.timer); + state = { kind: "active" }; + }, + }; +}