mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
ui-tui: env-gated yoga-node sampler for bench instrumentation (dark by default)
This commit is contained in:
parent
b091b4eaeb
commit
79d1b58afe
3 changed files with 163 additions and 0 deletions
|
|
@ -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?.()
|
||||
|
||||
|
|
|
|||
68
ui-tui/packages/hermes-ink/src/ink/memSampler.test.ts
Normal file
68
ui-tui/packages/hermes-ink/src/ink/memSampler.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
87
ui-tui/packages/hermes-ink/src/ink/memSampler.ts
Normal file
87
ui-tui/packages/hermes-ink/src/ink/memSampler.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue