ui-tui: env-gated yoga-node sampler for bench instrumentation (dark by default)

This commit is contained in:
alt-glitch 2026-06-11 02:29:48 +05:30
parent b091b4eaeb
commit 79d1b58afe
3 changed files with 163 additions and 0 deletions

View file

@ -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?.()

View file

@ -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()
})
})

View file

@ -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":<epoch ms>,"dom":<DOM nodes>,"yoga":<live Yoga nodes>}.
//
// 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)
}
}