Skip to content

fix(ui/text): stop idle 300ms re-render loop that could OOM the TUI#8616

Merged
alexhancock merged 1 commit into
aaif-goose:mainfrom
michaelneale:fix/tui-idle-rerender-oom
Apr 17, 2026
Merged

fix(ui/text): stop idle 300ms re-render loop that could OOM the TUI#8616
alexhancock merged 1 commit into
aaif-goose:mainfrom
michaelneale:fix/tui-idle-rerender-oom

Conversation

@michaelneale
Copy link
Copy Markdown
Collaborator

Problem

The Ink-based text UI (ui/text) could crash with a JS heap OOM after running for a while, even when it appeared idle:

FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory

The native stack showed the crash happening inside a libuv check tick (uv__run_checkBuiltins_JSEntryTrampoline), i.e. a JS timer callback — which pointed at the 300ms animation interval in App.

Root cause

ui/text/src/tui.tsx had this at the top of App:

useEffect(() => {
  const t = setInterval(() => {
    setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length);
    setGooseFrame((f) => f + 1);   // never wrapped
  }, 300);
  return () => clearInterval(t);
}, []);

Two problems compound:

  1. gooseFrame was incremented without modulo, so state changed every tick even when the splash goose wasn't being shown.
  2. The interval ran for the entire lifetime of the process regardless of whether anything was actually animating. That forced a full re-render of App every 300ms forever.

Each of those re-renders called buildContentLines(...), which walks every turn's responseItems and runs marked + marked-terminal over every streamed content_chunk from scratch, allocating thousands of fresh Ink/Yoga elements per tick.

In a long-running session with a substantial transcript this is roughly O(total_chars_streamed) of markdown parsing ~3× per second, indefinitely. Young-gen fills, promotions pile up, mark-compact can't keep up, and V8 eventually gives up at the 4GB heap cap.

This affects real npx @aaif/goose users, not just dev — dev just hits it faster because of tsx transpile overhead on top.

Fix

  1. Only tick when something is animating. The animation useEffect now bails unless bannerVisible (splash goose) or loading (spinner) is true. Once the user dismisses the banner and is sitting at the prompt, the interval is cleared entirely and the UI stops re-rendering on its own.
  2. Wrap gooseFrame with modulo GOOSE_FRAMES.length so state doesn't change when the banner isn't visible, and doesn't drift monotonically when it is.
  3. Memoize buildContentLines via useMemo so any re-renders that do happen (e.g. window resize, new chunk) don't needlessly re-parse markdown for historical turns.

Verification

  • tsc --noEmit (the package lint script) passes.
  • Manually: leaving the TUI open at the prompt no longer grows RSS over time; previously it climbed steadily.

Notes

I also wrote up a longer dev-experience audit of ui/text (README drift, stderr: 'ignore' swallowing backend errors in dev, missing watch mode, etc.). Happy to do those as a follow-up if useful — this PR is scoped to the OOM fix.

The TUI had a 300ms setInterval in App that unconditionally incremented
gooseFrame (never wrapped) and spinIdx, forcing a full re-render of the
entire viewport every tick for the lifetime of the process. Each re-render
called buildContentLines() which re-ran marked + marked-terminal over every
streamed content_chunk in every turn, allocating thousands of fresh Ink/Yoga
elements per tick.

In long-running sessions this produced enough allocation pressure to push V8
over the 4GB heap limit, crashing with 'Ineffective mark-compacts near heap
limit' even when the UI appeared idle. The crash reproduced in the 300ms
timer callback (uv__run_check -> JSEntryTrampoline).

Fixes:

1. Only run the animation interval while the splash banner is visible or
   a turn is loading. Once the banner is dismissed and we're sitting at the
   prompt, there's nothing to animate, so the tick stops entirely.
2. Wrap gooseFrame increments with modulo GOOSE_FRAMES.length (was unbounded).
3. Memoize buildContentLines via useMemo so any re-renders that do happen
   don't needlessly re-parse markdown for historical turns.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
@alexhancock alexhancock added this pull request to the merge queue Apr 17, 2026
Merged via the queue into aaif-goose:main with commit c9536d7 Apr 17, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants