From 5ffbea81e995aaa3b6b6b5ba81d1f453a6b88ef8 Mon Sep 17 00:00:00 2001 From: kshitij Date: Mon, 3 Aug 2026 17:48:59 +0530 Subject: [PATCH] fix(desktop): escalate gateway reconnect on elapsed time, not attempt count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the full-jitter backoff (300ms base) six attempts can elapse in ~9s, so the old RECONNECT_ESCALATE_AFTER=6 attempt threshold raised the recoverable boot error during a brief post-boot blip — breaking the 'a remote that drops post-boot keeps looping with NO boot.error' contract. Escalate after RECONNECT_ESCALATE_AFTER_MS (45s, matching the old deterministic 1->15s ladder's calibration) elapsed since the first failed reconnect of the episode. Reset on clean open, manual/wake reconnect, and soft switch, preserving the reset-on-success path. --- .../src/app/gateway/hooks/use-gateway-boot.ts | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index b49d6456d81d7..99ccc0564cbdb 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -43,12 +43,16 @@ import type { RpcEvent } from '@/types/hermes' import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' -// After this many consecutive failed reconnects (≈45s with the 1→15s backoff) -// raise a recoverable boot error. Otherwise a dropped remote gateway loops the -// backoff forever behind the fullscreen CONNECTING overlay with no way to reach -// Settings / sign in / switch to local — the "lost connection breaks the app" -// dead end. The next successful reconnect clears it. -const RECONNECT_ESCALATE_AFTER = 6 +// After the reconnect loop has been failing for this long, raise a recoverable +// boot error. Otherwise a dropped remote gateway loops the backoff forever +// behind the fullscreen CONNECTING overlay with no way to reach Settings / +// sign in / switch to local — the "lost connection breaks the app" dead end. +// The next successful reconnect clears it. Time-based (not attempt-count) +// because the full-jitter backoff makes attempt counts a meaningless clock: +// six jittered attempts can elapse in ~9s, while the old deterministic +// 1→15s ladder took ~45s to reach six failures — this threshold keeps that +// original ~45s calibration. +const RECONNECT_ESCALATE_AFTER_MS = 45_000 interface GatewayBootOptions { beforeConnectionSwitch: () => void @@ -115,13 +119,18 @@ export function useGatewayBoot({ let reconnecting = false let reconnectTimer: ReturnType | null = null let reconnectAttempt = 0 + // Wall-clock start of the current disconnect episode (first failed + // reconnect attempt); null while healthy. Drives the time-based + // escalation below. Reset on a clean open or a manual/wake reconnect. + let reconnectFailingSince: number | null = null // Surface "sign in again" once per disconnect episode, not on every backoff // tick — a stale OAuth ticket fails every attempt and would otherwise stack // identical error toasts (and their haptics). Reset on the next clean open. let reauthNotified = false - // Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the - // recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean - // open or a manual/wake-driven reconnect. + // Raised once the reconnect loop has been failing for + // RECONNECT_ESCALATE_AFTER_MS so the recovery overlay replaces the + // dead-end CONNECTING screen. Reset on a clean open or a manual/ + // wake-driven reconnect. let escalated = false // Wrap the live getter in a call so TS control-flow analysis doesn't narrow @@ -174,6 +183,7 @@ export function useGatewayBoot({ } reconnectAttempt = 0 + reconnectFailingSince = null // A respawned backend re-mints (recycles) runtime ids, so any tile's // bound runtime id is now stale — drop them so each tile re-resumes. resetTileRuntimeBindings() @@ -193,7 +203,11 @@ export function useGatewayBoot({ reconnecting = false if (!cancelled && !gatewayOpen() && !$gatewaySwitching.get()) { - if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) { + if (reconnectFailingSince === null) { + reconnectFailingSince = Date.now() + } + + if (Date.now() - reconnectFailingSince >= RECONNECT_ESCALATE_AFTER_MS && !escalated) { escalated = true failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost')) } @@ -227,6 +241,7 @@ export function useGatewayBoot({ clearReconnectTimer() reconnectAttempt = 0 + reconnectFailingSince = null escalated = false reconnectSecondaryGateways() @@ -273,6 +288,7 @@ export function useGatewayBoot({ $gatewaySwitching.set(true) clearReconnectTimer() reconnectAttempt = 0 + reconnectFailingSince = null escalated = false reauthNotified = false callbacksRef.current.beforeConnectionSwitch() @@ -375,6 +391,7 @@ export function useGatewayBoot({ if (st === 'open') { reconnectAttempt = 0 + reconnectFailingSince = null reauthNotified = false escalated = false clearReconnectTimer()