Skip to content

tui: per-message render caches leak across long sessions, triggering macOS jetsam kills #2861

@aheritier

Description

@aheritier

Summary

On long-running TUI sessions, docker-agent retains roughly 161 KB of live heap per assistant message, growing linearly without plateau. Multiple concurrent sessions exacerbate the problem. On macOS, sessions are eventually terminated by the kernel's memorystatus (jetsam) subsystem.

Reproduction

Synthetic harness in pkg/tui/components/messages/leak_test.go streams 200 assistant messages of 8 KiB each (alternating prose and ```go code blocks) through AddAssistantMessage / `AppendToLastMessage`, calling `View()` between chunks.

messages HeapInuse (MB) HeapAlloc (MB) objects
0 6.97 4.34 34,807
100 23.81 20.50 42,839
200 33.56 30.10 47,174
400 39.96 36.51 50,068

Slope: +161 KB live / message, perfectly linear.

Symptom on macOS

kernel: [com.apple.xnu:memorystatus] memorystatus: killing largest compressed process docker-agent [<pid>] 390361 MB

Reported by users running multiple long sessions on a 48 GB Mac. The "390361 MB" figure is the kernel's compressed-pages footprint — ANSI output (escape sequences + line padding to terminal width) compresses extremely well, which explains the dramatic gap between live heap and jetsam's reported size.

Root cause

Each *messageModel (pkg/tui/components/message/message.go) retains, for the lifetime of the session:

  1. renderCache.result — full styled ANSI output of View(), populated on first render and only invalidated when an input changes (never released after the stream completes).
  2. mdRenderer *markdown.IncrementalRenderer — holds inputPrefix (raw markdown), outputPrefix (rendered ANSI), and codeBlocksPrefix for the entire message.

400 messageModel instances × ~80–120 KB of retained strings each matches the observed slope. m.renderedItems LRU(500) and global syntax-highlight caches are bounded and not contributors.

Regression window

The retention paths were introduced by these perf commits:

  • 54c4020d6perf(tui/message): cache Render output to avoid redundant markdown parses (May 12)
  • eddc8fb20perf(tui/markdown): incremental renderer for streaming chunks (May 12)

Both correctly invalidate on input change but never release the cached strings once a message is no longer the active streaming target.

Proposed fix

Add a Finalize() method on *messageModel that drops renderCache and resets+nils mdRenderer, called on the previous assistant view when a new message is appended (and on all loaded views in LoadFromSession). Re-renders triggered later by scroll/hover lazily re-populate, paying one full markdown parse per re-visit instead of holding the result forever. The renderedItems LRU absorbs most of the re-render cost.

Repro environment

  • macOS, Apple silicon, 48 GB RAM
  • docker-agent HEAD as of May 20, 2026
  • Multiple concurrent TUI sessions over many hours

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/tuiFor features/issues/fixes related to the TUIeffort:smallIsolated change, clear solution, single areapriority:highMajor impact, should be addressed within 2 daysstatus/needs-triageFor issues that need to be triaged

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions