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:
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).
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:
54c4020d6 — perf(tui/message): cache Render output to avoid redundant markdown parses (May 12)
eddc8fb20 — perf(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
Summary
On long-running TUI sessions,
docker-agentretains 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'smemorystatus(jetsam) subsystem.Reproduction
Synthetic harness in
pkg/tui/components/messages/leak_test.gostreams 200 assistant messages of 8 KiB each (alternating prose and ```go code blocks) throughAddAssistantMessage/ `AppendToLastMessage`, calling `View()` between chunks.Slope: +161 KB live / message, perfectly linear.
Symptom on macOS
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:renderCache.result— full styled ANSI output ofView(), populated on first render and only invalidated when an input changes (never released after the stream completes).mdRenderer *markdown.IncrementalRenderer— holdsinputPrefix(raw markdown),outputPrefix(rendered ANSI), andcodeBlocksPrefixfor the entire message.400
messageModelinstances × ~80–120 KB of retained strings each matches the observed slope.m.renderedItemsLRU(500) and global syntax-highlight caches are bounded and not contributors.Regression window
The retention paths were introduced by these perf commits:
54c4020d6—perf(tui/message): cache Render output to avoid redundant markdown parses(May 12)eddc8fb20—perf(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*messageModelthat dropsrenderCacheand resets+nilsmdRenderer, called on the previous assistant view when a new message is appended (and on all loaded views inLoadFromSession). Re-renders triggered later by scroll/hover lazily re-populate, paying one full markdown parse per re-visit instead of holding the result forever. TherenderedItemsLRU absorbs most of the re-render cost.Repro environment
docker-agentHEAD as of May 20, 2026