diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index 96be14fc14..b26abc6bee 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -234,6 +234,7 @@ export { DEFAULT_PROBE_TIMEOUT_MS, execProbe, hermesRuntimeImportProbe, + isTimeoutError, PROBE_TIMEOUT_MS, resolveProbeTimeoutMs, shouldTrustHermesOverride, diff --git a/apps/desktop/electron/backend-serve-support.test.ts b/apps/desktop/electron/backend-serve-support.test.ts index b775f94453..73ae1eba5b 100644 --- a/apps/desktop/electron/backend-serve-support.test.ts +++ b/apps/desktop/electron/backend-serve-support.test.ts @@ -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() + } +}) diff --git a/apps/desktop/electron/backend-serve-support.ts b/apps/desktop/electron/backend-serve-support.ts index ca7567a7e0..773472969b 100644 --- a/apps/desktop/electron/backend-serve-support.ts +++ b/apps/desktop/electron/backend-serve-support.ts @@ -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 } }