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>
This commit is contained in:
Wes
2026-08-04 18:33:42 +00:00
committed by GitHub
co-authored by npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6
parent cb4a73e17d
commit e5efd04705
13 changed files with 680 additions and 69 deletions
@@ -2,6 +2,7 @@ import * as React from "react";
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
import { relayClient } from "@/shared/api/relayClient";
import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers";
type AppShellLifecycleEffectsOptions = {
desktopBadgeEnabled: boolean;
@@ -16,6 +17,10 @@ export function useAppShellLifecycleEffects({
unreadChannelIds,
unreadChannelNotificationCount,
}: AppShellLifecycleEffectsOptions) {
// Event-driven reconnect: network online / focus / visibility short-circuit
// the backoff timer when the relay session is degraded (CMD+R gap G1).
useRelayResumeTriggers();
// Prevent webview file:/// navigation on file drop outside the composer.
// Scoped to file drags only (text drag-and-drop into inputs still works).
// Composer's onDrop fires first (React synthetic before window bubble).
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AuthOkTracker,
MAX_CONSECUTIVE_AUTH_REJECTIONS,
} from "./relayAuthPolicy.ts";
test("success resolves authenticated and resets the streak", () => {
const tracker = new AuthOkTracker();
tracker.record(false, "auth-required: verification failed");
tracker.record(false, "auth-required: verification failed");
assert.equal(tracker.record(true, ""), "authenticated");
// Streak reset: the next rejection starts a fresh count.
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => {
const tracker = new AuthOkTracker();
assert.equal(
tracker.record(false, "auth-required: already authenticated"),
"authenticated",
);
});
test("restricted rejections latch terminal immediately", () => {
for (const message of [
"restricted: not a relay member",
"restricted: you are banned",
]) {
assert.equal(new AuthOkTracker().record(false, message), "terminal");
}
});
test("the relay's actual ban string latches terminal immediately", () => {
// Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the
// ban seam. A known-permanent ban must never enter the retry loop.
assert.equal(
new AuthOkTracker().record(
false,
"blocked: you are banned from this community",
),
"terminal",
);
});
test("verification failures retry with backoff (clock skew, DB fail-closed)", () => {
const tracker = new AuthOkTracker();
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("unknown rejection reasons retry rather than latch", () => {
assert.equal(new AuthOkTracker().record(false, ""), "retry");
assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry");
});
test("consecutive rejections latch terminal at the cap", () => {
const tracker = new AuthOkTracker();
let decision = "retry";
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
decision = tracker.record(false, "auth-required: verification failed");
}
assert.equal(decision, "terminal");
});
test("reset grants a fresh retry streak after explicit re-engagement", () => {
const tracker = new AuthOkTracker();
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) {
tracker.record(false, "auth-required: verification failed");
}
tracker.reset();
assert.equal(
tracker.record(false, "auth-required: verification failed"),
"retry",
);
});
test("already-authenticated wins even past the cap (session is usable)", () => {
const tracker = new AuthOkTracker();
for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) {
tracker.record(false, "auth-required: verification failed");
}
assert.equal(
tracker.record(false, "auth-required: already authenticated"),
"authenticated",
);
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit).
*
* Historically ANY auth `OK false` latched the session terminal — no
* reconnect until explicit user re-engagement. But the relay sends
* `OK false` for conditions that are transient from the client's side:
*
* - `auth-required: already authenticated` — a duplicate/late AUTH event on
* a connection that is in fact authenticated. The session is usable;
* treat it as authenticated.
* - `auth-required: verification failed` — covers ±60s clock-skew rejects
* and the relay's fail-closed allowlist DB lookup errors, both of which
* can clear on retry.
*
* Only `restricted:` and `blocked:` rejections (not a relay member / banned)
* are known permanent. Everything else retries with normal backoff, but
* latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive
* rejections so a genuinely broken identity (e.g. persistently wrong system
* clock) still surfaces the terminal error card instead of flapping forever.
*
* The rejection streak is preserved across environment-driven resume
* attempts (focus/online/visibility); only explicit user re-engagement —
* the reconnect card or a community switch — may reset it.
*/
export type AuthOkDecision = "authenticated" | "retry" | "terminal";
export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3;
/** Tracks consecutive AUTH rejections across reconnect attempts. */
export class AuthOkTracker {
private consecutiveRejections = 0;
/**
* Record an AUTH `OK` and decide the session's next move.
* A success — real or "already authenticated" — resets the streak.
*/
record(success: boolean, message: string): AuthOkDecision {
const normalized = message.trim().toLowerCase();
if (
success ||
normalized.startsWith("auth-required: already authenticated")
) {
this.consecutiveRejections = 0;
return "authenticated";
}
this.consecutiveRejections++;
if (
normalized.startsWith("restricted:") ||
normalized.startsWith("blocked:")
) {
return "terminal";
}
if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) {
return "terminal";
}
return "retry";
}
/** Called on explicit re-engagement (disconnect / manual preconnect). */
reset(): void {
this.consecutiveRejections = 0;
}
}
+52 -52
View File
@@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm
import {
isServiceRestartClose,
isWebSocketClose,
isWebSocketError,
shouldRefuseConnect,
shouldScheduleReconnect,
shouldWaitForScheduledReconnect,
@@ -65,6 +66,7 @@ import {
STALL_IDLE_TIMEOUT_MS,
} from "@/shared/api/relayClientTimings";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
export class RelayClient {
@@ -92,6 +94,7 @@ export class RelayClient {
private connectionGeneration = 0;
private stabilityTimer: number | null = null;
private visibleChannelId: string | null = null;
private authOkTracker = new AuthOkTracker();
private terminal = false;
@@ -128,6 +131,7 @@ export class RelayClient {
this.notifyReconnectListeners = false;
this.terminal = false;
this.visibleChannelId = null;
this.authOkTracker.reset();
this.connectionStateEmitter.set("idle");
if (this.wsId !== null) {
@@ -295,7 +299,7 @@ export class RelayClient {
parentEventId?: string | null,
rootEventId?: string | null,
) {
// Bail when disconnected not worth triggering a reconnect for ephemeral typing events.
// Disconnected: not worth triggering a reconnect for ephemeral typing.
if (this.wsId === null) {
return;
}
@@ -325,11 +329,11 @@ export class RelayClient {
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
// 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS'
// other consumers (unread tracking, cache merges) must never see
// summary overlays.
return this.subscribe(
{
// 39005 rides only this window-store subscription — not
// CHANNEL_EVENT_KINDS, whose other consumers (unread tracking,
// timeline-cache merges) must never see summary overlays.
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
"#h": [channelId],
limit: 1000,
@@ -340,10 +344,9 @@ export class RelayClient {
}
/**
* Subscribe to huddle lifecycle events (kinds 4810048103) for a channel.
* Used by HuddleIndicator to detect active huddles without being drowned
* out by regular channel messages in the generic subscription window.
* Includes both historical (last 10) and live events.
* Subscribe to huddle lifecycle events (kinds 4810048103) for a channel,
* so HuddleIndicator detects active huddles without being drowned out by
* regular channel messages. Includes the last 10 historical events.
*/
async subscribeToHuddleEvents(
channelId: string,
@@ -425,12 +428,26 @@ export class RelayClient {
}
async preconnect() {
// Explicit re-engagement. If the session went terminal (auth rejection)
// the caller is asking us to try again, so clear the latch. A manual
// reconnect also bypasses the current delay once; ordinary operations do
// not, so background traffic cannot continuously defeat backoff.
// Explicit re-engagement (reconnect card / community switch): clears the
// terminal latch and AUTH rejection streak, and bypasses backoff once.
this.terminal = false;
this.authOkTracker.reset();
this.keepAliveRequested = true;
await this.connectBypassingBackoff();
}
/**
* Environment-driven resume (online/focus/visibility): bypasses a pending
* backoff timer but preserves the terminal latch and AUTH rejection streak
* — only `preconnect()` clears those, so resume events during repeated
* AUTH rejection cannot defeat the consecutive-rejection cap.
*/
async resumeReconnect() {
if (this.terminal) return;
await this.connectBypassingBackoff();
}
private async connectBypassingBackoff() {
if (this.reconnectTimeout !== null) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
@@ -448,7 +465,6 @@ export class RelayClient {
subscribeToReconnects(listener: () => void) {
this.reconnectListeners.add(listener);
return () => {
this.reconnectListeners.delete(listener);
};
@@ -460,8 +476,8 @@ export class RelayClient {
}
/**
* Subscribe to connection-state transitions. The listener is invoked
* immediately with the current state so callers don't need a separate
* Subscribe to connection-state transitions. The listener fires
* immediately with the current state, so callers need no separate
* `getConnectionState()` call to seed their UI.
*/
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
@@ -470,11 +486,10 @@ export class RelayClient {
private async ensureConnected() {
if (shouldRefuseConnect({ terminal: this.terminal })) {
// Session is terminal (e.g. relay rejected auth). Refuse to connect
// until an explicit re-engagement (disconnect()/preconnect()) clears
// the flag. Without this, the reconnect timer's catch handler — and
// the retry wrappers in publishEvent / sendRawWithReconnectRetry —
// would race the terminal "disconnected" state back to "reconnecting".
// Terminal (e.g. relay rejected auth): refuse until disconnect() or
// preconnect() clears the latch, else the reconnect-timer catch and
// the publish/subscribe retry wrappers would race the terminal
// "disconnected" state back to "reconnecting".
throw new Error("Relay session is terminal; cannot reconnect.");
}
@@ -494,7 +509,7 @@ export class RelayClient {
// The reconnect coordinator owns outage pacing. Query, publish, and
// subscription callers must wait for its scheduled attempt instead of
// clearing the timer and creating an immediate reconnect storm.
return this.waitForScheduledReconnect();
return this.reconnectWaiters.wait();
}
const connectPromise = this.connect();
@@ -670,7 +685,6 @@ export class RelayClient {
error,
fallbackMessage,
);
try {
await this.ensureConnected();
await this.sendRaw(payload);
@@ -749,12 +763,7 @@ export class RelayClient {
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "Error"
) {
if (isWebSocketError(message)) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
@@ -819,8 +828,7 @@ export class RelayClient {
if (type === "NOTICE" && typeof rest[0] === "string") {
const notice: string = rest[0];
// Relay back-pressure signal — activate the gate so pending operations
// back off until the window expires.
// Relay back-pressure — arm the gate until the window expires.
if (notice.startsWith("rate-limited:")) {
activateRateLimit(parseRateLimitHint(notice));
}
@@ -892,12 +900,14 @@ export class RelayClient {
const authRequest = this.authRequest;
this.authRequest = null;
if (success) {
// Decision table lives in relayAuthPolicy.ts.
const decision = this.authOkTracker.record(success, message);
if (decision === "authenticated") {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error, { reconnect: false });
this.resetConnection(error, { reconnect: decision === "retry" });
}
return;
@@ -919,13 +929,7 @@ export class RelayClient {
}
private hasLiveSubscriptions() {
for (const subscription of this.subscriptions.values()) {
if (subscription.mode === "live") {
return true;
}
}
return false;
return [...this.subscriptions.values()].some((s) => s.mode === "live");
}
private async replayLiveSubscriptions() {
@@ -948,13 +952,6 @@ export class RelayClient {
}
}
private waitForScheduledReconnect(): Promise<void> {
if (this.reconnectTimeout === null) {
return this.ensureConnected();
}
return this.reconnectWaiters.wait();
}
private scheduleReconnect() {
if (
!shouldScheduleReconnect({
@@ -968,9 +965,8 @@ export class RelayClient {
return;
}
// Apply ±25% jitter so a fleet of clients reconnecting simultaneously
// spreads their AUTH storms across a 50% window instead of all hitting
// the relay at the same instant.
// ±25% jitter spreads a fleet's AUTH storms across a 50% window instead
// of hitting the relay at the same instant.
const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
this.reconnectDelayMs = Math.min(
@@ -1031,9 +1027,13 @@ export class RelayClient {
if (options?.reconnect === false) {
this.terminal = true;
this.connectionStateEmitter.set("disconnected");
} else if (this.connectionStateEmitter.get() !== "stalled") {
// Stall is a stronger signal than a generic drop; keep it until the
// reconnect timer transitions us back to "reconnecting" in connect().
} else if (
// A late retry failure racing a terminal latch must not paint
// "reconnecting" over the terminal "disconnected" state; stall is a
// stronger signal than a generic drop and is kept until reconnect.
!this.terminal &&
this.connectionStateEmitter.get() !== "stalled"
) {
this.connectionStateEmitter.set("reconnecting");
}
@@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => {
for (const message of [
"restricted: not a channel member",
"restricted: channel access revoked",
"auth-required: not authenticated",
"blocked: banned",
"invalid: malformed filter",
"pow: difficulty too low",
@@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => {
}
});
test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => {
// A REQ that lands before the AUTH handshake completes gets CLOSED with
// auth-required. Deleting the subscription would silently freeze the
// channel while the connection state still reads "connected" — the sub
// must survive and be retried after backoff.
assert.equal(
classifyRelayClosed("auth-required: not authenticated"),
"retryable",
);
assert.equal(
classifyRelayClosed("auth-required: authenticate before subscribing"),
"retryable",
);
});
// ── Subscription-survival semantics ──────────────────────────────────────────
// These replace the removed isRetryableRelayClosed wrapper tests.
// rate-limited must not delete the subscription; terminal must.
@@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de
test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => {
for (const message of [
"restricted: not a channel member",
"auth-required: not authenticated",
"blocked: banned",
"invalid: malformed filter",
"pow: difficulty too low",
+5 -1
View File
@@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass {
if (normalized.startsWith("rate-limited:")) {
return "rate-limited";
}
// `auth-required:` is deliberately retryable, NOT terminal: it occurs
// transiently when a REQ races the AUTH handshake after a reconnect. The
// backoff retry re-sends the REQ once the session is authenticated. A
// session that is genuinely unauthenticated latches `terminal` at the
// connection level (AUTH OK=false), so this cannot loop forever.
if (
normalized.startsWith("restricted:") ||
normalized.startsWith("auth-required:") ||
normalized.startsWith("blocked:") ||
normalized.startsWith("invalid:") ||
normalized.startsWith("pow:") ||
@@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean {
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"
);
}
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
RESUME_TRIGGER_MIN_INTERVAL_MS,
shouldTriggerResumeReconnect,
} from "./relayResumeTriggerPolicy.ts";
const base = Object.freeze({
connectionState: "reconnecting",
lastAttemptAt: 0,
now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1,
});
test("reconnecting + interval elapsed triggers", () => {
assert.equal(shouldTriggerResumeReconnect({ ...base }), true);
});
test("stalled + interval elapsed triggers", () => {
assert.equal(
shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }),
true,
);
});
test("healthy and terminal states never trigger", () => {
for (const connectionState of [
"idle",
"connecting",
"connected",
"disconnected",
]) {
assert.equal(
shouldTriggerResumeReconnect({ ...base, connectionState }),
false,
connectionState,
);
}
});
test("burst of triggers within the rate window fires once", () => {
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 1_000,
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1,
}),
false,
);
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 1_000,
now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS,
}),
true,
);
});
test("custom interval override is honoured", () => {
assert.equal(
shouldTriggerResumeReconnect({
...base,
lastAttemptAt: 0,
now: 10,
minIntervalMs: 5,
}),
true,
);
});
@@ -0,0 +1,39 @@
/**
* Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit).
*
* The exponential-backoff timer is the only thing driving recovery after an
* outage and WKWebView throttles JS timers in occluded/background windows,
* so at max backoff (30s) a scheduled attempt may not fire until the user
* focuses the window. These triggers short-circuit the wait the moment the
* environment signals recovery: network `online`, window focus, and
* visibility becoming visible. `preconnect()` already clears the pending
* backoff timer, so a trigger converts "wait up to 30s (or forever, if
* throttled)" into "reconnect now".
*/
import type { ConnectionState } from "@/shared/api/relayClientShared";
/** Min ms between trigger-driven preconnect attempts. */
export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000;
export function shouldTriggerResumeReconnect(inputs: {
connectionState: ConnectionState;
lastAttemptAt: number;
now: number;
minIntervalMs?: number;
}): boolean {
const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS;
// Only degraded-but-recoverable states. `disconnected` is the terminal
// latch — explicit user re-engagement owns that path, and `idle` /
// `connecting` / `connected` need no help.
if (
inputs.connectionState !== "reconnecting" &&
inputs.connectionState !== "stalled"
) {
return false;
}
// Rate-limit: focus/online events arrive in bursts (e.g. wake fires all
// three); one attempt per window is enough.
return inputs.now - inputs.lastAttemptAt >= minInterval;
}
+18 -12
View File
@@ -2,12 +2,12 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import { relayClient } from "@/shared/api/relayClient";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
type ConnectionState,
} from "@/shared/api/relayClientShared";
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
import {
isRateLimited,
waitForRateLimit,
@@ -100,8 +100,6 @@ export class RelayAutoHealScheduler {
*/
export function useRelayAutoHeal(): void {
const queryClient = useQueryClient();
const connectionState = useRelayConnection();
const prevConnectionStateRef = React.useRef(connectionState);
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
if (schedulerRef.current === null) {
@@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void {
}
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();
};
}, []);
React.useEffect(() => {
const prev = prevConnectionStateRef.current;
prevConnectionStateRef.current = connectionState;
schedulerRef.current?.onTransition(prev, connectionState);
}, [connectionState]);
}
@@ -0,0 +1,60 @@
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy";
/**
* Event-driven reconnect triggers: network `online`, window `focus`, and
* visibilityvisible each attempt an immediate `resumeReconnect()` when the
* relay session is degraded (reconnecting/stalled), rate-limited by
* `RESUME_TRIGGER_MIN_INTERVAL_MS`.
*
* Rationale (CMD+R gap audit G1): without these, recovery rides solely on
* the backoff timer, which WKWebView throttles while the window is occluded
* or the system was asleep. The moment a user focuses the window to hit
* CMD+R *is* a focus event this fires the reconnect first.
*
* Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the
* pending backoff timer but must preserve the terminal latch and the AUTH
* rejection streak. Otherwise a focus/online event arriving during repeated
* AUTH rejection would reset the consecutive-rejection cap and the session
* could retry indefinitely instead of surfacing `disconnected`. The
* terminal state stays user-owned via the reconnect card.
*/
export function useRelayResumeTriggers(): void {
React.useEffect(() => {
let lastAttemptAt = -Infinity;
const attempt = () => {
const now = Date.now();
if (
!shouldTriggerResumeReconnect({
connectionState: relayClient.getConnectionState(),
lastAttemptAt,
now,
})
) {
return;
}
lastAttemptAt = now;
// resumeReconnect() clears any pending backoff timer and connects now,
// preserving terminal/AUTH-streak state. Failures re-arm the normal
// backoff loop; nothing to handle here.
void relayClient.resumeReconnect().catch(() => {});
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") attempt();
};
window.addEventListener("online", attempt);
window.addEventListener("focus", attempt);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.removeEventListener("online", attempt);
window.removeEventListener("focus", attempt);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);
}
+32 -1
View File
@@ -1186,6 +1186,12 @@ declare global {
};
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: ConnectionState) => void;
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState;
/** Queue deterministic mock AUTH outcomes, consumed in order. */
__BUZZ_E2E_QUEUE_AUTH_RESPONSES__?: (
responses: Array<{ success: boolean; message: string }>,
) => void;
/** Inject CLOSED into every active mock live subscription. */
__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__?: (reason: string) => number;
__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number;
__BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number;
@@ -2942,6 +2948,7 @@ const mockReminderEvents: RelayEvent[] = [];
const mockPersonaEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
const mockAuthResponses: Array<{ success: boolean; message: string }> = [];
let mockWebsocketUnavailable = false;
const relayWebsocketConnectAttemptStarts: number[] = [];
let mockWebsocketSendMutexWedged = false;
@@ -9443,7 +9450,16 @@ function sendToMockSocket(args: {
if (type === "AUTH") {
const event = rest[0] as RelayEvent;
sendWsText(socket.handler, ["OK", event.id, true, ""]);
const response = mockAuthResponses.shift() ?? {
success: true,
message: "",
};
sendWsText(socket.handler, [
"OK",
event.id,
response.success,
response.message,
]);
return;
}
@@ -9806,6 +9822,7 @@ export function maybeInstallE2eTauriMocks() {
mockClosedChannelLiveSubscription = false;
mockWebsocketUnavailable = false;
mockAuthResponses.length = 0;
relayWebsocketConnectAttemptStarts.length = 0;
mockGlobalAgentConfig = config.mock?.globalAgentConfig
? { ...config.mock.globalAgentConfig }
@@ -10053,6 +10070,20 @@ export function maybeInstallE2eTauriMocks() {
};
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ = () =>
relayClient.getConnectionState();
window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__ = (responses) => {
mockAuthResponses.push(...responses);
};
window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__ = (reason) => {
let closed = 0;
for (const socket of mockSockets.values()) {
for (const subId of [...socket.subscriptions.keys()]) {
sendWsText(socket.handler, ["CLOSED", subId, reason]);
socket.subscriptions.delete(subId);
closed += 1;
}
}
return closed;
};
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => {
mockReminderEvents.length = 0;
+216 -1
View File
@@ -111,9 +111,32 @@ async function emitMockMessages(
}, messages);
}
async function queueAuthResponses(
page: import("@playwright/test").Page,
responses: Array<{ success: boolean; message: string }>,
) {
await page.evaluate((queued) => {
const queue = window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__;
if (!queue) throw new Error("E2E AUTH response seam is not installed.");
queue(queued);
}, responses);
}
async function closeLiveSubscriptions(
page: import("@playwright/test").Page,
reason: string,
) {
const closed = await page.evaluate((message) => {
const close = window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__;
if (!close) throw new Error("E2E live CLOSED seam is not installed.");
return close(message);
}, reason);
expect(closed).toBeGreaterThan(0);
}
async function driveConnectionDegraded(
page: import("@playwright/test").Page,
state: "reconnecting" | "stalled" | "disconnected",
state: "connected" | "reconnecting" | "stalled" | "disconnected",
) {
await page.evaluate((s) => {
const setter = (
@@ -322,6 +345,198 @@ test("profile popover does not show relay reconnect controls", async ({
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
});
test("resume event short-circuits accumulated reconnect backoff", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await setMockWebsocketUnavailable(page, true);
await disconnectMockWebsockets(page);
await expect
.poll(() => getMockWebsocketConnectAttempts(page), { timeout: 10_000 })
.toHaveLength(3);
await setMockWebsocketUnavailable(page, false);
const resumedAt = Date.now();
await page.evaluate(() => window.dispatchEvent(new Event("online")));
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{
timeout: 2_000,
},
)
.toBe("connected");
expect(Date.now() - resumedAt).toBeLessThan(2_000);
});
test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Three consecutive rejections must latch terminal even when resume
// events interleave with the handshakes: resume attempts bypass backoff
// but must preserve the AUTH rejection streak (review finding on
// PR #4737 — preconnect()'s streak reset previously made the cap
// unreachable under focus/online bursts).
//
// Queue MORE rejections than the cap: superseded connection attempts
// (resume racing the backoff timer) consume queue entries on handshakes
// whose frames the client drops as stale, exactly like a real relay that
// rejects every attempt. A queue of exactly 3 can be silently eaten and
// a default-success AUTH would then reset the streak.
await queueAuthResponses(
page,
Array.from({ length: 12 }, () => ({
success: false,
message: "auth-required: verification failed",
})),
);
await disconnectMockWebsockets(page);
// Fire resume events while the rejection sequence plays out. Repeated
// dispatch (post-rate-limit spacing is irrelevant here: each poll tick
// dispatches both events) guarantees at least one lands between
// handshakes.
await expect
.poll(
async () => {
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("focus"));
});
return page.evaluate(() =>
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
);
},
{ intervals: [500], timeout: 20_000 },
)
.toBe("disconnected");
// Terminal is user-owned: further resume events must not revive the
// session.
await page.evaluate(() => {
window.dispatchEvent(new Event("online"));
window.dispatchEvent(new Event("focus"));
});
await page.waitForTimeout(1_000);
expect(
await page.evaluate(() =>
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(),
),
).toBe("disconnected");
});
test("sub-2s degraded flap invalidates relay queries on recovery", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await page.evaluate(() => {
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as
| {
invalidateQueries: (...args: unknown[]) => unknown;
__rawHealInvalidations?: number;
}
| undefined;
if (!queryClient)
throw new Error("E2E query client seam is not installed.");
const original = queryClient.invalidateQueries.bind(queryClient);
queryClient.__rawHealInvalidations = 0;
queryClient.invalidateQueries = (...args: unknown[]) => {
queryClient.__rawHealInvalidations =
(queryClient.__rawHealInvalidations ?? 0) + 1;
return original(...args);
};
});
await driveConnectionDegraded(page, "reconnecting");
await page.waitForTimeout(100);
await driveConnectionDegraded(page, "connected");
await expect
.poll(() =>
page.evaluate(
() =>
(
window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
__rawHealInvalidations?: number;
}
)?.__rawHealInvalidations ?? 0,
),
)
.toBeGreaterThan(0);
});
test("transient AUTH rejection reconnects and restores live traffic", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await queueAuthResponses(page, [
{ success: false, message: "auth-required: verification failed" },
{ success: true, message: "" },
]);
await disconnectMockWebsockets(page);
await expect
.poll(
() =>
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
{
timeout: 10_000,
},
)
.toBe("connected");
const recovered = `live after transient AUTH rejection ${Date.now()}`;
await emitMockMessages(page, [
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
]);
await expect(page.getByTestId("message-timeline")).toContainText(recovered);
const sent = `send after transient AUTH rejection ${Date.now()}`;
await page.getByTestId("message-input").fill(sent);
await page.getByTestId("send-message").click();
await expect(page.getByTestId("message-timeline")).toContainText(sent);
});
test("auth-required CLOSED restores the active live subscription", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await closeLiveSubscriptions(page, "auth-required: not authenticated");
const recovered = `live after auth-required CLOSED ${Date.now()}`;
await expect
.poll(
async () => {
await emitMockMessages(page, [
{ content: recovered, createdAt: Math.floor(Date.now() / 1_000) },
]);
return page
.getByTestId("message-timeline")
.evaluate(
(element, content) => (element.textContent ?? "").includes(content),
recovered,
);
},
{ intervals: [1_100], timeout: 5_000 },
)
.toBe(true);
});
test("reconnect backfills more missed channel messages than the live subscription limit", async ({
page,
}) => {