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:
kshitijk4poor
2026-09-15 00:04:55 +05:30
committed by kshitij
parent 09cf145ec8
commit 844e5f2610
3 changed files with 36 additions and 4 deletions

View File

@@ -234,6 +234,7 @@ export {
DEFAULT_PROBE_TIMEOUT_MS,
execProbe,
hermesRuntimeImportProbe,
isTimeoutError,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,

View File

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

View File

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