perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint

Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:36:42 -05:00
parent c0763bd13a
commit 296303302d
8 changed files with 165 additions and 19 deletions

View file

@ -3,6 +3,7 @@ import concurrent.futures
import contextlib
import contextvars
import copy
import hashlib
import inspect
import json
import logging
@ -226,6 +227,13 @@ _LONG_HANDLERS = frozenset(
"pet.thumb",
"learning.frames",
"plugins.manage",
# reload.mcp shuts down and rediscovers every MCP server — with a
# flapping server (retry loops, connect timeouts up to 120s) that can
# block for minutes. Inline it froze the reader thread: config.set,
# complete.slash, prompt.submit all sat unread and the TUI appeared
# dead after a few skin switches. The handler serializes concurrent
# reloads via _mcp_reload_lock.
"reload.mcp",
"process.list",
"projects.discover_repos",
"projects.record_repos",
@ -12674,11 +12682,24 @@ def _(rid, params: dict) -> dict:
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:
return _ok(
rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0}
)
mtime = cfg_path.stat().st_mtime if cfg_path.exists() else 0
except Exception:
return _ok(rid, {"mtime": 0})
# Revision hash of the MCP-relevant config sections. The TUI's
# config-change poller uses it to reload MCP servers only when their
# config actually changed — a /skin or /statusbar write bumps mtime
# but must not cost a multi-second MCP reconnect.
try:
cfg = _load_cfg()
rev_src = json.dumps(
{"mcp": cfg.get("mcp"), "tools": cfg.get("tools")},
sort_keys=True,
default=str,
)
mcp_rev = hashlib.sha1(rev_src.encode()).hexdigest()[:12]
except Exception:
mcp_rev = ""
return _ok(rid, {"mtime": mtime, "mcp_rev": mcp_rev})
return _err(rid, 4002, f"unknown config key: {key}")
@ -12852,6 +12873,14 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5010, str(e))
# reload.mcp runs on the RPC pool (see _LONG_HANDLERS) so a slow/flapping MCP
# server can't freeze the reader thread. Serialize reloads: overlapping
# shutdown+discover pairs from stacked config-change polls would interleave
# and leave the registry half-built. Piggyback rather than queue — a reload
# that arrives while one is running would just redo identical work.
_mcp_reload_lock = threading.Lock()
@method("reload.mcp")
def _(rid, params: dict) -> dict:
session = _sessions.get(params.get("session_id", ""))
@ -12906,8 +12935,18 @@ def _(rid, params: dict) -> dict:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
shutdown_mcp_servers()
discover_mcp_tools()
if not _mcp_reload_lock.acquire(blocking=False):
# A reload is already in flight; wait for it to finish and reuse
# its result instead of tearing the freshly-built registry down.
with _mcp_reload_lock:
pass
return _ok(rid, {"status": "reloaded", "coalesced": True})
try:
shutdown_mcp_servers()
discover_mcp_tools()
finally:
_mcp_reload_lock.release()
if session:
agent = session["agent"]
# Rebuild the cached agent's tool snapshot so the current session

View file

@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { onTerminalBackground } from '@hermes/ink'
import { forceRedraw, onTerminalBackground } from '@hermes/ink'
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
import { STREAM_BATCH_MS } from '../config/timing.js'
@ -50,9 +50,42 @@ const themeForSkin = (s: GatewaySkin) => {
// Patch the live theme AND persist it for the next launch's first frame
// (flash-free boot — see lib/themeBoot.ts).
//
// The force-redraw is load-bearing: a theme swap recolors EVERYTHING, but the
// renderer's diff/blit cache treats layout-unchanged regions as reusable, so
// incremental repaints after a swap can tear — stale cells keep the previous
// palette (observed live: gold headers from the boot theme composited with
// slate chrome, dark status fills surviving on a light terminal, half-
// overwritten glyphs reading as "shadows"). One full clear+repaint after the
// new theme has rendered guarantees a coherent frame. Deferred ~2 frames so
// React + Ink flush the recolored tree first; skipping identical themes keeps
// the no-op resolution path (boot cache confirmed by detection) paint-free.
let lastCommittedTheme: Theme | null = null
const commitTheme = (theme: Theme) => {
const changed = lastCommittedTheme !== null && !themesEqual(lastCommittedTheme, theme)
lastCommittedTheme = theme
patchUiState({ theme })
writeBootTheme(theme, process.env.HERMES_TUI_BACKGROUND)
if (changed) {
setTimeout(() => forceRedraw(process.stdout), 40).unref?.()
}
}
const themesEqual = (a: Theme, b: Theme) => {
if (a === b) {
return true
}
for (const key of Object.keys(a.color) as (keyof Theme['color'])[]) {
if (a.color[key] !== b.color[key]) {
return false
}
}
return a.brand.name === b.brand.name && a.brand.prompt === b.brand.prompt && a.bannerLogo === b.bannerLogo && a.bannerHero === b.bannerHero
}
const applySkin = (s: GatewaySkin) => {
@ -122,13 +155,15 @@ export function syncThemeToTerminalBackground(): void {
let resolved = false
onTerminalBackground(hex => {
// xterm.js hosts (VS Code / Cursor) answer OSC 11 with #000000 when the
// editor theme sets no explicit terminal background — the renderer's
// unset DEFAULT, not the painted color (observed: pure black reported on
// a white terminal). A real vscode dark theme reports its actual surface
// (#1e1e1e etc.), so exactly-#000000 from a vscode host carries no
// signal: skip the cache and fall through to the OS-appearance inference.
if (hex === '#000000' && (process.env.TERM_PROGRAM ?? '') === 'vscode') {
// Exactly-#000000 is the "unset default" fingerprint, not a measurement:
// xterm.js reports it when the editor theme sets no terminal background
// (observed: pure black reported on a white Cursor terminal), and tmux
// answers OSC 11 with its own black fallback regardless of the outer
// terminal — and tmux also strips TERM_PROGRAM, so no host allow-list
// can catch it. Real dark themes report their actual surface (#1e1e1e,
// #282828, …). Distrusting pure black universally is safe: on a truly
// pure-black terminal the fall-through detection lands on dark anyway.
if (hex === '#000000') {
return
}

View file

@ -245,6 +245,7 @@ export function useConfigSync({
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
const mcpRevRef = useRef('')
useEffect(() => {
if (!sid) {
@ -270,10 +271,12 @@ export function useConfigSync({
const id = setInterval(() => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
const nextMcpRev = String(r?.mcp_rev ?? '')
if (!mtimeRef.current) {
if (next) {
mtimeRef.current = next
mcpRevRef.current = nextMcpRev
}
return
@ -285,9 +288,21 @@ export function useConfigSync({
mtimeRef.current = next
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
// Reload MCP only when the MCP-relevant config actually changed.
// Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly;
// reconnecting every MCP server for those costs seconds and made
// skin switching feel glacial. Older gateways don't send mcp_rev —
// fall back to reload-on-any-change there.
const mcpChanged = !nextMcpRev || nextMcpRev !== mcpRevRef.current
mcpRevRef.current = nextMcpRev
if (mcpChanged) {
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
}
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
})
}, MTIME_POLL_MS)

View file

@ -55,6 +55,8 @@ gw.start()
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
`hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n`
let consecutiveDeadStreamErrors = 0
setupGracefulExit({
cleanups: [
() => {
@ -67,7 +69,31 @@ setupGracefulExit({
const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err)
recordParentLifecycle(`${scope}: ${message.split('\n')[0]?.slice(0, 400) ?? ''}`)
process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`)
// A dead PTY (terminal tab closed, SSH dropped without SIGHUP) turns every
// stdout/stderr write into EIO/EPIPE. Swallowing those here made the parent
// a zombie: Ink's render loop throws once a second, each throw lands back
// in this handler, and the crash log fills with `write EIO` forever while
// the gateway child keeps running. Bail out for real after a few in a row.
const code = (err as NodeJS.ErrnoException)?.code
if (code === 'EIO' || code === 'EPIPE') {
if (++consecutiveDeadStreamErrors >= 5) {
recordParentLifecycle(`dead output stream (${code} x${consecutiveDeadStreamErrors}) → exiting`)
void gw.kill('dead-output-stream')
process.exit(1)
}
return
}
consecutiveDeadStreamErrors = 0
try {
process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`)
} catch {
// stderr may be the dead stream itself.
}
},
onSignal: signal => {
// The next line in the crash log is the child's `=== SIGTERM received ===`

View file

@ -128,6 +128,9 @@ export interface ConfigFullResponse {
}
export interface ConfigMtimeResponse {
/** Revision hash of MCP-relevant config sections; reload MCP only when it
* changes (cosmetic writes like /skin must not trigger reconnects). */
mcp_rev?: string
mtime?: number
}

View file

@ -265,6 +265,23 @@ export function retone(color: string, lightness: number, minSaturation = 0): str
return toHex(fromHsl(h, Math.max(s, minSaturation), lightness))
}
/** Multiply HSL saturation by `factor` (clamped to 1), hue/lightness fixed. */
export function boostSaturation(color: string, factor: number): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const [h, s, l] = toHsl(rgb)
if (s === 0) {
return color
}
return toHex(fromHsl(h, Math.min(1, s * factor), l))
}
/**
* Chainable form for multi-step derivations, e.g.
* `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`.

View file

@ -111,7 +111,16 @@ export function writeBootTheme(theme: Theme, background?: string): void {
*/
const boot = readBootTheme()
if (boot?.background && !process.env.HERMES_TUI_BACKGROUND && !process.env.HERMES_TUI_THEME && !process.env.HERMES_TUI_LIGHT) {
if (
boot?.background &&
// Never seed the untrusted "unset default" fingerprint — a cache written
// before the distrust rule existed must not poison this session's
// detection (it would also suppress the macOS-appearance fallback).
boot.background.toLowerCase() !== '#000000' &&
!process.env.HERMES_TUI_BACKGROUND &&
!process.env.HERMES_TUI_THEME &&
!process.env.HERMES_TUI_LIGHT
) {
process.env.HERMES_TUI_BACKGROUND = boot.background
}

View file

@ -541,7 +541,9 @@ export function deriveTones(seeds: {
const { accent, bg, primary, text } = seeds
const isLight = (relativeLuminance(bg) ?? 0) > 0.5
const inkBlend = mix(primary, text, 0.27)
const surface = mix(bg, desaturate(accent, 0.35), isLight ? 0.045 : 0.09)
// Fill tint keeps most of the accent's chroma — a heavier desaturate here
// read as washed-out ("a little too desat") next to authored fills.
const surface = mix(bg, desaturate(accent, 0.15), isLight ? 0.045 : 0.09)
return {
muted: isLight ? inkBlend : desaturate(mix(accent, bg, 0.19), 0.16),