diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts index 0888d65fbc41..36d352d66cc2 100644 --- a/apps/desktop/electron/backend-start-failure.test.ts +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { shouldLatchBackendStartFailure } from './backend-start-failure' +import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' test('latches a LOCAL backend failure so the install-retry loop is broken', () => { assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true) @@ -21,3 +21,32 @@ test('the two branches are mutually exclusive (a failure either latches or stays assert.equal(latched, !attemptedRemote) } }) + +test('latches a CONFIRMED remote reauth failure so the overlay stays clickable', () => { + // Without this the non-latching remote path re-runs startHermes on every + // getConnection/api call, re-emits running:true, and the overlay hides + // itself — the "Sign in" button flickers away before it can be clicked. + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: true }), true) +}) + +test('does not latch a transient remote failure as reauth', () => { + // A mint timeout or a host unreachable across sleep must still self-heal. + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: false }), false) +}) + +test('never latches a LOCAL failure as reauth (that is backendStartFailure job)', () => { + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: true }), false) + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: false }), false) +}) + +test('the two latches never fire for the same failure', () => { + // They are complementary, not overlapping: local failures latch via + // backendStartFailure, confirmed remote reauth latches via its own flag. + for (const attemptedRemote of [true, false]) { + for (const isReauth of [true, false]) { + const start = shouldLatchBackendStartFailure({ attemptedRemote }) + const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth }) + assert.ok(!(start && reauth), `both latched for remote=${attemptedRemote} reauth=${isReauth}`) + } + } +}) diff --git a/apps/desktop/electron/backend-start-failure.ts b/apps/desktop/electron/backend-start-failure.ts index 4998b0164a70..3c5ffdbcad91 100644 --- a/apps/desktop/electron/backend-start-failure.ts +++ b/apps/desktop/electron/backend-start-failure.ts @@ -39,3 +39,34 @@ export interface BackendStartFailureContext { export function shouldLatchBackendStartFailure(context: BackendStartFailureContext): boolean { return !context.attemptedRemote } + +export interface RemoteReauthFailureContext { + /** True when the boot that just failed was dialing a REMOTE (or cloud) backend. */ + attemptedRemote: boolean + /** + * True when the failure was a CONFIRMED auth rejection (a credentialed + * probe got 401/403), not a transient connectivity fault. + */ + isReauth: boolean +} + +/** + * Whether a failed remote boot should latch as a reauth failure. + * + * This is the deliberate counterpart to `shouldLatchBackendStartFailure`, + * which never latches a remote failure because remote faults are usually + * transient and must stay retryable. A *confirmed* reauth rejection is the + * exception: it cannot self-heal, because nothing will change until the user + * signs in again. + * + * Without a latch, the non-latching remote path actively prevents recovery. + * Every subsequent `getConnection`/`api` call re-runs `startHermes`, re-emits + * `running: true`, and the boot-failure overlay (`visible = Boolean(boot.error) + * && !boot.running`) hides itself — so the "Sign in" button flickers out from + * under the user before they can click it. Latching holds the overlay still + * and clickable. Cleared on every recovery path (reset, repair, apply-config, + * and a confirmed sign-in) so a fresh session boots normally. + */ +export function shouldLatchRemoteReauthFailure(context: RemoteReauthFailureContext): boolean { + return context.attemptedRemote && context.isReauth +}