mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
> 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>
149 lines
5.0 KiB
TypeScript
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();
|
|
};
|
|
}, []);
|
|
}
|