From 31916539af5cf09d913cb292e41cef3af2592f34 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 11 Jun 2026 04:01:57 +0530 Subject: [PATCH] opentui(v6): degrade SyntaxStyle exhaustion, unmask the exit-7 crash, clamp the cap to the 65k native handle table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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). --- ui-opentui/.gitignore | 1 + ui-opentui/eslint.config.mjs | 4 +- ui-opentui/scripts/fixture.ts | 11 +-- ui-opentui/src/boundary/nativeHandles.ts | 110 +++++++++++++++++++++ ui-opentui/src/boundary/renderer.ts | 80 ++++++++++++++- ui-opentui/src/logic/store.ts | 39 ++++++-- ui-opentui/src/test/nativeHandles.test.tsx | 109 ++++++++++++++++++++ ui-opentui/src/test/store.test.ts | 27 ++++- 8 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 ui-opentui/src/boundary/nativeHandles.ts create mode 100644 ui-opentui/src/test/nativeHandles.test.tsx diff --git a/ui-opentui/.gitignore b/ui-opentui/.gitignore index ee97323c01f..91055e5fe63 100644 --- a/ui-opentui/.gitignore +++ b/ui-opentui/.gitignore @@ -7,3 +7,4 @@ bun.lockb # the global ~/.gitignore_global `lib/` rule swallows our test harness — re-include it !src/test/lib/ +.bench/ diff --git a/ui-opentui/eslint.config.mjs b/ui-opentui/eslint.config.mjs index 537dbf44cff..cfe89bf821b 100644 --- a/ui-opentui/eslint.config.mjs +++ b/ui-opentui/eslint.config.mjs @@ -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, diff --git a/ui-opentui/scripts/fixture.ts b/ui-opentui/scripts/fixture.ts index 81c2bf853b3..26300164be2 100644 --- a/ui-opentui/scripts/fixture.ts +++ b/ui-opentui/scripts/fixture.ts @@ -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) diff --git a/ui-opentui/src/boundary/nativeHandles.ts b/ui-opentui/src/boundary/nativeHandles.ts new file mode 100644 index 00000000000..6e30045d025 --- /dev/null +++ b/ui-opentui/src/boundary/nativeHandles.ts @@ -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 `` 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() + } + } +} diff --git a/ui-opentui/src/boundary/renderer.ts b/ui-opentui/src/boundary/renderer.ts index 807e4ad02be..f73c107e5a9 100644 --- a/ui-opentui/src/boundary/renderer.ts +++ b/ui-opentui/src/boundary/renderer.ts @@ -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 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> { + 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> +): 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) + } + } +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 6ea9bb4bcd1..9a8326438ab 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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 `` 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() { // `` 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({ diff --git a/ui-opentui/src/test/nativeHandles.test.tsx b/ui-opentui/src/test/nativeHandles.test.tsx new file mode 100644 index 00000000000..2b4b8dd0b81 --- /dev/null +++ b/ui-opentui/src/test/nativeHandles.test.tsx @@ -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( + () => ( + store.state.theme}> + + + ), + { 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) +}) diff --git a/ui-opentui/src/test/store.test.ts b/ui-opentui/src/test/store.test.ts index 454d2753276..a7cfad7f6f1 100644 --- a/ui-opentui/src/test/store.test.ts +++ b/ui-opentui/src/test/store.test.ts @@ -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')