mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
ui-tui: env-gated yoga-node sampler for bench instrumentation (dark by default)
This commit is contained in:
parent
14ee1a52c0
commit
197d499480
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 { applyHyperlinkHoverHighlight } from './hyperlinkHover.js'
|
||||||
import instances from './instances.js'
|
import instances from './instances.js'
|
||||||
import { LogUpdate } from './log-update.js'
|
import { LogUpdate } from './log-update.js'
|
||||||
|
import { maybeStartMemSampler } from './memSampler.js'
|
||||||
import { nodeCache } from './node-cache.js'
|
import { nodeCache } from './node-cache.js'
|
||||||
import { optimize } from './optimizer.js'
|
import { optimize } from './optimizer.js'
|
||||||
import Output from './output.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
|
// Ignore last render after unmounting a tree to prevent empty output before exit
|
||||||
private isUnmounted = false
|
private isUnmounted = false
|
||||||
|
// Bench-only node-count sampler stopper (no-op unless HERMES_TUI_MEMSAMPLE_FD)
|
||||||
|
private readonly stopMemSampler: () => void
|
||||||
private isPaused = false
|
private isPaused = false
|
||||||
private readonly container: FiberRoot
|
private readonly container: FiberRoot
|
||||||
private rootNode: dom.DOMElement
|
private rootNode: dom.DOMElement
|
||||||
|
|
@ -391,6 +394,9 @@ export default class Ink {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rootNode = dom.createNode('ink-root')
|
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.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event))
|
||||||
this.rootNode.focusManager = this.focusManager
|
this.rootNode.focusManager = this.focusManager
|
||||||
this.renderer = createRenderer(this.rootNode, this.stylePool)
|
this.renderer = createRenderer(this.rootNode, this.stylePool)
|
||||||
|
|
@ -2426,6 +2432,8 @@ export default class Ink {
|
||||||
|
|
||||||
this.isUnmounted = true
|
this.isUnmounted = true
|
||||||
|
|
||||||
|
this.stopMemSampler()
|
||||||
|
|
||||||
// Cancel any pending throttled renders to prevent accessing freed Yoga nodes
|
// Cancel any pending throttled renders to prevent accessing freed Yoga nodes
|
||||||
this.scheduleRender.cancel?.()
|
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