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).
This commit is contained in:
@@ -261,9 +261,11 @@ export function drive(
|
||||
* path produces (minus the rolling cap), so `commitSnapshot` mounts the real shape.
|
||||
*/
|
||||
export function materialize(total: number): Message[] {
|
||||
const prev = process.env.HERMES_TUI_MAX_MESSAGES
|
||||
process.env.HERMES_TUI_MAX_MESSAGES = String(Number.MAX_SAFE_INTEGER)
|
||||
const store = createSessionStore()
|
||||
// `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
|
||||
@@ -272,9 +274,6 @@ export function materialize(total: number): Message[] {
|
||||
pushed += rowsPerTurn(turn)
|
||||
turn++
|
||||
}
|
||||
// Restore the env so the bench's own cap (read per-store) is unaffected.
|
||||
if (prev === undefined) delete process.env.HERMES_TUI_MAX_MESSAGES
|
||||
else process.env.HERMES_TUI_MAX_MESSAGES = prev
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user