fix(ui/text): stop idle 300ms re-render loop that could OOM the TUI#8616
Merged
alexhancock merged 1 commit intoApr 17, 2026
Merged
Conversation
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
approved these changes
Apr 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:The native stack showed the crash happening inside a libuv check tick (
uv__run_check→Builtins_JSEntryTrampoline), i.e. a JS timer callback — which pointed at the 300ms animation interval inApp.Root cause
ui/text/src/tui.tsxhad this at the top ofApp:Two problems compound:
gooseFramewas incremented without modulo, so state changed every tick even when the splash goose wasn't being shown.Appevery 300ms forever.Each of those re-renders called
buildContentLines(...), which walks every turn'sresponseItemsand runsmarked+marked-terminalover every streamedcontent_chunkfrom 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/gooseusers, not just dev — dev just hits it faster because oftsxtranspile overhead on top.Fix
useEffectnow bails unlessbannerVisible(splash goose) orloading(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.gooseFramewith moduloGOOSE_FRAMES.lengthso state doesn't change when the banner isn't visible, and doesn't drift monotonically when it is.buildContentLinesviauseMemoso 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 packagelintscript) passes.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.