mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
SignalR: a tab hidden for four minutes parks its connection and reconnects the moment it is back (D-335)
Chrome and Edge run the timers of a tab hidden for five minutes once a minute; the client then misses
its keep-alive, the service closes the connection and the automatic reconnect restores it, a loop every
minute in every background tab. lib/signalrPark.ts, a pure state machine tested with an injected clock,
parks the shared connection after four minutes hidden and resumes it on the first visible signal. The
resume ends a gap, so every view re-reads through useSignalRResync. A failed resume retries with backoff
up to 30 s until the next park; no park starts during an accessRevoked restart. Telemetry:
signalr_parked { hiddenMs } and signalr_resumed { parkedMs, transport }; park and resume emit neither
signalr_disconnected nor signalr_connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<number | null>(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<number | null>(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<void> | 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);
|
||||
|
||||
@@ -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<number, { at: number; fn: () => 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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof setTimeout>),
|
||||
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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user