mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
79d1b58afe
commit
31916539af
8 changed files with 359 additions and 22 deletions
1
ui-opentui/.gitignore
vendored
1
ui-opentui/.gitignore
vendored
|
|
@ -7,3 +7,4 @@ bun.lockb
|
|||
|
||||
# the global ~/.gitignore_global `lib/` rule swallows our test harness — re-include it
|
||||
!src/test/lib/
|
||||
.bench/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import unusedImports from "eslint-plugin-unused-imports"
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", ".repos/**", "*.frame.txt", "*.ansi"],
|
||||
// .bench/ is a build artifact of the bench suite's `nodes` cell
|
||||
// (bench/run.mjs builds scripts/mem-bench.tsx into it) — never lint it.
|
||||
ignores: ["node_modules/**", "dist/**", ".bench/**", ".repos/**", "*.frame.txt", "*.ansi"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
110
ui-opentui/src/boundary/nativeHandles.ts
Normal file
110
ui-opentui/src/boundary/nativeHandles.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,15 @@ import { Deferred, Effect } from 'effect'
|
|||
import { RendererError } from './errors.ts'
|
||||
import { installFfiCoordSafety } from './ffiSafe.ts'
|
||||
import { getLog } from './log.ts'
|
||||
import { installSyntaxStyleDegrade } from './nativeHandles.ts'
|
||||
|
||||
// Node-FFI seam: clamp negative draw coordinates BEFORE the u32 FFI marshaling
|
||||
// (see ffiSafe.ts — scrolled-out <diff> line backgrounds crashed the render loop).
|
||||
installFfiCoordSafety()
|
||||
// Native handle-table seam: SyntaxStyle allocation failure (global 65,534-slot
|
||||
// registry exhausted) degrades to an unstyled detached style instead of throwing
|
||||
// out of a Solid mount effect (see nativeHandles.ts for the full root cause).
|
||||
installSyntaxStyleDegrade()
|
||||
|
||||
/**
|
||||
* The text a finished selection copies: the RENDERED text the user highlighted,
|
||||
|
|
@ -64,8 +69,11 @@ export interface RendererOptions {
|
|||
export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: RendererOptions) {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
createCliRenderer({
|
||||
try: async () => {
|
||||
// Snapshot process error listeners so we can guard exactly the ones the
|
||||
// renderer installs (its `handleError` — see guardRendererErrorHandlers).
|
||||
const preexisting = snapshotErrorListeners()
|
||||
const created = await createCliRenderer({
|
||||
// Root canvas: TRANSPARENT by default — the terminal's own background
|
||||
// shows through (do not paint a "default dark" canvas; glitch hated
|
||||
// it). A skin's explicit ui_bg lands reactively via the header's
|
||||
|
|
@ -83,7 +91,10 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options:
|
|||
exitSignals: ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'],
|
||||
useKittyKeyboard: {},
|
||||
useMouse: options.mouse
|
||||
}),
|
||||
})
|
||||
guardRendererErrorHandlers(created, preexisting)
|
||||
return created
|
||||
},
|
||||
catch: cause => new RendererError({ cause })
|
||||
}),
|
||||
renderer => Effect.sync(() => destroyRenderer(renderer))
|
||||
|
|
@ -145,3 +156,66 @@ function destroyRenderer(renderer: CliRenderer): void {
|
|||
// teardown is best-effort; a failed destroy must not mask the real exit cause.
|
||||
}
|
||||
}
|
||||
|
||||
// ── honest-crash guard for the renderer's process error handlers ─────────────
|
||||
//
|
||||
// CliRenderer installs its own `uncaughtException`/`unhandledRejection` handler
|
||||
// (`handleError`: console.error + console.show()). `console.show()` ALLOCATES —
|
||||
// the console-overlay OptimizedBuffer needs a native handle — so under native
|
||||
// handle-table exhaustion (the very condition being reported, see
|
||||
// nativeHandles.ts) the handler itself throws `Failed to create optimized
|
||||
// buffer: WxH`, and Node kills the process with exit 7, MASKING the original
|
||||
// error (this is exactly the bench mem3000 postmortem). Wrap the listeners the
|
||||
// renderer added so a handler failure is logged honestly and the original
|
||||
// error stays the story; while the renderer is alive the process keeps running
|
||||
// (core's own contract: handled uncaught exceptions don't exit).
|
||||
|
||||
type ProcessErrorEvent = 'uncaughtException' | 'unhandledRejection'
|
||||
const PROCESS_ERROR_EVENTS: readonly ProcessErrorEvent[] = ['uncaughtException', 'unhandledRejection']
|
||||
type ErrorListener = (...args: unknown[]) => void
|
||||
|
||||
// Node's typings don't accept the union event name in listeners/on/removeListener
|
||||
// overloads — view the process emitter through a minimal untyped seam.
|
||||
const proc = process as unknown as {
|
||||
listeners(event: ProcessErrorEvent): ErrorListener[]
|
||||
on(event: ProcessErrorEvent, listener: ErrorListener): void
|
||||
removeListener(event: ProcessErrorEvent, listener: ErrorListener): void
|
||||
}
|
||||
|
||||
function snapshotErrorListeners(): ReadonlyMap<ProcessErrorEvent, ReadonlySet<unknown>> {
|
||||
return new Map(PROCESS_ERROR_EVENTS.map(event => [event, new Set(proc.listeners(event))]))
|
||||
}
|
||||
|
||||
/** Re-wrap the error listeners `createCliRenderer` added (delta vs the snapshot)
|
||||
* so an exception INSIDE them can never exit-7-mask the original error. */
|
||||
function guardRendererErrorHandlers(
|
||||
renderer: CliRenderer,
|
||||
preexisting: ReadonlyMap<ProcessErrorEvent, ReadonlySet<unknown>>
|
||||
): void {
|
||||
for (const event of PROCESS_ERROR_EVENTS) {
|
||||
const before = preexisting.get(event)
|
||||
for (const listener of proc.listeners(event)) {
|
||||
if (before?.has(listener)) continue
|
||||
proc.removeListener(event, listener)
|
||||
const guarded: ErrorListener = (...args) => {
|
||||
// After teardown the renderer can no longer report anything — rethrow so
|
||||
// the ORIGINAL error reaches Node's default fatal path unmasked.
|
||||
if (renderer.isDestroyed) throw args[0]
|
||||
try {
|
||||
listener(...args)
|
||||
} catch (handlerFailure) {
|
||||
try {
|
||||
const original = args[0]
|
||||
getLog().error('renderer', 'core error handler crashed while reporting an uncaught error', {
|
||||
original: original instanceof Error ? (original.stack ?? original.message) : String(original),
|
||||
handlerFailure: String(handlerFailure)
|
||||
})
|
||||
} catch {
|
||||
// logging is best-effort — never throw out of an exception handler.
|
||||
}
|
||||
}
|
||||
}
|
||||
proc.on(event, guarded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,14 @@ function subagentStatusFor(type: string): string {
|
|||
return 'running'
|
||||
}
|
||||
|
||||
export function createSessionStore() {
|
||||
export interface SessionStoreOptions {
|
||||
/** Fixture/diagnostic harnesses ONLY (scripts/fixture.ts `materialize`): bypass
|
||||
* the handle-safe cap for a store that is never mounted into a renderer.
|
||||
* Production stores must stay clamped — see HANDLE_SAFE_MAX_ROWS below. */
|
||||
readonly uncappedFixture?: boolean
|
||||
}
|
||||
|
||||
export function createSessionStore(options?: SessionStoreOptions) {
|
||||
// Rolling cap on retained transcript rows. OpenTUI lays out via Yoga (WASM), whose
|
||||
// linear memory is grow-only — every live `<For>` row is a Yoga-node subtree, so an
|
||||
// uncapped `messages[]` ratchets the high-water mark up over a long session and never
|
||||
|
|
@ -391,16 +398,30 @@ export function createSessionStore() {
|
|||
// `<For>` UNMOUNT exactly the evicted oldest rows → `Renderable.destroy()` →
|
||||
// `yogaNode.free()`, returning those nodes to the WASM allocator's free list.
|
||||
//
|
||||
// Default 3000 (≈1500 turns of scrollback): the highest cap whose steady-state RSS
|
||||
// stays within a sane TUI budget on the realistic-fixture bench (~20.4 renderables/
|
||||
// msg, ~0.65 MB/msg → ~2 GB at 3000 — and that ceiling is only reached by marathon
|
||||
// 3000+-message sessions; typical sessions cost a fraction). opencode caps at 100;
|
||||
// we trade memory for far more in-TUI scrollback (the dashboard holds the rest).
|
||||
// Read once per store from `HERMES_TUI_MAX_MESSAGES`. Turns trimmed beyond the cap
|
||||
// aren't lost — they live on the gateway and are recoverable via `/resume`.
|
||||
// The BINDING limit is NOT memory, it's the native handle table: @opentui/core
|
||||
// indexes every native object through ONE global 65,534-slot registry
|
||||
// (zig/handles.zig, 16-bit slot indices), and every text-bearing renderable
|
||||
// burns THREE slots (TextBuffer + TextBufferView + SyntaxStyle —
|
||||
// TextBufferRenderable.ts:77-80). The bench fixture measured ~47 handles/row
|
||||
// (~16 text renderables/row), so the table exhausts at ≈1,400 LIVE rows with
|
||||
// an uncaught "Failed to create SyntaxStyle" mid-mount (crash anatomy +
|
||||
// degrade shim: boundary/nativeHandles.ts). The previous default of 3000 was
|
||||
// therefore unreachable — the TUI crashed before the cap ever bound.
|
||||
//
|
||||
// HANDLE_SAFE_MAX_ROWS = 1000 ≈ 47k handles ≈ 72% of the table on the
|
||||
// realistic-fixture mix, leaving ~18k slots of headroom for chrome
|
||||
// (composer/pickers/dashboard) and heavier-than-fixture rows. Pathological
|
||||
// rows can still exceed it — renderable-weight-aware capping belongs to the
|
||||
// virtualization work (#27); until then nativeHandles.ts degrades (unstyled
|
||||
// text) instead of crashing. `HERMES_TUI_MAX_MESSAGES` can LOWER the cap but
|
||||
// never raise it past the ceiling. Read once per store. Trimmed turns aren't
|
||||
// lost — they live on the gateway and are recoverable via `/resume`.
|
||||
const HANDLE_SAFE_MAX_ROWS = 1000
|
||||
const MESSAGE_CAP = (() => {
|
||||
if (options?.uncappedFixture) return Number.MAX_SAFE_INTEGER
|
||||
const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 3000
|
||||
const requested = Number.isFinite(raw) && raw > 0 ? raw : HANDLE_SAFE_MAX_ROWS
|
||||
return Math.min(requested, HANDLE_SAFE_MAX_ROWS)
|
||||
})()
|
||||
|
||||
const [state, setState] = createStore<StoreState>({
|
||||
|
|
|
|||
109
ui-opentui/src/test/nativeHandles.test.tsx
Normal file
109
ui-opentui/src/test/nativeHandles.test.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Degrade path for native handle-table exhaustion (boundary/nativeHandles.ts).
|
||||
*
|
||||
* @opentui/core 0.4.0 routes every native object through ONE global
|
||||
* 65,534-slot handle registry; each TextBufferRenderable allocates a
|
||||
* SyntaxStyle (+ TextBuffer + TextBufferView) in its constructor, so a long
|
||||
* mount-everything transcript exhausts the table and `SyntaxStyle.create()`
|
||||
* throws `Failed to create SyntaxStyle` out of a Solid mount effect —
|
||||
* uncaught, then MASKED by the renderer's own error handler crashing while
|
||||
* allocating its console-overlay buffer (Node exit 7). The shim makes style
|
||||
* allocation DEGRADE: a detached style (native handle 0) whose JS-side style
|
||||
* definitions still work and whose native calls are inert no-ops, so text
|
||||
* still mounts and renders — merely unstyled at the buffer level.
|
||||
*
|
||||
* The failing factory injected here simulates the exhausted table for EVERY
|
||||
* style created in this fork (vitest isolates files per process, so the
|
||||
* global patch cannot leak into other suites).
|
||||
*/
|
||||
import { SyntaxStyle, TextBuffer } from '@opentui/core'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { installSyntaxStyleDegrade } from '../boundary/nativeHandles.ts'
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { renderProbe } from './lib/render.ts'
|
||||
|
||||
// Simulate the exhausted handle table: the exact error zig.ts:4554 throws.
|
||||
installSyntaxStyleDegrade(() => {
|
||||
throw new Error('Failed to create SyntaxStyle')
|
||||
})
|
||||
|
||||
describe('native handle exhaustion degrades instead of crashing (boundary/nativeHandles.ts)', () => {
|
||||
test('SyntaxStyle.create() under allocation failure returns a detached, inert style — never throws', () => {
|
||||
let style!: SyntaxStyle
|
||||
expect(() => {
|
||||
style = SyntaxStyle.create()
|
||||
}).not.toThrow()
|
||||
|
||||
// detached = native INVALID_HANDLE (0): every native call no-ops safely
|
||||
expect(Number(style.ptr)).toBe(0)
|
||||
expect(() => style.resolveStyleId('keyword')).not.toThrow()
|
||||
expect(style.getStyleCount()).toBe(0)
|
||||
|
||||
// JS-side style definitions still work — markdown/code chunk colors are
|
||||
// computed from styleDefs/mergeStyles in JS, not the native handle.
|
||||
expect(() => style.registerStyle('keyword', { fg: '#ff0000', bold: true })).not.toThrow()
|
||||
expect(style.getStyle('keyword')).toMatchObject({ bold: true })
|
||||
expect(style.mergeStyles('keyword').fg).toBeDefined()
|
||||
|
||||
expect(() => style.destroy()).not.toThrow()
|
||||
})
|
||||
|
||||
test('a text buffer accepts a detached style (native treats handle 0 as "no style")', () => {
|
||||
const style = SyntaxStyle.create()
|
||||
const buffer = TextBuffer.create('unicode')
|
||||
try {
|
||||
expect(() => buffer.setSyntaxStyle(style)).not.toThrow()
|
||||
expect(() => buffer.setSyntaxStyle(null)).not.toThrow()
|
||||
} finally {
|
||||
buffer.destroy()
|
||||
style.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('the transcript still mounts and renders text when every style allocation fails', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.pushUser('does styled text survive handle exhaustion?')
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal', context: 'echo degraded' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: 't1',
|
||||
name: 'terminal',
|
||||
args: { command: 'echo degraded' },
|
||||
duration_s: 0.1,
|
||||
result: 'degraded but alive'
|
||||
}
|
||||
})
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const errors: unknown[] = []
|
||||
const onErr = (e: unknown) => errors.push(e)
|
||||
process.on('uncaughtException', onErr)
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 30 }
|
||||
)
|
||||
try {
|
||||
// Every TextBufferRenderable in this tree got a detached SyntaxStyle —
|
||||
// the content must still be there (unstyled is fine; absent/crashed is not).
|
||||
// (Assistant MARKDOWN paint is not assertable headlessly — tree-sitter
|
||||
// doesn't settle in the test renderer, see render.test.tsx — so assert
|
||||
// the plain-text user row and the styled tool row.)
|
||||
const frame = await probe.waitForFrame(f => f.includes('terminal'))
|
||||
expect(frame).toContain('does styled text survive handle exhaustion?')
|
||||
expect(errors).toEqual([])
|
||||
} finally {
|
||||
process.off('uncaughtException', onErr)
|
||||
probe.destroy()
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
|
|
@ -578,14 +578,35 @@ describe('session store — rolling message cap (bounds the Yoga node high-water
|
|||
expect(store.state.messages.at(-1)!.text).toBe('h7')
|
||||
})
|
||||
|
||||
test('defaults to 3000 when the env var is unset/invalid', () => {
|
||||
test('defaults to 1000 (the handle-safe ceiling) when the env var is unset/invalid', () => {
|
||||
delete process.env[ENV_KEY]
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 3050; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(3000)
|
||||
for (let i = 0; i < 1050; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(1000)
|
||||
expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped
|
||||
})
|
||||
|
||||
test('env values ABOVE the handle-safe ceiling are clamped to it (the native handle table binds, not memory)', () => {
|
||||
// @opentui/core's global handle registry holds 65,534 live objects and a
|
||||
// text renderable costs 3; ~47 handles/row on the realistic fixture means
|
||||
// ≳1,400 live rows crashes mid-mount ("Failed to create SyntaxStyle").
|
||||
// A 100000 "cap" is therefore a crash sentence, not a cap — clamp it.
|
||||
process.env[ENV_KEY] = '100000'
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 1100; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(1000)
|
||||
expect(store.state.dropped).toBe(100)
|
||||
expect(store.state.messages[0]!.text).toBe('m100')
|
||||
})
|
||||
|
||||
test('uncappedFixture bypasses the clamp (fixture materialization — store never mounted)', () => {
|
||||
delete process.env[ENV_KEY]
|
||||
const store = createSessionStore({ uncappedFixture: true })
|
||||
for (let i = 0; i < 1100; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(1100)
|
||||
expect(store.state.dropped).toBe(0)
|
||||
})
|
||||
|
||||
test('clearTranscript empties messages AND the applied dedup set', () => {
|
||||
const store = createSessionStore()
|
||||
store.pushUser('x')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue