mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
Root cause of the bench-suite crash (every otui mem3000/slope cell died at
~3000 lumpy fixture msgs, exit 7, ~880MB RSS — not a cgroup kill):
- @opentui/core 0.4.0 routes EVERY native object through ONE global handle
registry with 16-bit slot indices (core src/zig/handles.zig: INDEX_BITS=16,
MAX_SLOTS=65535, slot 0 reserved). Measured on this install: exactly 65,534
live handles; the next createSyntaxStyle() fails. destroy() DOES recycle
slots — exhaustion means LIVE objects.
- Every TextBufferRenderable burns THREE slots in its constructor
(TextBufferRenderable.ts:77-80: TextBuffer + TextBufferView + SyntaxStyle),
so the mount-everything transcript hits the wall at ~1,400 store rows
(~16 text renderables/row x 3 ~ 47 handles/row): "Failed to create
SyntaxStyle" (zig.ts:4554) throws out of a Solid mount effect.
- The crash was MASKED: CliRenderer's own uncaughtException handler
(handleError -> console.show()) allocates the console-overlay
OptimizedBuffer — another handle — so the handler itself threw "Failed to
create optimized buffer: WxH" and Node died with exit 7 (fatal error in
the uncaughtException handler), hiding the real error.
Why not share one SyntaxStyle (the obvious 3->2): the per-buffer style is
load-bearing — native setStyledText (text-buffer.zig) registers each chunk's
color by NAME ("chunk{i}") into the buffer's OWN style, and registration is
name-keyed-overwrite (syntax-style.zig putStyle), so a shared style would
cross-corrupt chunk colors between every styled <text>. Pooling is unsound
at our layer in core 0.4.0.
The fix, at the seams that are ours:
- boundary/nativeHandles.ts (ffiSafe.ts sibling): SyntaxStyle.create() on a
full table DEGRADES to a detached style (native handle 0) instead of
throwing — JS-side styleDefs/mergeStyles (what markdown/code chunk colors
actually use) keep working; all native calls on handle 0 are inert no-ops.
- boundary/renderer.ts: guard the process error listeners createCliRenderer
installs so an exception INSIDE the handler can never exit-7-mask the
original error again (logged honestly; original error stays the story).
- logic/store.ts: HERMES_TUI_MAX_MESSAGES clamped to a handle-safe ceiling
(1000 rows ~ 47k handles ~ 72% of the table on the realistic fixture).
The old default of 3000 was unreachable — the TUI crashed at ~1,400 rows,
before the cap ever bound. Renderable-weight-aware capping is #27's
(virtualization) to do properly; until then the degrade shim backstops
pathological rows.
TODO(upstream) — issue-shaped, for the OpenTUI repo:
(a) a global 64k handle table with a 3-slot cost per text renderable is
too small for transcript-style TUIs (61k renderables ~ 3k messages);
(b) native allocation failures throw out of the render loop with no
degrade path;
(c) handleError allocates (console overlay buffer) and so crashes on the
very condition it is reporting, masking the root cause with exit 7.
Also: eslint now ignores ui-opentui/.bench/** (bench `nodes`-cell build
artifact broke the lint gate) and .gitignore covers it.
Gate: npm run check green, 599 tests (595 baseline + 3 degrade-path tests
+ 1 cap-clamp test).
287 lines
9.9 KiB
TypeScript
287 lines
9.9 KiB
TypeScript
/**
|
||
* DEV BENCH FIXTURE — NOT a test, NOT production code. A deterministic generator
|
||
* for a REALISTIC heavy session, consumed by `scripts/mem-bench.tsx`. Excluded
|
||
* from the vitest run (not a *.test.ts) and lint-clean.
|
||
*
|
||
* The old synthetic bench pushed tiny 3-delta turns (~5.5 mounted nodes each) —
|
||
* an unrealistic per-message cost. Real transcripts are LUMPY: an assistant turn
|
||
* is ONE `message` but a fat node subtree (markdown blocks + a reasoning block +
|
||
* several tool headers, each a multi-line result). That makes message-count a
|
||
* LOOSE proxy for memory, which is exactly what we're trying to quantify before
|
||
* picking a `HERMES_TUI_MAX_MESSAGES` default.
|
||
*
|
||
* Design: a turn is modeled as a small typed `TurnAction` union (user / system /
|
||
* gateway-event). The driver maps user→`pushUser`, system→`pushSystem`, and every
|
||
* gateway event through the SAME `apply()` reducer real usage takes — so the
|
||
* mounted result is identical to a live session. The same action stream also
|
||
* materializes a settled `Message[]` (via `materialize`) for the resume-path check
|
||
* (`commitSnapshot`). Everything is seeded by index (no `Math.random` —
|
||
* unavailable here), so a given `total` reproduces byte-for-byte.
|
||
*/
|
||
import type { GatewayEvent } from '../src/boundary/schema/GatewayEvent.ts'
|
||
import { createSessionStore, type Message } from '../src/logic/store.ts'
|
||
|
||
/** One scripted action in a turn: a composer push or a decoded gateway event. */
|
||
type TurnAction =
|
||
| { kind: 'user'; text: string }
|
||
| { kind: 'system'; text: string }
|
||
| { kind: 'event'; event: GatewayEvent }
|
||
|
||
/** A pool of lorem-ipsum words — varied content is selected by index from here. */
|
||
const WORDS = [
|
||
'lorem',
|
||
'ipsum',
|
||
'dolor',
|
||
'sit',
|
||
'amet',
|
||
'consectetur',
|
||
'adipiscing',
|
||
'elit',
|
||
'sed',
|
||
'eiusmod',
|
||
'tempor',
|
||
'incididunt',
|
||
'labore',
|
||
'magna',
|
||
'aliqua',
|
||
'enim',
|
||
'minim',
|
||
'veniam',
|
||
'quis',
|
||
'nostrud',
|
||
'exercitation',
|
||
'ullamco',
|
||
'laboris',
|
||
'aliquip',
|
||
'commodo',
|
||
'consequat',
|
||
'duis',
|
||
'aute',
|
||
'irure',
|
||
'reprehenderit',
|
||
'voluptate',
|
||
'velit',
|
||
'esse',
|
||
'cillum',
|
||
'fugiat',
|
||
'nulla',
|
||
'pariatur',
|
||
'excepteur',
|
||
'occaecat',
|
||
'cupidatat',
|
||
'proident',
|
||
'sunt',
|
||
'culpa',
|
||
'officia',
|
||
'deserunt',
|
||
'mollit',
|
||
'anim'
|
||
] as const
|
||
|
||
/** Deterministic pseudo-word stream: pick from WORDS by a seeded index. */
|
||
function word(seed: number, k: number): string {
|
||
return WORDS[(seed * 31 + k * 7) % WORDS.length] ?? 'lorem'
|
||
}
|
||
|
||
/** A lorem sentence of `n` words, capitalized + terminated. */
|
||
function sentence(seed: number, n: number): string {
|
||
const parts: string[] = []
|
||
for (let k = 0; k < n; k++) parts.push(word(seed + k, k))
|
||
const text = parts.join(' ')
|
||
return text.charAt(0).toUpperCase() + text.slice(1) + '.'
|
||
}
|
||
|
||
/** A paragraph of `s` sentences (varying length by index). */
|
||
function paragraph(seed: number, s: number): string {
|
||
const out: string[] = []
|
||
for (let i = 0; i < s; i++) out.push(sentence(seed + i * 13, 6 + ((seed + i) % 9)))
|
||
return out.join(' ')
|
||
}
|
||
|
||
/** N lorem-ipsum lines (for tool result bodies), each varying in length. */
|
||
function lines(seed: number, n: number): string {
|
||
const out: string[] = []
|
||
for (let i = 0; i < n; i++) out.push(sentence(seed + i * 5, 4 + ((seed + i) % 11)))
|
||
return out.join('\n')
|
||
}
|
||
|
||
/** A markdown assistant body: paragraphs + a list + a fenced code block. */
|
||
function assistantMarkdown(seed: number): string {
|
||
const lead = paragraph(seed, 1 + (seed % 3))
|
||
const bullets = [`- ${sentence(seed + 1, 5)}`, `- ${sentence(seed + 2, 7)}`, `- ${sentence(seed + 3, 4)}`].join('\n')
|
||
const code = [
|
||
'```ts',
|
||
`const x${seed % 7} = ${seed % 100}`,
|
||
`function f${seed % 5}() {`,
|
||
' return x',
|
||
'}',
|
||
'```'
|
||
].join('\n')
|
||
const tail = paragraph(seed + 17, 1 + ((seed + 1) % 2))
|
||
return `${lead}\n\n${bullets}\n\n${code}\n\n${tail}`
|
||
}
|
||
|
||
/** Tool names cycled by index (mirrors a real tool mix). */
|
||
const TOOL_NAMES = ['terminal', 'read_file', 'edit_file', 'grep', 'web_search', 'write_file'] as const
|
||
|
||
/** A tool.start + tool.complete pair for tool `t` in turn `seed`. */
|
||
function toolEvents(seed: number, t: number): GatewayEvent[] {
|
||
const id = `tool-${seed}-${t}`
|
||
const name = TOOL_NAMES[(seed + t) % TOOL_NAMES.length] ?? 'terminal'
|
||
const variant = (seed + t) % 3
|
||
// short / capped-16-line / medium result bodies, mixing the render-cost cases.
|
||
const bodyLines = variant === 0 ? 2 : variant === 1 ? 18 : 7
|
||
const resultText = lines(seed + t * 3, bodyLines)
|
||
const context = sentence(seed + t, 4)
|
||
// ~half the tools carry a multi-line args block (the expanded-view cost).
|
||
const withArgs = (seed + t) % 2 === 0
|
||
const start: GatewayEvent = {
|
||
type: 'tool.start',
|
||
payload: withArgs ? { tool_id: id, name, context, args_text: lines(seed + t, 5) } : { tool_id: id, name, context }
|
||
}
|
||
const complete: GatewayEvent = {
|
||
type: 'tool.complete',
|
||
payload: {
|
||
tool_id: id,
|
||
name,
|
||
result_text: resultText,
|
||
duration_s: 0.1 + ((seed + t) % 40) / 10,
|
||
args: { command: context, index: seed + t }
|
||
}
|
||
}
|
||
return [start, complete]
|
||
}
|
||
|
||
/** One USER message (1–4 lorem paragraphs; some very short, some RFC-sized). */
|
||
function userText(seed: number): string {
|
||
const shape = seed % 7
|
||
if (shape === 0) return 'yes do that'
|
||
if (shape === 1) return 'ok'
|
||
if (shape === 6) {
|
||
// an RFC-sized pasted block: many paragraphs.
|
||
const out: string[] = []
|
||
for (let p = 0; p < 8; p++) out.push(paragraph(seed + p * 23, 4 + (p % 3)))
|
||
return out.join('\n\n')
|
||
}
|
||
const n = 1 + (seed % 4)
|
||
const out: string[] = []
|
||
for (let p = 0; p < n; p++) out.push(paragraph(seed + p * 11, 1 + ((seed + p) % 3)))
|
||
return out.join('\n\n')
|
||
}
|
||
|
||
/**
|
||
* Build the scripted actions for ONE turn. Most turns are a plain user+assistant
|
||
* exchange; a deterministic subset are tool-heavy (1–15 tool calls) or a system
|
||
* slash-output line. Returns the actions for the whole turn in order.
|
||
*/
|
||
function turnActions(turn: number): TurnAction[] {
|
||
const actions: TurnAction[] = []
|
||
// Occasional system slash-output line (≈ every 9th turn) instead of a user line.
|
||
if (turn % 9 === 4) {
|
||
actions.push({ kind: 'system', text: sentence(turn, 8) })
|
||
return actions
|
||
}
|
||
|
||
actions.push({ kind: 'user', text: userText(turn) })
|
||
actions.push({ kind: 'event', event: { type: 'message.start' } })
|
||
|
||
// Reasoning on ≈ every 3rd assistant turn.
|
||
if (turn % 3 === 0) {
|
||
actions.push({
|
||
kind: 'event',
|
||
event: {
|
||
type: 'reasoning.delta',
|
||
payload: { text: `**${sentence(turn, 3).replace(/\.$/, '')}**\n\n${paragraph(turn + 5, 2)}` }
|
||
}
|
||
})
|
||
}
|
||
|
||
// Leading text part.
|
||
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: assistantMarkdown(turn) } } })
|
||
|
||
// Tool-heavy turns: ≈ every 4th assistant turn carries several tool calls,
|
||
// interleaved with a follow-up text part (the fat-turn stress case).
|
||
if (turn % 4 === 0) {
|
||
const toolCount = 1 + (turn % 15) // 1..15 tools
|
||
for (let t = 0; t < toolCount; t++) {
|
||
for (const ev of toolEvents(turn, t)) actions.push({ kind: 'event', event: ev })
|
||
}
|
||
actions.push({ kind: 'event', event: { type: 'message.delta', payload: { text: paragraph(turn + 31, 2) } } })
|
||
}
|
||
|
||
actions.push({ kind: 'event', event: { type: 'message.complete' } })
|
||
return actions
|
||
}
|
||
|
||
/** How many transcript ROWS a turn produces (user/system + at most one assistant). */
|
||
export function rowsPerTurn(turn: number): number {
|
||
return turn % 9 === 4 ? 1 : 2
|
||
}
|
||
|
||
/** Apply ONE turn's actions to a store via the same paths real usage takes. */
|
||
export function applyTurn(store: ReturnType<typeof createSessionStore>, turn: number): void {
|
||
for (const action of turnActions(turn)) {
|
||
if (action.kind === 'user') store.pushUser(action.text)
|
||
else if (action.kind === 'system') store.pushSystem(action.text)
|
||
else store.apply(action.event)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drive at least `total` MESSAGES into the live store, calling `onSample(pushes)`
|
||
* each time the cumulative produced-row count crosses a `sampleEvery` boundary.
|
||
* `pushes` counts MESSAGES (rows produced, pre-cap), so the matrix samples on a
|
||
* raw message cadence regardless of the rolling cap.
|
||
*/
|
||
export function drive(
|
||
store: ReturnType<typeof createSessionStore>,
|
||
total: number,
|
||
sampleEvery: number,
|
||
onSample: (pushes: number) => void
|
||
): number {
|
||
let pushed = 0
|
||
let nextSample = sampleEvery
|
||
let turn = 0
|
||
while (pushed < total) {
|
||
applyTurn(store, turn)
|
||
pushed += rowsPerTurn(turn)
|
||
turn++
|
||
while (pushed >= nextSample && nextSample <= total) {
|
||
onSample(Math.min(pushed, total))
|
||
nextSample += sampleEvery
|
||
}
|
||
}
|
||
return turn
|
||
}
|
||
|
||
/**
|
||
* Materialize the FULL settled `Message[]` for the resume path: replay the same
|
||
* action stream into a FRESH, EFFECTIVELY-UNCAPPED store and snapshot its rows.
|
||
* This guarantees the resume fixture is byte-identical to what the live push
|
||
* path produces (minus the rolling cap), so `commitSnapshot` mounts the real shape.
|
||
*/
|
||
export function materialize(total: number): Message[] {
|
||
// `uncappedFixture` bypasses the store's handle-safe cap CLAMP (an env value
|
||
// can no longer raise the cap past logic/store.ts HANDLE_SAFE_MAX_ROWS — the
|
||
// old env=MAX_SAFE_INTEGER trick would now silently truncate to 1000 rows).
|
||
// This store is never mounted into a renderer, so no native handles are at stake.
|
||
const store = createSessionStore({ uncappedFixture: true })
|
||
store.apply({ type: 'gateway.ready' })
|
||
let pushed = 0
|
||
let turn = 0
|
||
while (pushed < total) {
|
||
applyTurn(store, turn)
|
||
pushed += rowsPerTurn(turn)
|
||
turn++
|
||
}
|
||
// Deep-copy out of the solid store proxy into plain objects (the resume path
|
||
// takes a plain Message[]).
|
||
return store.state.messages.slice(0, total).map(cloneMessage)
|
||
}
|
||
|
||
/** Plain deep copy of a store Message (drop the solid proxy + streaming flag). */
|
||
function cloneMessage(m: Message): Message {
|
||
const copy: Message = { role: m.role, text: m.text }
|
||
if (m.parts) copy.parts = m.parts.map(p => ({ ...p }))
|
||
return copy
|
||
}
|