From 2a75c4a8cb4aaa1f08c0a4fda9a192e52e89cec2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 18:32:29 -0500 Subject: [PATCH] fix(desktop): give the gateway reconnect loop an escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/gateway/hooks/use-gateway-boot.ts | 18 ++++++++++++++++++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 6 files changed, 23 insertions(+) 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 593e7a36f74..a4d134f3836 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8a1a295ce92..a276f7918ee 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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.' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ad1bf090657..26514ca2b88 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -57,6 +57,7 @@ export const ja = defineLocale({ backgroundExitedDuringStartup: '起動中に Hermes バックグラウンドプロセスが終了しました。', backendStopped: 'バックエンドが停止しました', desktopBootFailed: 'デスクトップの起動に失敗しました', + gatewayConnectionLost: 'ゲートウェイへの接続が切断されました', gatewaySignInRequired: 'ゲートウェイへのサインインが必要です', ipcBridgeUnavailable: 'デスクトップ IPC ブリッジが利用できません。' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 411c7d5847f..ff4ff703e6c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -72,6 +72,7 @@ export interface Translations { backgroundExitedDuringStartup: string backendStopped: string desktopBootFailed: string + gatewayConnectionLost: string gatewaySignInRequired: string ipcBridgeUnavailable: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index d5500570906..76e2eade88f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -57,6 +57,7 @@ export const zhHant = defineLocale({ backgroundExitedDuringStartup: 'Hermes 背景程序在啟動期間結束。', backendStopped: '後端已停止', desktopBootFailed: '桌面啟動失敗', + gatewayConnectionLost: '與閘道的連線已中斷', gatewaySignInRequired: '需要閘道登入', ipcBridgeUnavailable: '桌面 IPC 橋接器不可用。' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6423e1749a9..4359eeccf57 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -57,6 +57,7 @@ export const zh: Translations = { backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。', backendStopped: '后端已停止', desktopBootFailed: '桌面启动失败', + gatewayConnectionLost: '与网关的连接已断开', gatewaySignInRequired: '需要登录网关', ipcBridgeUnavailable: '桌面 IPC 桥不可用。' },