Files
buzz/desktop/src/shared/api/useRelayAutoHeal.ts
Wes e5efd04705 fix(desktop): close reconnect gaps that previously required CMD+R (#4737)
> Opened by Brain (agent) on behalf of @wesbillman.

## Problem

Users report the desktop app doesn't reliably reconnect and can wedge in
states where only CMD+R (or a full restart) restores connectivity
(thread `c2205e2b` in #desktop-reconnecting).

Pinky's empirical light-switch matrix (real `buzz-relay`, SIGTERM/1012 +
SIGKILL × 1s/45s/3min, at `f18a9cb10`) passed 4/4 — the backoff state
machine recovers cleanly from ordinary relay loss. That isolates the
user-stuck states to four special cases a reload resets but the auto
flow never did.

## Fixes

| Gap | Change |
|---|---|
| **G1** — recovery rode solely on the backoff timer (max 30s),
throttled by WKWebView in occluded/background windows; nothing fired on
network return or wake | New `useRelayResumeTriggers`: `online`, window
focus, and visibility→visible call `preconnect()` when the session is
`reconnecting`/`stalled`, rate-limited to one attempt per 5s
(`relayResumeTriggerPolicy.ts`). Deliberately inert for the terminal
`disconnected` state. |
| **G2** — any AUTH `OK false` latched the session terminal forever,
though the relay also rejects for transient causes (duplicate-AUTH
"already authenticated" race, ±60s clock skew, fail-closed allowlist DB
errors) | New `AuthOkTracker` (`relayAuthPolicy.ts`): "already
authenticated" resolves as success; transient rejections retry with
normal backoff; latch only on `restricted:` or after 3 consecutive
rejections. |
| **G3** — an `auth-required:` CLOSED (REQ racing AUTH after reconnect)
permanently deleted the live subscription with no UI signal — frozen
channel while state reads "connected" | Reclassified `auth-required:` as
retryable in `relayClosedPolicy.ts`. Genuinely terminal classes
(`restricted:`, `invalid:`, …) still delete. Can't loop: a truly
unauthenticated session latches terminal at the connection level. |
| **G4** — `useRelayAutoHeal` observed the 2s-debounced connection hook,
so sub-2s flaps never triggered the heal even though `resetConnection`
had already rejected every in-flight query | Auto-heal now observes the
raw connection-state emitter. The existing 15s heal rate-limit still
guards against flap storms. |

Each fix is a colocated pure-policy module + unit tests, matching the
existing `relayReconnectPolicy`/`relayClosedPolicy` pattern.

## Validation

- Full desktop unit suite: **4151 pass, 0 fail** (at branch tip, `pnpm
-C desktop test`)
- `pnpm -C desktop typecheck` and `pnpm -C desktop check` clean
(file-size ratchet respected — `relayClientSession.ts` net −2 lines
despite the tracker wiring)
- Evidence trail: `RESEARCH/DESKTOP_RECONNECT_CMDR_GAP_AUDIT.md`
(audit), `RESEARCH/DESKTOP_RECONNECT_LIGHT_SWITCH_RESULTS.md` (Pinky's
matrix)

## Not covered / follow-ups

- Native macOS sleep-wake was not automated (would kill the harness
session); G1's focus trigger is the mechanism that covers wake in
practice, but a manual sleep-wake verification on a real build is
worthwhile.
- G3 terminal-CLOSED classes (`restricted:` etc.) still silently delete
subs with no UI signal — surfacing that is a separate UX decision.
- Stall-watchdog latency (60s idle + 10s check) left unchanged; G1
triggers largely mask it.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
Co-authored-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
Co-authored-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
2026-08-04 18:33:42 +00:00

149 lines
5.0 KiB
TypeScript

import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { relayClient } from "@/shared/api/relayClient";
import {
isRelayConnectionDegraded,
type ConnectionState,
} from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import {
isRateLimited,
waitForRateLimit,
} from "@/shared/api/relayRateLimitGate";
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
* relay-dependent queries so errored queries (e.g. messages, which don't poll)
* refetch automatically without requiring a manual reconnect action.
*
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
* firing a relay-wide invalidation 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 schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
if (schedulerRef.current === null) {
schedulerRef.current = new RelayAutoHealScheduler(
() => {
if (isRateLimited()) {
// Connection recovered but the relay is still under back-pressure.
// Defer the invalidate until the rate-limit window clears so queries
// don't immediately refetch and generate another burst.
void waitForRateLimit().then(() => {
void queryClient.invalidateQueries({
predicate: isRelayDependentQuery,
});
});
} else {
void queryClient.invalidateQueries({
predicate: isRelayDependentQuery,
});
}
},
AUTO_HEAL_MIN_INTERVAL_MS,
window.setTimeout.bind(window),
window.clearTimeout.bind(window),
);
}
React.useEffect(() => {
// Observe the RAW connection-state emitter, not the 2s-debounced
// useRelayConnection() hook. A sub-2s flap never surfaces through the
// debounced hook (its degraded report is cancelled by the recovery), yet
// resetConnection() already rejected every in-flight query — the heal
// must still fire or errored panes persist until a manual reconnect.
let prev: ConnectionState | null = null;
const unsubscribe = relayClient.subscribeToConnectionState((next) => {
if (prev !== null) {
schedulerRef.current?.onTransition(prev, next);
}
prev = next;
});
return () => {
unsubscribe();
schedulerRef.current?.dispose();
};
}, []);
}