fix(desktop): give the gateway reconnect loop an escape hatch

When a remote gateway dropped after a healthy boot (internet loss,
sleep/wake, VPS restart), use-gateway-boot retried with backoff forever
and never surfaced an error. The renderer sat behind the fullscreen
CONNECTING overlay with gatewayState non-open and boot.error null — no
way to reach Settings, sign in again, or switch to a local gateway. To
the user the app was simply broken on connection loss.

Raise a recoverable boot error once the reconnect loop crosses
RECONNECT_ESCALATE_AFTER (6 attempts, ≈45s), so the BootFailureOverlay
(Retry / Sign in / Use local gateway) replaces the dead-end CONNECTING
screen. The loop keeps retrying underneath; the next successful reconnect
(or a manual/wake-driven one) clears the error and dismisses the overlay.

This implements the contract already specified — but never wired up — in
use-gateway-boot.test.tsx (desktop vitest isn't in CI, so the failing
"FIX:" specs went unnoticed). All 4 hook tests + the 3 connecting-overlay
tests pass.
This commit is contained in:
Brooklyn Nicholson 2026-06-24 18:32:29 -05:00
parent 41b9b7e719
commit 2a75c4a8cb
6 changed files with 23 additions and 0 deletions

View file

@ -40,6 +40,13 @@ import {
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
// 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
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (
@ -105,6 +112,10 @@ export function useGatewayBoot({
// 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.
let escalated = false
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
// `connectionState` to a constant across the early-return guards (the state
@ -171,6 +182,11 @@ export function useGatewayBoot({
reconnecting = false
if (!cancelled && !gatewayOpen()) {
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
escalated = true
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
}
scheduleReconnect()
}
}
@ -197,6 +213,7 @@ export function useGatewayBoot({
clearReconnectTimer()
reconnectAttempt = 0
escalated = false
reconnectSecondaryGateways()
if (!gatewayOpen()) {
@ -230,6 +247,7 @@ export function useGatewayBoot({
if (st === 'open') {
reconnectAttempt = 0
reauthNotified = false
escalated = false
clearReconnectTimer()
// A revalidate-driven reconnect can rebuild the backend in place when the

View file

@ -57,6 +57,7 @@ export const en: Translations = {
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
backendStopped: 'Backend stopped',
desktopBootFailed: 'Desktop boot failed',
gatewayConnectionLost: 'Lost connection to the gateway',
gatewaySignInRequired: 'Gateway sign-in required',
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
},

View file

@ -57,6 +57,7 @@ export const ja = defineLocale({
backgroundExitedDuringStartup: '起動中に Hermes バックグラウンドプロセスが終了しました。',
backendStopped: 'バックエンドが停止しました',
desktopBootFailed: 'デスクトップの起動に失敗しました',
gatewayConnectionLost: 'ゲートウェイへの接続が切断されました',
gatewaySignInRequired: 'ゲートウェイへのサインインが必要です',
ipcBridgeUnavailable: 'デスクトップ IPC ブリッジが利用できません。'
},

View file

@ -72,6 +72,7 @@ export interface Translations {
backgroundExitedDuringStartup: string
backendStopped: string
desktopBootFailed: string
gatewayConnectionLost: string
gatewaySignInRequired: string
ipcBridgeUnavailable: string
}

View file

@ -57,6 +57,7 @@ export const zhHant = defineLocale({
backgroundExitedDuringStartup: 'Hermes 背景程序在啟動期間結束。',
backendStopped: '後端已停止',
desktopBootFailed: '桌面啟動失敗',
gatewayConnectionLost: '與閘道的連線已中斷',
gatewaySignInRequired: '需要閘道登入',
ipcBridgeUnavailable: '桌面 IPC 橋接器不可用。'
},

View file

@ -57,6 +57,7 @@ export const zh: Translations = {
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
backendStopped: '后端已停止',
desktopBootFailed: '桌面启动失败',
gatewayConnectionLost: '与网关的连接已断开',
gatewaySignInRequired: '需要登录网关',
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
},