diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts new file mode 100644 index 0000000000000..0888d65fbc41b --- /dev/null +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { shouldLatchBackendStartFailure } from './backend-start-failure' + +test('latches a LOCAL backend failure so the install-retry loop is broken', () => { + assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true) +}) + +test('never latches a REMOTE failure so recovery stays retryable without a restart', () => { + // A lapsed OAuth session / mint timeout / host briefly unreachable across a + // laptop sleep must not wedge the app: the next connect has to re-attempt and + // re-mint against the refreshed session. + assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: true }), false) +}) + +test('the two branches are mutually exclusive (a failure either latches or stays retryable)', () => { + for (const attemptedRemote of [true, false]) { + const latched = shouldLatchBackendStartFailure({ attemptedRemote }) + assert.equal(latched, !attemptedRemote) + } +}) diff --git a/apps/desktop/electron/backend-start-failure.ts b/apps/desktop/electron/backend-start-failure.ts new file mode 100644 index 0000000000000..4998b0164a705 --- /dev/null +++ b/apps/desktop/electron/backend-start-failure.ts @@ -0,0 +1,41 @@ +/** + * backend-start-failure.ts + * + * Decides whether a failed primary-backend boot should *latch* into + * `backendStartFailure`. A latched failure makes every subsequent + * startHermes() re-throw the cached error without re-attempting the connect — + * the right behavior for a LOCAL backend so the renderer's retry loop can't + * restart a broken install over and over. + * + * It is the WRONG behavior for a REMOTE backend. A remote connect can fail for + * transient reasons — a lapsed OAuth access-token cookie (the gateway rotates a + * fresh one from the live refresh-token cookie on the next request), a + * ws-ticket mint that timed out mid sleep/wake, or a host that was briefly + * unreachable across a laptop sleep. There is no child process whose 'exit' + * handler would clear the cache, so a latched remote failure sticks until the + * whole app is quit and relaunched: reconnect, "Sign out & sign in" (which only + * reloads the renderer), and the wake-recovery revalidate path all keep hitting + * the same stale error. Not latching lets the very next connect re-mint a + * ticket against the (now refreshed) session and self-heal. + * + * Extracted as a dependency-free pure predicate so the invariant is testable + * without booting Electron or reading main.ts source text. + */ + +export interface BackendStartFailureContext { + /** + * True when the boot that just failed was resolving/dialing a REMOTE (or + * cloud) primary backend rather than spawning a local child. + */ + attemptedRemote: boolean +} + +/** + * Whether a startHermes() failure should latch into `backendStartFailure`. + * Latch local failures (prevent install-restart loops); never latch remote + * failures (they are transient and must stay retryable so recovery paths work + * without an app restart). + */ +export function shouldLatchBackendStartFailure(context: BackendStartFailureContext): boolean { + return !context.attemptedRemote +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 29e38d6420f2c..1a9d28c5eef5f 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -34,6 +34,7 @@ import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' import { canImportHermesCli, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' +import { shouldLatchBackendStartFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' import { @@ -6171,6 +6172,16 @@ function globalRemoteActive() { return modeIsRemoteLike(readDesktopConnectionConfig().mode) } +// True when the PRIMARY profile's backend resolves to a remote/cloud host — +// i.e. resolveRemoteBackend(primaryProfileKey()) would return a descriptor +// rather than null. Mirrors that function's precedence (per-profile override → +// env → global) so a startHermes() failure can be classified as remote (never +// latch — transient, must stay retryable) vs local (latch to break install +// loops) BEFORE the throwing resolve/mint runs. +function primaryBackendIsRemote() { + return Boolean(profileHasRemoteOverride(primaryProfileKey())) || globalRemoteActive() +} + // GET a profile's resolved backend (remote pool or local primary), parsed JSON. async function fetchJsonForProfile(profile, path) { return requestJsonForProfile(profile, path, 'GET') @@ -6738,10 +6749,17 @@ async function startHermes() { const connectionAttempt = backendConnectionState.startAttempt() + // Classify this boot BEFORE the throwing resolve/mint runs: a remote failure + // must NOT latch (it's transient — see shouldLatchBackendStartFailure), while + // a local failure latches to break install-restart loops. + let attemptedRemote = primaryBackendIsRemote() + const connectionPromise = (async () => { await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) // Resolve for the desktop's primary profile so a per-profile remote // override on the active profile is honored (falls back to env / global). + // Re-read once resolved so the classification tracks the value actually used. + attemptedRemote = primaryBackendIsRemote() const remote = await resolveRemoteBackend(primaryProfileKey()) if (remote) { @@ -6947,7 +6965,14 @@ async function startHermes() { } const message = error instanceof Error ? error.message : String(error) - backendStartFailure = error instanceof Error ? error : new Error(message) + // Only latch LOCAL boot failures. A remote failure (lapsed session / mint + // timeout / host briefly unreachable across sleep) is transient and has no + // child 'exit' handler to clear the cache — latching it would wedge the app + // on "session expired" until a full restart, defeating reconnect, the + // "Sign out & sign in" reload, and the wake-recovery revalidate path. + if (shouldLatchBackendStartFailure({ attemptedRemote })) { + backendStartFailure = error instanceof Error ? error : new Error(message) + } updateBootProgress( { error: message,