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:
Oliver Kieselbach
2026-10-09 15:47:35 +02:00
parent 4868a4448d
commit d093be3e8d
3 changed files with 351 additions and 4 deletions
@@ -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" };
},
};
}