chore(tui): /clean recent perf work — KISS/DRY pass

24 files, -319 LoC. Behaviour preserved, 369/369 tests green.

- hermes-ink caches: shared lruEvict helper for the four parallel LRU
  caches (stringWidth, wrapText, sliceAnsi, lineWidth); touch-on-read
  stays inlined per cache; tightened output.ts skip-slice fast path.
- wheelAccel: trimmed provenance header, collapsed env parsing, ternary
  dispatch in computeWheelStep.
- perfPane: folded ensureLogDir into once-flag, spread-with-overrides
  for fastPath/phases instead of full rebuilds.
- env: extracted truthy() (used 4×).
- virtualHeights: collapsed user/diff/slash height bumps; trail+todos
  estimate.
- useInputHandlers: scrollIdleTimer cleanup on unmount, ?? undefined
  shorthand.
- useMainApp: dropped dead liveTailVisible IIFE and liveProgress
  indirection.
- appLayout, markdown, messageLine, entry: vertical rhythm, dropped
  narration comments, inlined one-shot vars.
- fix: empty catch blocks → /* best-effort */ for no-empty lint.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 20:38:47 -05:00
parent 527ac351b4
commit b1c49d5e73
32 changed files with 259 additions and 547 deletions

View file

@ -1,43 +1,15 @@
// Perf instrumentation for the full render pipeline.
//
// Two sources of timing:
// 1. React.Profiler wrapper (PerfPane) → per-pane commit times. Shows
// which subtree is reconciling and for how long.
// 2. Ink onFrame callback (logFrameEvent) → per-frame pipeline phases:
// yoga (calculateLayout), renderer (DOM → screen buffer), diff
// (prev vs current screen → patches), optimize (patch merge/dedupe),
// write (serialize → ANSI → stdout), plus yoga counters (visited,
// measured, cacheHits, live). Shows where the time goes BELOW React.
// PerfPane (React.Profiler) → per-pane commit times
// logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write
// phases + yoga counters + scroll fast-path
//
// Both sources gate on HERMES_DEV_PERF=1 and dump JSON-lines to the same
// log (default ~/.hermes/perf.log, override via HERMES_DEV_PERF_LOG).
// Events are tagged { src: 'react' | 'frame' } so jq can split them.
// Both gate on HERMES_DEV_PERF=1 and dump JSON-lines (default ~/.hermes/perf.log,
// override HERMES_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq.
// HERMES_DEV_PERF_MS (default 2) skips sub-ms idle frames; set 0 to capture all.
//
// Threshold HERMES_DEV_PERF_MS (default 2ms) skips sub-millisecond idle
// frames. For the 2fps-during-PageUp investigation, set
// HERMES_DEV_PERF_MS=0 to capture everything, then filter with jq.
//
// Zero cost when the env var is unset: PerfPane returns children
// directly (no Profiler fiber), logFrameEvent is a noop on the onFrame
// callback — the ink instance isn't given the callback at all.
//
// Usage:
// # entry.tsx wires logFrameEvent into render()
// import { logFrameEvent, PerfPane } from './lib/perfPane.js'
// render(<App/>, { onFrame: logFrameEvent })
//
// Analysis helpers (once you've captured a session):
// tail -f ~/.hermes/perf.log | jq -c 'select(.src=="frame" and .durationMs > 16)'
// # p50/p99 per phase across frame events:
// jq -s '[.[] | select(.src=="frame")] |
// {n: length,
// dur_p50: (sort_by(.durationMs) | .[length/2|floor].durationMs),
// dur_p99: (sort_by(.durationMs) | .[length*0.99|floor].durationMs),
// yoga_p99: (sort_by(.phases.yoga) | .[length*0.99|floor].phases.yoga),
// write_p99: (sort_by(.phases.write) | .[length*0.99|floor].phases.write),
// diff_p99: (sort_by(.phases.diff) | .[length*0.99|floor].phases.diff),
// patches_p99: (sort_by(.phases.patches) | .[length*0.99|floor].phases.patches)}' \
// ~/.hermes/perf.log
// Zero cost when unset: PerfPane returns children directly, logFrameEvent is
// undefined so ink doesn't pay the timing cost.
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
@ -51,31 +23,23 @@ const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
let initialized = false
const ensureLogDir = () => {
if (initialized) {
return
}
initialized = true
try {
mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch {
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.)
// the appendFileSync calls below will throw silently and we drop the
// sample. Perf logging should never crash the TUI.
}
}
let logReady = false
const writeRow = (row: Record<string, unknown>) => {
ensureLogDir()
if (!logReady) {
logReady = true
try {
mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch {
// Best-effort — never crash the TUI to log a sample.
}
}
try {
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
} catch {
// Same rationale as ensureLogDir — never crash the UI to log a sample.
/* best-effort */
}
}
@ -110,59 +74,27 @@ export function PerfPane({ children, id }: { children: ReactNode; id: string })
)
}
/**
* Ink onFrame handler. Captures the FULL render pipeline: yoga calculateLayout,
* DOM screen buffer, screen diff, patch optimize, and stdout write.
*
* Returns `undefined` when disabled so `render()` doesn't attach the callback
* ink only pays the timing cost when the callback is truthy.
*/
export const logFrameEvent = ENABLED
? (event: FrameEvent) => {
if (event.durationMs < THRESHOLD_MS) {
return
}
// Snapshot the fast-path counters each frame. Cumulative values —
// consumers diff pairs to get per-frame deltas. Written verbatim
// so we can also see "last*" fields (which decline reason fired,
// and what the height math looked like).
const fastPath = {
captured: scrollFastPathStats.captured,
taken: scrollFastPathStats.taken,
declined: {
heightDeltaMismatch: scrollFastPathStats.declined.heightDeltaMismatch,
noPrevScreen: scrollFastPathStats.declined.noPrevScreen,
other: scrollFastPathStats.declined.other
},
lastDeclineReason: scrollFastPathStats.lastDeclineReason,
lastHeightDelta: scrollFastPathStats.lastHeightDelta,
lastHintDelta: scrollFastPathStats.lastHintDelta,
lastPrevHeight: scrollFastPathStats.lastPrevHeight,
lastScrollHeight: scrollFastPathStats.lastScrollHeight
}
writeRow({
durationMs: round2(event.durationMs),
fastPath,
// Cumulative counters — consumers diff pairs to get per-frame deltas.
fastPath: { ...scrollFastPathStats, declined: { ...scrollFastPathStats.declined } },
flickers: event.flickers.length ? event.flickers : undefined,
phases: event.phases
? {
backpressure: event.phases.backpressure,
...event.phases,
commit: round2(event.phases.commit),
diff: round2(event.phases.diff),
optimize: round2(event.phases.optimize),
optimizedPatches: event.phases.optimizedPatches,
patches: event.phases.patches,
prevFrameDrainMs: round2(event.phases.prevFrameDrainMs),
renderer: round2(event.phases.renderer),
write: round2(event.phases.write),
writeBytes: event.phases.writeBytes,
yoga: round2(event.phases.yoga),
yogaCacheHits: event.phases.yogaCacheHits,
yogaLive: event.phases.yogaLive,
yogaMeasured: event.phases.yogaMeasured,
yogaVisited: event.phases.yogaVisited
yoga: round2(event.phases.yoga)
}
: undefined,
src: 'frame',