diff --git a/apps/desktop/electron/backend-health.test.ts b/apps/desktop/electron/backend-health.test.ts new file mode 100644 index 000000000000..f542f58c31d9 --- /dev/null +++ b/apps/desktop/electron/backend-health.test.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { DEFAULT_HEALTH_PROBE_TIMEOUT_MS, isMissingHealthEndpointError, waitForHermesReady } from './backend-health' + +test('uses lightweight /api/health for current backends', async () => { + const calls: string[][] = [] + + await waitForHermesReady('http://127.0.0.1:9000/', { + token: 'secret-token', + fetchPublicJson: async url => { + calls.push(['public', url]) + + return { ok: true } + }, + fetchJson: async url => { + calls.push(['token', url]) + throw new Error('status should not be called') + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']]) +}) + +test('falls back to /api/status only for old backends without /api/health', async () => { + const calls: string[][] = [] + + await waitForHermesReady('http://127.0.0.1:9000', { + token: 'secret-token', + fetchPublicJson: async url => { + calls.push(['public', url]) + + throw new Error('404: {"detail":"Not Found"}') + }, + fetchJson: async (url, token) => { + calls.push(['token', url, token ?? '']) + + return { version: 'old' } + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [ + ['public', 'http://127.0.0.1:9000/api/health'], + ['token', 'http://127.0.0.1:9000/api/status', 'secret-token'] + ]) +}) + +test('does not fall back to heavyweight /api/status for transient health failures', async () => { + const calls: string[][] = [] + let currentTime = 0 + + await assert.rejects( + waitForHermesReady('http://127.0.0.1:9000', { + fetchPublicJson: async url => { + calls.push(['public', url]) + throw new Error('Timed out connecting to Hermes backend after 15000ms') + }, + fetchJson: async url => { + calls.push(['token', url]) + }, + sleep: async () => {}, + now: () => { + currentTime += 20 + + return currentTime + }, + timeoutMs: 50, + pollMs: 1 + }), + /Timed out connecting/ + ) + + assert.ok(calls.length > 0) + assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health'))) +}) + +test('probes health on a short timeout but leaves the legacy fallback its own', async () => { + const timeouts: (number | undefined)[] = [] + + await waitForHermesReady('http://127.0.0.1:9000', { + fetchPublicJson: async (_url, options) => { + timeouts.push(options?.timeoutMs) + + throw new Error('404: {"detail":"Not Found"}') + }, + fetchJson: async (_url, _token, options) => { + timeouts.push(options?.timeoutMs) + + return { version: 'old' } + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined]) +}) + +test('aborts as superseded when the bootstrap signal fires', async () => { + const controller = new AbortController() + controller.abort() + + await assert.rejects( + waitForHermesReady('http://127.0.0.1:9000', { + signal: controller.signal, + fetchPublicJson: async () => { + throw new Error('should not probe after abort') + }, + fetchJson: async () => { + throw new Error('should not probe after abort') + }, + timeoutMs: 100, + pollMs: 1 + }), + (error: any) => error.kind === 'superseded' + ) +}) + +test('recognizes missing-route shapes only', () => { + assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true) + assert.equal( + isMissingHealthEndpointError( + new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.') + ), + true + ) + assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false) + assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false) +}) diff --git a/apps/desktop/electron/backend-health.ts b/apps/desktop/electron/backend-health.ts new file mode 100644 index 000000000000..623208545e20 --- /dev/null +++ b/apps/desktop/electron/backend-health.ts @@ -0,0 +1,95 @@ +export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000 +export const DEFAULT_BACKEND_READY_POLL_MS = 500 +// A cold backend can stall its event loop for tens of seconds while Windows +// scans and byte-compiles the gateway import tree. At the default 15s socket +// timeout only three probes fit in the budget; a short one keeps retrying +// across the stall. Health only — the legacy /api/status fallback is genuinely +// slow to answer and keeps the caller's default timeout. +export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000 + +type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise +type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise + +export interface HermesReadyOptions { + fetchPublicJson: FetchPublicJson + fetchJson: FetchJson + token?: string | null + signal?: AbortSignal + timeoutMs?: number + pollMs?: number + healthProbeTimeoutMs?: number + sleep?: (ms: number) => Promise + now?: () => number +} + +export function isMissingHealthEndpointError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + + return /^404:/.test(message) || message.includes('endpoint is likely missing') +} + +function supersededError() { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + + return error +} + +export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS + const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS + const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS + const now = options.now ?? Date.now + const signal = options.signal + + const sleep = + options.sleep ?? + (ms => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(supersededError()) + }, + { once: true } + ) + })) + + const base = baseUrl.replace(/\/+$/, '') + const deadline = now() + timeoutMs + let lastError: unknown = null + let useStatusFallback = false + + while (now() < deadline) { + if (signal?.aborted) { + throw supersededError() + } + + try { + if (useStatusFallback) { + await options.fetchJson(`${base}/api/status`, options.token) + } else { + await options.fetchPublicJson(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs }) + } + + return + } catch (error) { + lastError = error + + // Only an explicitly missing route means the backend predates + // /api/health; timeouts and server errors keep polling health. + if (!useStatusFallback && isMissingHealthEndpointError(error)) { + useStatusFallback = true + + continue + } + + await sleep(pollMs) + } + } + + const detail = lastError instanceof Error ? lastError.message : 'timeout' + throw new Error(`Hermes backend did not become ready: ${detail}`) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 98762242871d..89b350f1b4b9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -34,6 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { waitForHermesReady } from './backend-health' import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure } from './backend-start-failure' @@ -4815,39 +4816,12 @@ function closePreviewWatchers() { } async function waitForHermes(baseUrl, token, signal?) { - const deadline = Date.now() + 45_000 - let lastError = null - - while (Date.now() < deadline) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') - error.kind = 'superseded' - throw error - } - - try { - await fetchJson(`${baseUrl}/api/status`, token) - - return - } catch (error) { - lastError = error - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, 500) - signal?.addEventListener( - 'abort', - () => { - clearTimeout(timer) - const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') - aborted.kind = 'superseded' - reject(aborted) - }, - { once: true } - ) - }) - } - } - - throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`) + return waitForHermesReady(baseUrl, { + token, + signal, + fetchPublicJson, + fetchJson + }) } function getWindowButtonPosition() {