hermes-agent/ui-opentui/src/boundary/nativeHandles.ts
alt-glitch 31916539af opentui(v6): degrade SyntaxStyle exhaustion, unmask the exit-7 crash, clamp the cap to the 65k native handle table
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).
2026-06-11 04:06:19 +05:30

111 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Native handle-table exhaustion safety for @opentui/core 0.4.0 — sibling of
* the ffiSafe.ts coordinate shim (same class of fix: harden OUR side of the
* Node-FFI seam, TODO(upstream) to delete).
*
* Root cause (bench crash: every otui mem3000 cell died at ≈3000 lumpy fixture
* messages, exit 7, ~880MB RSS — far below the 2GB cgroup cap): the native
* core indexes EVERY object — TextBuffer, TextBufferView, SyntaxStyle,
* OptimizedBuffer, … — 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
* 65,535th `createSyntaxStyle()` fails; `destroy()` does recycle slots, so
* exhaustion means LIVE objects.
*
* Every `TextBufferRenderable` burns THREE slots at construction
* (`TextBufferRenderable.ts:77-80`: `TextBuffer.create()` +
* `TextBufferView.create()` + `SyntaxStyle.create()`). The mount-everything
* transcript hits the wall at ≈1,400 store rows (≈21.8k text renderables ×3 ≈
* 65.5k handles): the next mount throws `Failed to create SyntaxStyle`
* (zig.ts:4554) out of a Solid mount effect → uncaught → the renderer's OWN
* `uncaughtException` handler (renderer.ts `handleError`) calls
* `console.show()`, which allocates the console-overlay `OptimizedBuffer` —
* needing ANOTHER slot — so the handler itself throws `Failed to create
* optimized buffer: WxH` and Node dies with exit 7 (fatal error in the
* uncaughtException handler), MASKING the real error. (The exception-handler
* guard lives in renderer.ts `guardRendererErrorHandlers`.)
*
* Why we can't just SHARE one SyntaxStyle across renderables (the obvious
* 3→2 fix): the per-buffer style is load-bearing. The native styled-text path
* (text-buffer.zig `setStyledText`) registers each chunk's color by NAME —
* "chunk0", "chunk1", … — into the buffer's OWN syntax style, and
* registration is name-keyed-overwrite (syntax-style.zig `putStyle`: existing
* name → overwrite that id's definition). A shared style would have every
* styled `<text>` overwrite every other one's chunk colors (live highlights
* reference style IDS, re-resolved at render). So pooling is unsound at our
* layer; the table pressure itself is bounded by the store row cap
* (logic/store.ts, clamped to a handle-safe ceiling) until #27 lands
* renderable-weight-aware capping/virtualization.
*
* What THIS shim does: makes style allocation failure DEGRADE instead of
* throwing out of mount/render. `SyntaxStyle.create()` on a full table
* returns a DETACHED style (handle 0 = the native INVALID_HANDLE):
* - JS-side styling still works — markdown/code chunk colors come from
* `getStyle`/`mergeStyles`, which read the instance's JS `styleDefs` map
* (see core lib/tree-sitter-styled-text.ts), never the native handle;
* - every native call on handle 0 is already a safe no-op in zig (acquire
* fails → early return), and `textBuffer.setSyntaxStyle(detached)` passes
* ptr 0 which the native side treats as "no style" — buffer-level styled
* -text highlights are skipped, i.e. that text renders unstyled;
* - `destroy()` on a detached style is a native no-op (beginDestroy(0)).
*
* TODO(upstream): file an OpenTUI issue — (a) a global 64k handle table with a
* 3-slot cost per text renderable is too small for transcript-style TUIs;
* (b) allocation failure throws out of the render loop with no degrade path;
* (c) `handleError` allocates (console overlay) and so crashes on the very
* condition it is reporting, masking the root cause with exit 7.
*/
import { SyntaxStyle, resolveRenderLib, type SyntaxStyleHandle } from '@opentui/core'
import { getLog } from './log.ts'
/** The native side's INVALID_HANDLE — every FFI entry point no-ops on it. */
const DETACHED: SyntaxStyleHandle = 0 as never
let installed = false
let warnedExhausted = false
/** Build a SyntaxStyle backed by NO native handle: JS-side styleDefs/merge
* caches fully functional, all native calls safe no-ops (handle 0). */
function detachedSyntaxStyle(): SyntaxStyle {
return new SyntaxStyle(resolveRenderLib(), DETACHED)
}
/**
* Patch `SyntaxStyle.create` (the static the core's own TextBufferRenderable
* constructor calls — @opentui/core is external, one shared class object) so
* native handle-table exhaustion degrades to a detached, unstyled-but-inert
* style instead of throwing out of a Solid mount effect. Idempotent.
*
* @param factory test seam — inject a failing allocator to exercise the
* degrade path (defaults to the real `SyntaxStyle.create`).
*/
export function installSyntaxStyleDegrade(factory?: () => SyntaxStyle): void {
if (installed) return
installed = true
const origCreate = factory ?? SyntaxStyle.create.bind(SyntaxStyle)
SyntaxStyle.create = function create(): SyntaxStyle {
try {
return origCreate()
} catch (cause) {
if (!warnedExhausted) {
warnedExhausted = true
try {
getLog().error(
'native',
'SyntaxStyle allocation failed — native handle table exhausted; degrading to unstyled',
{
cause: String(cause)
}
)
} catch {
// logging is best-effort inside a degrade path
}
}
return detachedSyntaxStyle()
}
}
}