Merge pull request #122136 from NousResearch/fix/desktop-post-update-backend-start

Desktop no longer fails to start after an update (dead backend record wedged boot)
This commit is contained in:
ethernet
2026-09-24 22:23:14 -04:00
committed by GitHub
3 changed files with 65 additions and 4 deletions

View File

@@ -116,6 +116,38 @@ test('probes health on a short timeout but leaves the legacy fallback its own',
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
})
function connectionRefused(port: number): Error {
return Object.assign(new Error(`connect ECONNREFUSED 127.0.0.1:${port}`), { code: 'ECONNREFUSED' })
}
async function probesUntilSettled(alreadyBound: boolean): Promise<number> {
let probes = 0
let currentTime = 0
await waitForHermesReady('http://127.0.0.1:2802', {
fetchPublicJson: async () => {
probes += 1
throw connectionRefused(2802)
},
fetchJson: async () => ({}),
sleep: async () => {},
now: () => (currentTime += 1),
timeoutMs: 1_000,
pollMs: 1,
alreadyBound
}).catch(() => undefined)
return probes
}
test('a refused port on a backend known to have bound fails at once; an unbound one keeps polling', async () => {
// A hard-killed backend leaves its ledger record and published token behind.
// Polling that dead port for the whole budget outlived the renderer's boot
// timeout and wedged every post-update launch.
assert.equal(await probesUntilSettled(true), 1)
assert.ok((await probesUntilSettled(false)) > 1)
})
test('aborts as superseded when the bootstrap signal fires', async () => {
const controller = new AbortController()
controller.abort()

View File

@@ -33,6 +33,19 @@ export interface HermesReadyOptions {
* two very different meanings of a 401 (see `waitForHermesReady`).
*/
probeIsCredentialed?: boolean
/**
* The caller has proof the backend already bound its socket: a spawn-ledger
* record is only written after bind, and an attached backend answered once.
* A refused connection then means the process is gone, not still starting,
* so fail at once instead of polling a dead port for the whole budget.
* Remote/SSH callers leave this off: a tunnel that is still coming up
* refuses legitimately.
*/
alreadyBound?: boolean
}
export function isConnectionRefusedError(error: unknown): boolean {
return (error as { code?: unknown } | null)?.code === 'ECONNREFUSED'
}
export const REMOTE_SESSION_EXPIRED_MESSAGE =
@@ -285,6 +298,10 @@ export async function waitForHermesReady(baseUrl: string, options: HermesReadyOp
throw makeReauthRequiredError(error instanceof Error ? error.message : String(error))
}
if (options.alreadyBound && isConnectionRefusedError(error)) {
throw new Error(`Hermes backend did not become ready: ${(error as Error).message}`)
}
// An explicitly missing route means the backend predates /api/health.
// So does a gate-shaped 401 on an ANONYMOUS probe: the dashboard auth
// gate runs ahead of the SPA catch-all, so a pre-/api/health backend

View File

@@ -6032,7 +6032,14 @@ async function waitForRemoteHermes(remote) {
}
}
async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) {
async function waitForHermes(
baseUrl: string,
token: string | null | undefined,
signal?: AbortSignal,
authMode?: string | null,
headers: Record<string, string> = {},
{ alreadyBound = false }: { alreadyBound?: boolean } = {}
): Promise<void> {
const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token)
return waitForHermesReady(baseUrl, {
@@ -6043,7 +6050,8 @@ async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) {
? (url, _token, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers))
: fetchJson,
probeHealth: (url, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers)),
probeIsCredentialed
probeIsCredentialed,
alreadyBound
})
}
@@ -12080,7 +12088,7 @@ function startAttachedBackendMonitor(attached: AttachedBackend) {
stopAttachedBackendMonitor()
attachedBackendMonitor = setInterval(() => {
void waitForHermes(attached.baseUrl, attached.token, undefined, 'token', {}).catch(() => {
void waitForHermes(attached.baseUrl, attached.token, undefined, 'token', {}, { alreadyBound: true }).catch(() => {
stopAttachedBackendMonitor()
rememberLog(`[attach] attached backend on ${attached.baseUrl} (pid ${attached.pid}) is gone; recovering`)
invalidatePrimaryConnection()
@@ -12142,7 +12150,11 @@ function hostBackendAttachDeps() {
}
),
resolveServedToken: (baseUrl: string) => resolveServedDashboardToken(baseUrl, ''),
waitForReady: (baseUrl: string, token: string) => waitForHermes(baseUrl, token, undefined, 'token', {})
// A ledger record is written only after its backend binds, so a refused
// port is a dead record (a hard-killed backend leaves both the record and
// its published token behind), not one still starting.
waitForReady: (baseUrl: string, token: string) =>
waitForHermes(baseUrl, token, undefined, 'token', {}, { alreadyBound: true })
}
}