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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user