fix(desktop): escalate gateway reconnect on elapsed time, not attempt count

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.
This commit is contained in:
kshitij 2026-08-03 17:48:59 +05:30 committed by kshitij
parent ed66ff17d8
commit 5ffbea81e9
1 changed files with 27 additions and 10 deletions

View File

@ -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<typeof setTimeout> | 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()