Files
buzz/desktop/src/shared/api/relayReconnectPolicy.ts
T
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

83 lines
2.6 KiB
TypeScript

/**
* Pure helpers for the relay reconnect policy.
*
* Extracted from `RelayClient` so the decision rules — when to schedule a
* reconnect, when to refuse to connect — live in one legible place that
* unit tests can reach without booting the WS layer.
*
* The rules:
*
* - **Terminal sessions never reconnect.** When the relay has explicitly
* rejected us (today: kind:22242 AUTH OK=false) the session is dead
* until the user re-engages (community switch or explicit preconnect).
* This guards the reconnect-timer catch handler — and the retry wrappers
* in `publishEvent` / `sendRawWithReconnectRetry` — from racing the
* `disconnected` state back to `reconnecting`.
*
* - **No-op when a reconnect is already scheduled or in progress.**
* A pending timer or a live `wsId` means we have nothing to do.
*
* - **No reconnect needed when nothing wants the socket.** No live
* subscription, no `keepAliveRequested` from `preconnect()` → don't
* keep an idle socket.
*/
export type RelayReconnectInputs = {
terminal: boolean;
hasPendingReconnect: boolean;
hasLiveSocket: boolean;
keepAliveRequested: boolean;
hasLiveSubscriptions: boolean;
};
export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean {
if (inputs.terminal) return false;
if (inputs.hasPendingReconnect) return false;
if (inputs.hasLiveSocket) return false;
if (!inputs.keepAliveRequested && !inputs.hasLiveSubscriptions) return false;
return true;
}
export function shouldWaitForScheduledReconnect(inputs: {
hasPendingReconnect: boolean;
}): boolean {
return inputs.hasPendingReconnect;
}
/** Whether `ensureConnected()` should refuse with a terminal error. */
export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean {
return inputs.terminal;
}
export function isWebSocketClose(
message: unknown,
): message is { type: "Close"; data?: unknown } {
return (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Close"
);
}
export function isServiceRestartClose(message: unknown): boolean {
if (!isWebSocketClose(message)) return false;
if (!("data" in message)) return false;
const data = message.data;
return (
typeof data === "object" &&
data !== null &&
"code" in data &&
data.code === 1012
);
}
/** Whether a WS-layer message is the plugin's `Error` frame. */
export function isWebSocketError(message: unknown): boolean {
return (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
);
}