Merge pull request #71104 from NousResearch/bb/desktop-boot-readiness

fix(desktop): boot readiness probes a lightweight /api/health
This commit is contained in:
brooklyn! 2026-07-24 20:02:17 -05:00 committed by GitHub
commit 476f009ff6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 270 additions and 33 deletions

View file

@ -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)
})

View file

@ -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<unknown>
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
export interface HermesReadyOptions {
fetchPublicJson: FetchPublicJson
fetchJson: FetchJson
token?: string | null
signal?: AbortSignal
timeoutMs?: number
pollMs?: number
healthProbeTimeoutMs?: number
sleep?: (ms: number) => Promise<void>
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<void> {
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<void>((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}`)
}

View file

@ -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() {

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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