diff --git a/apps/desktop/electron/backend-health.test.ts b/apps/desktop/electron/backend-health.test.ts index 9ebff185a1..52d37dd2ca 100644 --- a/apps/desktop/electron/backend-health.test.ts +++ b/apps/desktop/electron/backend-health.test.ts @@ -116,6 +116,38 @@ test('probes health on a short timeout but leaves the legacy fallback its own', assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined]) }) +function connectionRefused(port: number): Error { + return Object.assign(new Error(`connect ECONNREFUSED 127.0.0.1:${port}`), { code: 'ECONNREFUSED' }) +} + +async function probesUntilSettled(alreadyBound: boolean): Promise { + let probes = 0 + let currentTime = 0 + + await waitForHermesReady('http://127.0.0.1:2802', { + fetchPublicJson: async () => { + probes += 1 + throw connectionRefused(2802) + }, + fetchJson: async () => ({}), + sleep: async () => {}, + now: () => (currentTime += 1), + timeoutMs: 1_000, + pollMs: 1, + alreadyBound + }).catch(() => undefined) + + return probes +} + +test('a refused port on a backend known to have bound fails at once; an unbound one keeps polling', async () => { + // A hard-killed backend leaves its ledger record and published token behind. + // Polling that dead port for the whole budget outlived the renderer's boot + // timeout and wedged every post-update launch. + assert.equal(await probesUntilSettled(true), 1) + assert.ok((await probesUntilSettled(false)) > 1) +}) + test('aborts as superseded when the bootstrap signal fires', async () => { const controller = new AbortController() controller.abort() diff --git a/apps/desktop/electron/backend-health.ts b/apps/desktop/electron/backend-health.ts index 86c30543f3..6513b1e697 100644 --- a/apps/desktop/electron/backend-health.ts +++ b/apps/desktop/electron/backend-health.ts @@ -33,6 +33,19 @@ export interface HermesReadyOptions { * two very different meanings of a 401 (see `waitForHermesReady`). */ probeIsCredentialed?: boolean + /** + * The caller has proof the backend already bound its socket: a spawn-ledger + * record is only written after bind, and an attached backend answered once. + * A refused connection then means the process is gone, not still starting, + * so fail at once instead of polling a dead port for the whole budget. + * Remote/SSH callers leave this off: a tunnel that is still coming up + * refuses legitimately. + */ + alreadyBound?: boolean +} + +export function isConnectionRefusedError(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === 'ECONNREFUSED' } export const REMOTE_SESSION_EXPIRED_MESSAGE = @@ -285,6 +298,10 @@ export async function waitForHermesReady(baseUrl: string, options: HermesReadyOp throw makeReauthRequiredError(error instanceof Error ? error.message : String(error)) } + if (options.alreadyBound && isConnectionRefusedError(error)) { + throw new Error(`Hermes backend did not become ready: ${(error as Error).message}`) + } + // An explicitly missing route means the backend predates /api/health. // So does a gate-shaped 401 on an ANONYMOUS probe: the dashboard auth // gate runs ahead of the SPA catch-all, so a pre-/api/health backend diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 070ecb4737..0b79236420 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -6032,7 +6032,14 @@ async function waitForRemoteHermes(remote) { } } -async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) { +async function waitForHermes( + baseUrl: string, + token: string | null | undefined, + signal?: AbortSignal, + authMode?: string | null, + headers: Record = {}, + { alreadyBound = false }: { alreadyBound?: boolean } = {} +): Promise { const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token) return waitForHermesReady(baseUrl, { @@ -6043,7 +6050,8 @@ async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) { ? (url, _token, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers)) : fetchJson, probeHealth: (url, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers)), - probeIsCredentialed + probeIsCredentialed, + alreadyBound }) } @@ -12080,7 +12088,7 @@ function startAttachedBackendMonitor(attached: AttachedBackend) { stopAttachedBackendMonitor() attachedBackendMonitor = setInterval(() => { - void waitForHermes(attached.baseUrl, attached.token, undefined, 'token', {}).catch(() => { + void waitForHermes(attached.baseUrl, attached.token, undefined, 'token', {}, { alreadyBound: true }).catch(() => { stopAttachedBackendMonitor() rememberLog(`[attach] attached backend on ${attached.baseUrl} (pid ${attached.pid}) is gone; recovering`) invalidatePrimaryConnection() @@ -12142,7 +12150,11 @@ function hostBackendAttachDeps() { } ), resolveServedToken: (baseUrl: string) => resolveServedDashboardToken(baseUrl, ''), - waitForReady: (baseUrl: string, token: string) => waitForHermes(baseUrl, token, undefined, 'token', {}) + // A ledger record is written only after its backend binds, so a refused + // port is a dead record (a hard-killed backend leaves both the record and + // its published token behind), not one still starting. + waitForReady: (baseUrl: string, token: string) => + waitForHermes(baseUrl, token, undefined, 'token', {}, { alreadyBound: true }) } }