From ccab46ca435a21c103fe5e7c1a8247f4bacaff54 Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Fri, 24 Jul 2026 19:43:44 -0500 Subject: [PATCH 1/2] fix(dashboard): add lightweight /api/health liveness endpoint /api/status is the only public liveness route, and its handler loads the gateway config, probes gateway health, and counts sessions before it can answer. That work is wrong for a readiness probe: a caller that only needs to know the process is up pays for a cold plugin import tree. Add /api/health, which returns process liveness, version, and the auth-gate shape and touches nothing else. --- hermes_cli/dashboard_auth/public_paths.py | 5 +++++ hermes_cli/web_server.py | 10 ++++++++++ .../hermes_cli/test_dashboard_auth_middleware.py | 1 + .../test_dashboard_auth_status_endpoint.py | 16 ++++++++++++++++ 4 files changed, 32 insertions(+) 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 From 23cb26c2e3528c1b7d522eeec1295e5921d0714b Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Fri, 24 Jul 2026 19:43:48 -0500 Subject: [PATCH 2/2] fix(desktop): probe /api/health for boot readiness, and survive a stalled loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop boot polls /api/status, so readiness waits on gateway config and a cold plugin import tree. On Windows that regularly outlives the probe and Desktop kills a backend that is already listening, respawns it, and re-pays the same import cost — the reported crash loop. Probe /api/health instead, falling back to /api/status only for the missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so an older remote backend still connects. Timeouts and server errors keep polling health rather than dropping to the heavyweight route. A cheap route is not enough on its own. Warming the gateway import holds the GIL, so the event loop can stall for tens of seconds and starve /api/health too. At the default 15s socket timeout only three attempts fit in the 45s budget; give each probe 5s so the loop keeps retrying across the stall. Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com> Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com> --- apps/desktop/electron/backend-health.test.ts | 136 +++++++++++++++++++ apps/desktop/electron/backend-health.ts | 95 +++++++++++++ apps/desktop/electron/main.ts | 40 +----- 3 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/electron/backend-health.test.ts create mode 100644 apps/desktop/electron/backend-health.ts 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() {