Two targeted fixes on the critical path from `hermes --tui` launch to
`gateway.ready`:
1. **Defer `@hermes/ink` import in memoryMonitor.ts.** The static top-level
import dragged the full ~414KB Ink bundle (React + renderer + all
components/hooks) onto the critical path *before* `gw.start()` could
spawn the Python gateway — serialising ~155ms of Node work in front of
it on every launch. `evictInkCaches` only runs inside the 10-second
tick under heap pressure, so it moves to a lazy dynamic import. First
tick hits the ESM cache because the app entry has long since imported
`@hermes/ink`.
2. **Gate `tools.mcp_tool` import on config in tui_gateway/entry.py.**
Importing the module transitively pulls the MCP SDK + pydantic + httpx
+ jsonschema + starlette formparsers (~200ms). The overwhelming
majority of users have no `mcp_servers` configured, so this runs for
nothing. A cheap `load_config()` check (~25ms) skips the 200ms import
when no servers are declared, with a conservative fallback to the old
behaviour if the config probe itself fails.
## Measurements (macOS Terminal.app, Apple Silicon, n=12)
| Metric | Before (p50) | After (p50) | Δ |
|----------------------------|--------------|-------------|----------|
| Python gateway boot alone | 252–365ms | 105–151ms | −180ms |
| `hermes --tui` banner paint | 686ms | 665ms | −21ms |
| `hermes --tui` → ready | **1843ms** | **1655ms** | **−188ms (−10.2%)** |
| `hermes --tui` → ready p90 | 1932ms | 1778ms | −154ms |
| stdev (ready) | 126ms | 83ms | also more consistent |
## Tests
- `scripts/run_tests.sh tests/tui_gateway/ tests/tools/test_mcp_tool.py`:
195 passed. (The one pre-existing failure in
`test_session_resume_returns_hydrated_messages` reproduces on main —
unrelated, it's a mock-DB kwarg mismatch.)
- `ui-tui` vitest: 430 tests, all pass.
- `npm run type-check` in ui-tui: clean.
## Notes
- Node-side first paint ("banner") didn't move meaningfully because that
latency is dominated by Ink's render pipeline + React mount, not by
which imports load first.
- The win shows up entirely in the time from banner to `gateway.ready`
— exactly where we expected it, since both fixes shorten the Python
gateway's boot path or let it overlap more with Node startup.
- No user-visible behaviour change. Memory monitoring still fires every
10s; MCP still works when `mcp_servers` is configured.
89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
import { type HeapDumpResult, performHeapDump } from './memory.js'
|
|
|
|
export type MemoryLevel = 'critical' | 'high' | 'normal'
|
|
|
|
export interface MemorySnapshot {
|
|
heapUsed: number
|
|
level: MemoryLevel
|
|
rss: number
|
|
}
|
|
|
|
export interface MemoryMonitorOptions {
|
|
criticalBytes?: number
|
|
highBytes?: number
|
|
intervalMs?: number
|
|
onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
|
|
onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
|
|
}
|
|
|
|
const GB = 1024 ** 3
|
|
|
|
// Deferred @hermes/ink import: loading `@hermes/ink` at module top-level
|
|
// pulls the full ~414KB Ink bundle (React, renderer, components, hooks) onto
|
|
// the critical path before the Python gateway can even be spawned. That
|
|
// serialised roughly 150ms of Node work in front of gw.start() on every
|
|
// cold `hermes --tui` launch.
|
|
//
|
|
// evictInkCaches only runs inside `tick()`, which fires on a 10s timer and
|
|
// only when heap pressure crosses the high-water mark — by then Ink has
|
|
// long since been loaded by the app entry. This dynamic import is a no-op
|
|
// on the hot path (module is already in the ESM cache); when a startup
|
|
// spike somehow trips the threshold before the app registers its own Ink
|
|
// import, we pay the load cost exactly once, inside the tick that needs it.
|
|
let _evictInkCaches: ((level: 'all' | 'half') => unknown) | null = null
|
|
async function _ensureEvictInkCaches(): Promise<(level: 'all' | 'half') => unknown> {
|
|
if (_evictInkCaches) return _evictInkCaches
|
|
const mod = await import('@hermes/ink')
|
|
_evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown
|
|
return _evictInkCaches
|
|
}
|
|
|
|
export function startMemoryMonitor({
|
|
criticalBytes = 2.5 * GB,
|
|
highBytes = 1.5 * GB,
|
|
intervalMs = 10_000,
|
|
onCritical,
|
|
onHigh
|
|
}: MemoryMonitorOptions = {}): () => void {
|
|
const dumped = new Set<Exclude<MemoryLevel, 'normal'>>()
|
|
|
|
const tick = async () => {
|
|
const { heapUsed, rss } = process.memoryUsage()
|
|
const level: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
|
|
|
|
if (level === 'normal') {
|
|
return void dumped.clear()
|
|
}
|
|
|
|
if (dumped.has(level)) {
|
|
return
|
|
}
|
|
|
|
// Prune Ink content caches before dump/exit — half on 'high' (recoverable),
|
|
// full on 'critical' (post-dump RSS reduction, keeps user running).
|
|
// 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
|
|
// the same module, so this resolves instantly from the ESM cache.
|
|
try {
|
|
const evictInkCaches = await _ensureEvictInkCaches()
|
|
evictInkCaches(level === 'critical' ? 'all' : 'half')
|
|
} catch {
|
|
// Best-effort: if the dynamic import fails for any reason we still
|
|
// continue to the heap dump below so the user gets diagnostics.
|
|
}
|
|
|
|
dumped.add(level)
|
|
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
|
|
|
|
const snap: MemorySnapshot = { heapUsed, level, rss }
|
|
|
|
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
|
|
}
|
|
|
|
const handle = setInterval(() => void tick(), intervalMs)
|
|
|
|
handle.unref?.()
|
|
|
|
return () => clearInterval(handle)
|
|
}
|