fix(tui): tighten cold-start edge cases after review

Clean up the remaining review nits:

- let the deferred @hermes/ink import retry after a transient failure instead
  of memoizing a rejected promise forever
- keep memory-monitor in-flight state inside a finally so future exceptions
  cannot suppress that memory level indefinitely
- use read_raw_config for the TUI MCP cold-start probe instead of full
  load_config()
- keep input.detect_drop for explicit relative path prefixes (./ and ../)
  while preserving the no-RPC fast path for ordinary plain prompts

Tests:
- python -m py_compile tui_gateway/server.py tui_gateway/entry.py
- cd ui-tui && npm run type-check && npm run build
- scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py
- cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
This commit is contained in:
Brooklyn Nicholson 2026-04-29 00:08:34 -05:00
parent 72a3af63d4
commit 88a9efdb1a
3 changed files with 29 additions and 23 deletions

View file

@ -175,8 +175,8 @@ def main():
# loaded once by ``_config_mtime`` elsewhere) and only pay the import # loaded once by ``_config_mtime`` elsewhere) and only pay the import
# cost when there's actually MCP work to do. # cost when there's actually MCP work to do.
try: try:
from hermes_cli.config import load_config from hermes_cli.config import read_raw_config
_mcp_servers = (load_config() or {}).get("mcp_servers") _mcp_servers = (read_raw_config() or {}).get("mcp_servers")
_has_mcp_servers = isinstance(_mcp_servers, dict) and len(_mcp_servers) > 0 _has_mcp_servers = isinstance(_mcp_servers, dict) and len(_mcp_servers) > 0
except Exception: except Exception:
# Be conservative: if we can't decide, fall back to the old # Be conservative: if we can't decide, fall back to the old

View file

@ -127,9 +127,9 @@ export function useSubmission(opts: UseSubmissionOptions) {
} }
// Plain prompts are the common path and should not pay an extra RPC // Plain prompts are the common path and should not pay an extra RPC
// before prompt.submit. File-drop detection can still run for inputs // before prompt.submit. File-drop detection still runs for absolute,
// that contain an absolute/tilde path or file:// URI. // tilde, file://, and explicit relative paths.
if (!looksLikeSlashCommand(text) && !/(?:^|\s)(?:file:\/\/|~\/|\/)[^\s]+/.test(text)) { if (!looksLikeSlashCommand(text) && !/(?:^|\s)(?:file:\/\/|~\/|\.?\.\/|\/)[^\s]+/.test(text)) {
return startSubmit(text, expand(text), showUserMessage) return startSubmit(text, expand(text), showUserMessage)
} }

View file

@ -38,11 +38,16 @@ async function _ensureEvictInkCaches(): Promise<(level: 'all' | 'half') => unkno
return _evictInkCaches return _evictInkCaches
} }
_evictInkCachesPromise ??= import('@hermes/ink').then(mod => { _evictInkCachesPromise ??= import('@hermes/ink')
.then(mod => {
_evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown _evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown
return _evictInkCaches return _evictInkCaches
}) })
.catch(err => {
_evictInkCachesPromise = null
throw err
})
return _evictInkCachesPromise return _evictInkCachesPromise
} }
@ -79,6 +84,7 @@ export function startMemoryMonitor({
// Deferred import keeps `@hermes/ink` off the cold-start critical path; // Deferred import keeps `@hermes/ink` off the cold-start critical path;
// by the time a tick fires 10s after launch the app has already loaded // by the time a tick fires 10s after launch the app has already loaded
// the same module, so this resolves instantly from the ESM cache. // the same module, so this resolves instantly from the ESM cache.
try {
try { try {
const evictInkCaches = await _ensureEvictInkCaches() const evictInkCaches = await _ensureEvictInkCaches()
evictInkCaches(level === 'critical' ? 'all' : 'half') evictInkCaches(level === 'critical' ? 'all' : 'half')
@ -89,12 +95,12 @@ export function startMemoryMonitor({
dumped.add(level) dumped.add(level)
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null) const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
inFlight.delete(level)
const snap: MemorySnapshot = { heapUsed, level, rss } const snap: MemorySnapshot = { heapUsed, level, rss }
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump) ;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
} finally {
inFlight.delete(level)
}
} }
const handle = setInterval(() => void tick(), intervalMs) const handle = setInterval(() => void tick(), intervalMs)