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() { diff --git a/hermes_cli/dashboard_auth/public_paths.py b/hermes_cli/dashboard_auth/public_paths.py index 349937cffa06..befedb1070a2 100644 --- a/hermes_cli/dashboard_auth/public_paths.py +++ b/hermes_cli/dashboard_auth/public_paths.py @@ -31,6 +31,11 @@ the SPA should bootstrap it after login instead. from __future__ import annotations PUBLIC_API_PATHS: frozenset[str] = frozenset({ + # Minimal process liveness probe for desktop/backend boot handshakes. It + # intentionally avoids gateway config, platform discovery, MCP setup, and + # host-local detail so readiness checks cannot spend their budget inside + # cold plugin imports. + "/api/health", # Liveness probe target. Returns version, gateway state, active # session count, and the dashboard auth-gate shape. No bodies, no # session content, no secrets. Documented as the portal's wildcard diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1e5cf1655178..7b529fefef4b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3036,6 +3036,16 @@ async def get_ssh_ownership(request: Request): return {"ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1} +@app.get("/api/health") +async def get_health(): + """Lightweight process liveness for desktop/backend readiness probes.""" + return { + "ok": True, + "version": __version__, + "auth_required": bool(getattr(app.state, "auth_required", False)), + } + + @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None diff --git a/tests/hermes_cli/test_dashboard_auth_middleware.py b/tests/hermes_cli/test_dashboard_auth_middleware.py index 7c1d6a9c2b21..7e8cf95d9822 100644 --- a/tests/hermes_cli/test_dashboard_auth_middleware.py +++ b/tests/hermes_cli/test_dashboard_auth_middleware.py @@ -78,6 +78,7 @@ def test_gated_status_is_public(gated_app): @pytest.mark.parametrize("path", [ + "/api/health", "/api/config/defaults", "/api/config/schema", "/api/model/info", diff --git a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py index 4305a2e355f8..0d00b20ed23c 100644 --- a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py +++ b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py @@ -67,6 +67,22 @@ def test_status_reports_auth_required_in_gated_mode(gated_client): assert body["auth_providers"] == ["stub"] +def test_health_reports_liveness_without_loading_gateway_config(gated_client, monkeypatch): + def _boom(): + raise AssertionError("health must not load gateway config") + + monkeypatch.setattr("gateway.config.load_gateway_config", _boom) + + r = gated_client.get("/api/health") + assert r.status_code == 200 + body = r.json() + assert body == { + "ok": True, + "version": web_server.__version__, + "auth_required": True, + } + + def test_status_reports_auth_disabled_in_loopback_mode(loopback_client): r = loopback_client.get("/api/status") assert r.status_code == 200