mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay-reconnect): dismiss reconnect card on auto-reconnect without user click
connected state is now authoritative for recovery UI — a stale query error no longer pins the reconnect card while the relay is healthy. - useSidebarRelayConnectionCard: gate hasActiveRelayUnreachableError on !isRelayConnectionConnected so connected state clears the card even when channelsQuery.error has not yet been cleared - ConnectionBanner: add state !== connected guard to hasCollapsedRelayError for the same reason - RelayAutoHealScheduler: expand constructor parameter properties to explicit field declarations so node --experimental-strip-types can load the class in tests; initialize lastHealAt to -Infinity so the first recovery is never rate-limited - Add useRelayAutoHeal.test.mjs covering all scheduler state transitions Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
f42a301af1
commit
04524e17ab
@@ -82,7 +82,7 @@ export function useSidebarRelayConnectionCard(
|
||||
const [isWindowVisible, setIsWindowVisible] =
|
||||
React.useState(isDocumentVisible);
|
||||
const hasActiveRelayUnreachableError =
|
||||
hasRelayUnreachableError && !hasSuccess;
|
||||
hasRelayUnreachableError && !hasSuccess && !isRelayConnectionConnected;
|
||||
const isRelayConnectionActuallyDegraded =
|
||||
hasActiveRelayUnreachableError || isRelayConnectionStateDegraded;
|
||||
const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests for RelayAutoHealScheduler.
|
||||
*
|
||||
* Tests the rate-limited, deferred-heal state machine without React or DOM.
|
||||
* Covers: first recovery fires immediately, rate-limit suppression schedules
|
||||
* deferred heal, second recovery within window supersedes the deferred, heal
|
||||
* fires after deferred window expires, dispose cancels pending heal.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { RelayAutoHealScheduler } from "./useRelayAutoHeal.ts";
|
||||
|
||||
function makeScheduler({ minIntervalMs = 100, now: initialNow = 0 } = {}) {
|
||||
const heals = [];
|
||||
const timers = [];
|
||||
let timerSeq = 0;
|
||||
let nowMs = initialNow;
|
||||
|
||||
const sched = new RelayAutoHealScheduler(
|
||||
() => heals.push(nowMs),
|
||||
minIntervalMs,
|
||||
mock.fn((fn, ms) => {
|
||||
const id = ++timerSeq;
|
||||
timers.push({ id, fn, ms });
|
||||
return id;
|
||||
}),
|
||||
mock.fn((id) => {
|
||||
const idx = timers.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) timers.splice(idx, 1);
|
||||
}),
|
||||
() => nowMs,
|
||||
);
|
||||
|
||||
return {
|
||||
sched,
|
||||
heals,
|
||||
timers,
|
||||
setNow: (ms) => {
|
||||
nowMs = ms;
|
||||
},
|
||||
fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── First recovery — fires immediately ───────────────────────────────────────
|
||||
|
||||
test("first recovery fires onHeal immediately", () => {
|
||||
const { sched, heals } = makeScheduler();
|
||||
|
||||
sched.onTransition("reconnecting", "connected");
|
||||
|
||||
assert.equal(heals.length, 1, "onHeal fired once");
|
||||
});
|
||||
|
||||
test("non-recovery transitions are ignored", () => {
|
||||
const { sched, heals, timers } = makeScheduler();
|
||||
|
||||
sched.onTransition("connected", "reconnecting");
|
||||
sched.onTransition("reconnecting", "stalled");
|
||||
sched.onTransition("connected", "connected");
|
||||
|
||||
assert.equal(heals.length, 0, "no heals for non-recovery transitions");
|
||||
assert.equal(timers.length, 0, "no timers scheduled");
|
||||
});
|
||||
|
||||
// ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("second recovery within window is rate-limited and schedules deferred heal", () => {
|
||||
const { sched, heals, timers, setNow } = makeScheduler({
|
||||
minIntervalMs: 100,
|
||||
});
|
||||
|
||||
setNow(0);
|
||||
sched.onTransition("reconnecting", "connected"); // fires immediately
|
||||
assert.equal(heals.length, 1);
|
||||
|
||||
// Second recovery 50ms later — within the 100ms window.
|
||||
setNow(50);
|
||||
sched.onTransition("stalled", "connected");
|
||||
|
||||
assert.equal(heals.length, 1, "onHeal not called again immediately");
|
||||
assert.equal(timers.length, 1, "deferred heal timer scheduled");
|
||||
assert.equal(
|
||||
timers[0].ms,
|
||||
50,
|
||||
"deferred fires after remaining window (100-50=50ms)",
|
||||
);
|
||||
});
|
||||
|
||||
test("deferred heal fires onHeal when timer fires", () => {
|
||||
const { sched, heals, timers, setNow, fireTimer } = makeScheduler({
|
||||
minIntervalMs: 100,
|
||||
});
|
||||
|
||||
setNow(0);
|
||||
sched.onTransition("reconnecting", "connected");
|
||||
|
||||
setNow(50);
|
||||
sched.onTransition("stalled", "connected"); // rate-limited, timer id=1
|
||||
|
||||
setNow(100);
|
||||
fireTimer(timers[0].id);
|
||||
|
||||
assert.equal(heals.length, 2, "deferred onHeal fired after timer");
|
||||
});
|
||||
|
||||
test("second recovery supersedes deferred — old timer cancelled, new one scheduled", () => {
|
||||
const { sched, heals, timers, setNow, fireTimer } = makeScheduler({
|
||||
minIntervalMs: 100,
|
||||
});
|
||||
|
||||
setNow(0);
|
||||
sched.onTransition("reconnecting", "connected"); // immediate heal
|
||||
|
||||
setNow(40);
|
||||
sched.onTransition("stalled", "connected"); // rate-limited → deferred at remaining=60ms
|
||||
const firstTimerId = timers[0].id;
|
||||
assert.equal(timers.length, 1);
|
||||
|
||||
setNow(60);
|
||||
sched.onTransition("reconnecting", "connected"); // supersedes — cancel first, schedule new
|
||||
|
||||
assert.equal(
|
||||
timers.findIndex((t) => t.id === firstTimerId),
|
||||
-1,
|
||||
"first timer cancelled",
|
||||
);
|
||||
assert.equal(timers.length, 1, "new deferred timer scheduled");
|
||||
assert.equal(
|
||||
timers[0].ms,
|
||||
40,
|
||||
"new deferred fires after remaining window (100-60=40ms)",
|
||||
);
|
||||
|
||||
setNow(100);
|
||||
fireTimer(timers[0].id);
|
||||
|
||||
assert.equal(heals.length, 2, "exactly two heals total (initial + deferred)");
|
||||
});
|
||||
|
||||
// ── Dispose ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("dispose cancels pending deferred heal", () => {
|
||||
const { sched, heals, timers, setNow } = makeScheduler({
|
||||
minIntervalMs: 100,
|
||||
});
|
||||
|
||||
setNow(0);
|
||||
sched.onTransition("reconnecting", "connected");
|
||||
|
||||
setNow(50);
|
||||
sched.onTransition("stalled", "connected"); // deferred scheduled
|
||||
|
||||
sched.dispose();
|
||||
assert.equal(timers.length, 0, "timer cancelled on dispose");
|
||||
|
||||
// Firing the timer after dispose should be a no-op (timer is gone).
|
||||
// The heals array should still be 1 (only the initial immediate heal).
|
||||
assert.equal(heals.length, 1, "no additional heals after dispose");
|
||||
});
|
||||
|
||||
test("dispose with no pending timer is a no-op", () => {
|
||||
const { sched } = makeScheduler();
|
||||
assert.doesNotThrow(() => sched.dispose());
|
||||
});
|
||||
@@ -2,12 +2,83 @@ import * as React from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
|
||||
const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
|
||||
export const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Tracks degraded→connected transitions and fires `onHeal` at most once per
|
||||
* `minIntervalMs`. When a transition is suppressed by the rate limiter, a
|
||||
* deferred heal is scheduled for the remaining window so the *last* recovery
|
||||
* always wins — stale query errors do not persist after reconnect.
|
||||
*
|
||||
* Injectable deps make this testable without React or DOM.
|
||||
*/
|
||||
export class RelayAutoHealScheduler {
|
||||
private lastHealAt = -Infinity;
|
||||
private deferredId: number | null = null;
|
||||
private readonly onHeal: () => void;
|
||||
private readonly minIntervalMs: number;
|
||||
private readonly setTimeoutFn: (fn: () => void, ms: number) => number;
|
||||
private readonly clearTimeoutFn: (id: number) => void;
|
||||
private readonly nowFn: () => number;
|
||||
|
||||
constructor(
|
||||
onHeal: () => void,
|
||||
minIntervalMs: number,
|
||||
setTimeoutFn: (fn: () => void, ms: number) => number,
|
||||
clearTimeoutFn: (id: number) => void,
|
||||
nowFn: () => number = () => Date.now(),
|
||||
) {
|
||||
this.onHeal = onHeal;
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
this.setTimeoutFn = setTimeoutFn;
|
||||
this.clearTimeoutFn = clearTimeoutFn;
|
||||
this.nowFn = nowFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call on every connection-state change. Fires `onHeal` (immediately or
|
||||
* deferred) when the transition is degraded→connected.
|
||||
*/
|
||||
onTransition(prev: ConnectionState, next: ConnectionState): void {
|
||||
if (!(isRelayConnectionDegraded(prev) && next === "connected")) return;
|
||||
|
||||
// A new recovery supersedes any pending deferred heal.
|
||||
if (this.deferredId !== null) {
|
||||
this.clearTimeoutFn(this.deferredId);
|
||||
this.deferredId = null;
|
||||
}
|
||||
|
||||
const now = this.nowFn();
|
||||
const elapsed = now - this.lastHealAt;
|
||||
|
||||
if (elapsed < this.minIntervalMs) {
|
||||
// Rate-limited — schedule for when the window expires.
|
||||
const remaining = this.minIntervalMs - elapsed;
|
||||
this.deferredId = this.setTimeoutFn(() => {
|
||||
this.deferredId = null;
|
||||
this.lastHealAt = this.nowFn();
|
||||
this.onHeal();
|
||||
}, remaining);
|
||||
} else {
|
||||
this.lastHealAt = now;
|
||||
this.onHeal();
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel any pending deferred heal (call on unmount). */
|
||||
dispose(): void {
|
||||
if (this.deferredId !== null) {
|
||||
this.clearTimeoutFn(this.deferredId);
|
||||
this.deferredId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-heal: when the connection recovers from a degraded state, invalidate
|
||||
@@ -17,23 +88,35 @@ const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
|
||||
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
|
||||
* firing an unfiltered invalidation — ~20-40 requests across active queries
|
||||
* with retry:1 — every time the relay briefly recovers.
|
||||
*
|
||||
* When a recovery is suppressed by the rate limiter (an earlier flap consumed
|
||||
* the budget), a deferred heal is scheduled for the remaining window so the
|
||||
* *last* recovery always wins and stale query errors do not persist.
|
||||
*/
|
||||
export function useRelayAutoHeal(): void {
|
||||
const queryClient = useQueryClient();
|
||||
const connectionState = useRelayConnection();
|
||||
const prevConnectionStateRef = React.useRef(connectionState);
|
||||
const lastAutoHealAtRef = React.useRef(0);
|
||||
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
|
||||
|
||||
if (schedulerRef.current === null) {
|
||||
schedulerRef.current = new RelayAutoHealScheduler(
|
||||
() => void queryClient.invalidateQueries(),
|
||||
AUTO_HEAL_MIN_INTERVAL_MS,
|
||||
window.setTimeout.bind(window),
|
||||
window.clearTimeout.bind(window),
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
schedulerRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prev = prevConnectionStateRef.current;
|
||||
prevConnectionStateRef.current = connectionState;
|
||||
if (isRelayConnectionDegraded(prev) && connectionState === "connected") {
|
||||
const now = Date.now();
|
||||
if (now - lastAutoHealAtRef.current < AUTO_HEAL_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastAutoHealAtRef.current = now;
|
||||
void queryClient.invalidateQueries();
|
||||
}
|
||||
}, [connectionState, queryClient]);
|
||||
schedulerRef.current?.onTransition(prev, connectionState);
|
||||
}, [connectionState]);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const hasCollapsedRelayError =
|
||||
sidebarState === "collapsed" &&
|
||||
state !== "connected" &&
|
||||
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
|
||||
|
||||
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
|
||||
|
||||
Reference in New Issue
Block a user