diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index d8c95fcc703..90febfbe153 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -28,6 +28,7 @@ import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js' import { applyHyperlinkHoverHighlight } from './hyperlinkHover.js' import instances from './instances.js' import { LogUpdate } from './log-update.js' +import { maybeStartMemSampler } from './memSampler.js' import { nodeCache } from './node-cache.js' import { optimize } from './optimizer.js' import Output from './output.js' @@ -177,6 +178,8 @@ export default class Ink { } // Ignore last render after unmounting a tree to prevent empty output before exit private isUnmounted = false + // Bench-only node-count sampler stopper (no-op unless HERMES_TUI_MEMSAMPLE_FD) + private readonly stopMemSampler: () => void private isPaused = false private readonly container: FiberRoot private rootNode: dom.DOMElement @@ -391,6 +394,9 @@ export default class Ink { } this.rootNode = dom.createNode('ink-root') + // Bench-only node-count sampler — inert unless HERMES_TUI_MEMSAMPLE_FD is + // set (see memSampler.ts). Dark by default in production. + this.stopMemSampler = maybeStartMemSampler(this.rootNode) this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event)) this.rootNode.focusManager = this.focusManager this.renderer = createRenderer(this.rootNode, this.stylePool) @@ -2426,6 +2432,8 @@ export default class Ink { this.isUnmounted = true + this.stopMemSampler() + // Cancel any pending throttled renders to prevent accessing freed Yoga nodes this.scheduleRender.cancel?.() diff --git a/ui-tui/packages/hermes-ink/src/ink/memSampler.test.ts b/ui-tui/packages/hermes-ink/src/ink/memSampler.test.ts new file mode 100644 index 00000000000..5622185a114 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/memSampler.test.ts @@ -0,0 +1,68 @@ +import { closeSync, openSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { countNodes, maybeStartMemSampler } from './memSampler.js' + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) + +const tree = { + yogaNode: {}, + childNodes: [ + { yogaNode: {}, childNodes: [{ childNodes: [] }] }, // text child without yoga + { yogaNode: {} }, + {} // virtual node, no yoga, no children + ] +} + +describe('memSampler', () => { + afterEach(() => { + delete process.env['HERMES_TUI_MEMSAMPLE_FD'] + delete process.env['HERMES_TUI_MEMSAMPLE_MS'] + }) + + it('counts DOM and yoga nodes', () => { + expect(countNodes(tree)).toEqual({ dom: 5, yoga: 3 }) + }) + + it('is a no-op when the env gate is unset', () => { + const stop = maybeStartMemSampler(tree) + + expect(typeof stop).toBe('function') + stop() + }) + + it('writes NDJSON samples to the configured fd', async () => { + const path = join(tmpdir(), `memsampler-test-${process.pid}-${Date.now()}.ndjson`) + const fd = openSync(path, 'w') + + process.env['HERMES_TUI_MEMSAMPLE_FD'] = String(fd) + + const stop = maybeStartMemSampler(tree, 10) + + await sleep(60) + stop() + closeSync(fd) + + const lines = readFileSync(path, 'utf8').trim().split('\n') + + expect(lines.length).toBeGreaterThanOrEqual(2) + + const sample = JSON.parse(lines[0]!) as { dom: number; t: number; yoga: number } + + expect(sample.dom).toBe(5) + expect(sample.yoga).toBe(3) + expect(sample.t).toBeGreaterThan(0) + }) + + it('goes dark permanently on a bad fd instead of throwing', async () => { + process.env['HERMES_TUI_MEMSAMPLE_FD'] = '987' + + const stop = maybeStartMemSampler(tree, 10) + + await sleep(40) + stop() + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/memSampler.ts b/ui-tui/packages/hermes-ink/src/ink/memSampler.ts new file mode 100644 index 00000000000..d72c7604d09 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/memSampler.ts @@ -0,0 +1,87 @@ +// Bench-only live node-count sampler (dark by default). When +// HERMES_TUI_MEMSAMPLE_FD is set to a writable file descriptor, periodically +// walks the forked reconciler's root DOM tree and writes one NDJSON line per +// sample to that fd: {"t":,"dom":,"yoga":}. +// +// Used by bench/ (the instrumented node-count runs — see +// docs/plans/opentui-bench-suite.md). RSS from instrumented runs is flagged and +// never headlined; this sampler exists ONLY as the mechanism witness for the +// transcript-growth claim. It writes to a dedicated fd (3 by convention), never +// stdout/stderr, so it cannot perturb the rendered frame stream. +// +// Failure policy: any error (bad fd, closed pipe) disables the sampler +// silently — production behavior must be identical with the env unset. + +import { writeSync } from 'node:fs' + +interface WalkableNode { + childNodes?: WalkableNode[] + yogaNode?: unknown +} + +/** Count DOM nodes and nodes holding a live Yoga node under `root` (inclusive). */ +export function countNodes(root: WalkableNode): { dom: number; yoga: number } { + let dom = 0 + let yoga = 0 + const stack: WalkableNode[] = [root] + while (stack.length > 0) { + const node = stack.pop() as WalkableNode + dom++ + if (node.yogaNode !== undefined && node.yogaNode !== null) yoga++ + const children = node.childNodes + if (children) { + for (let i = 0; i < children.length; i++) { + stack.push(children[i] as WalkableNode) + } + } + } + return { dom, yoga } +} + +/** + * Start the env-gated sampler. Returns a stop function (no-op when the gate is + * off). `intervalMs` falls back to HERMES_TUI_MEMSAMPLE_MS, then 1000. + */ +export function maybeStartMemSampler(root: WalkableNode, intervalMs?: number): () => void { + const rawFd = process.env['HERMES_TUI_MEMSAMPLE_FD'] + if (!rawFd) { + return () => {} + } + + const fd = Number.parseInt(rawFd, 10) + + if (!Number.isInteger(fd) || fd < 0) { + return () => {} + } + + const rawMs = Number.parseInt(process.env['HERMES_TUI_MEMSAMPLE_MS'] ?? '', 10) + const period = intervalMs ?? (Number.isFinite(rawMs) && rawMs > 0 ? rawMs : 1000) + + let disabled = false + + const tick = () => { + if (disabled) { + return + } + + try { + const counts = countNodes(root) + + writeSync(fd, `${JSON.stringify({ t: Date.now(), dom: counts.dom, yoga: counts.yoga })}\n`) + } catch { + // Bad/closed fd: go dark permanently rather than risk the render loop. + disabled = true + clearInterval(timer) + } + } + + const timer = setInterval(tick, period) + + // Never keep the process alive for the sampler. + timer.unref?.() + + return () => { + disabled = true + clearInterval(timer) + } +}