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:
alt-glitch 2026-06-11 04:01:57 +05:30
parent 79d1b58afe
commit 31916539af
8 changed files with 359 additions and 22 deletions

View file

@ -7,3 +7,4 @@ bun.lockb
# the global ~/.gitignore_global `lib/` rule swallows our test harness — re-include it
!src/test/lib/
.bench/

View file

@ -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,

View file

@ -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)

View 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
* 32 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()
}
}
}

View file

@ -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)
}
}
}

View file

@ -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>({

View 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)
})

View file

@ -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')