fix(desktop): don't latch remote backend boot failures so remote gateway reconnect recovers (#65756)

This commit is contained in:
HexLab 2026-07-17 07:45:43 +07:00 committed by GitHub
parent dfb76d36d5
commit 3f199f5c51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 90 additions and 1 deletions

View File

@ -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)
}
})

View File

@ -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
}

View File

@ -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,