mirror of
https://github.com/block/buzz.git
synced 2026-11-15 13:51:12 +00:00
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:
@@ -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 48100–48103) 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 48100–48103) 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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user