mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(desktop): probe /api/health for boot readiness, and survive a stalled loop
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>
This commit is contained in:
parent
ccab46ca43
commit
23cb26c2e3
3 changed files with 238 additions and 33 deletions
136
apps/desktop/electron/backend-health.test.ts
Normal file
136
apps/desktop/electron/backend-health.test.ts
Normal 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)
|
||||
})
|
||||
95
apps/desktop/electron/backend-health.ts
Normal file
95
apps/desktop/electron/backend-health.ts
Normal 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}`)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue