fix(desktop): do not cache a timed-out serve-support probe
The serve-support resolver caches the probe outcome per resolved runtime for the process lifetime. That is right for a genuine "unknown subcommand" exit, but a probe that died by timeout says nothing about the runtime — only that this machine was slow right then (cold AV scan on Windows, first Python import after boot). Caching that as `false` routed a modern runtime through the legacy `dashboard` form until the app was relaunched. Export isTimeoutError from backend-probes and evict the cache entry on a timeout so the next check re-probes; non-timeout failures stay cached. One vitest case covers timeout → re-probe; the existing case still pins non-timeout failure → cached. Also replace the last synchronous read on the discovery path (dashboard.py fast path) with fs.promises.readFile, matching the stack's goal of keeping runtime discovery off the main event loop.
This commit is contained in:
@@ -234,6 +234,7 @@ export {
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
execProbe,
|
||||
hermesRuntimeImportProbe,
|
||||
isTimeoutError,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
|
||||
@@ -29,3 +29,21 @@ test('concurrent serve checks share one pending probe and retain its negative re
|
||||
probe.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a probe that fails by timeout is not cached, so the next check re-probes', async () => {
|
||||
const probe = vi
|
||||
.spyOn(probes, 'execProbe')
|
||||
.mockRejectedValueOnce(Object.assign(new Error('timed out'), { killed: true }))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const supportsServe = createBackendServeSupportResolver('/unused', () => {})
|
||||
const backend = { command: '/unused/hermes', args: ['serve'] }
|
||||
|
||||
try {
|
||||
assert.equal(await supportsServe(backend), false)
|
||||
assert.equal(await supportsServe(backend), true)
|
||||
assert.equal(probe.mock.calls.length, 2)
|
||||
} finally {
|
||||
probe.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { sourceDeclaresServe } from './backend-command'
|
||||
import { execProbe, PROBE_TIMEOUT_MS } from './backend-probes'
|
||||
import { execProbe, isTimeoutError, PROBE_TIMEOUT_MS } from './backend-probes'
|
||||
|
||||
interface ServeCandidate {
|
||||
command?: string | null
|
||||
@@ -21,7 +21,9 @@ interface ServeCandidate {
|
||||
// Fast path: read the runtime's own dashboard.py (instant, covers managed
|
||||
// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once
|
||||
// (covers a bare `hermes` resolved from PATH with no known source root). Result
|
||||
// is cached per resolved runtime so we probe at most once per backend.
|
||||
// is cached per resolved runtime so we probe at most once per backend — except
|
||||
// a probe that failed by timeout, which is evicted so the next start re-probes
|
||||
// rather than pinning a cold-AV false negative for the process lifetime.
|
||||
//
|
||||
// One cache per desktop runtime context; source inspection precedes a CLI probe.
|
||||
export function createBackendServeSupportResolver(hermesHome: string, rememberLog: (message: string) => void) {
|
||||
@@ -43,7 +45,11 @@ export function createBackendServeSupportResolver(hermesHome: string, rememberLo
|
||||
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
const src = await fs.promises.readFile(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
@@ -72,7 +78,14 @@ export function createBackendServeSupportResolver(hermesHome: string, rememberLo
|
||||
windowsHide: true
|
||||
})
|
||||
supported = true
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// A timeout says nothing about the runtime, only about this machine
|
||||
// right now (cold AV scan, slow disk). Evict so the next call
|
||||
// re-probes; a genuine "unknown subcommand" exit stays cached.
|
||||
if (isTimeoutError(err) && cache.get(key) === pending) {
|
||||
cache.delete(key)
|
||||
}
|
||||
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user