Repository navigation
perf(renderer): LRU-bound the native-chat composer scope caches - #7566
Conversation
draftCache (native-chat-draft-cache.ts) and attachmentCache (use-native-chat-composer-attachments.ts) are module-level maps keyed by targetPtyId ?? terminalTabId. They exist so an in-progress message survives the composer unmounting on a TUI/GUI toggle, but a scope key for a permanently removed pane is never revisited, so an unsent draft/attachment lingered for the renderer's whole session. Add a shared setBoundedScopeCacheEntry helper (delete-then-set LRU, cap 128) and route both write paths through it. A bound rather than removal-purge keeps the fix self-contained in the native-chat layer (no store -> component coupling). Co-authored-by: Orca <help@stably.ai>
📝 WalkthroughWalkthroughChangesThis PR introduces a shared Sequence Diagram(s)Not applicable; changes involve synchronous Map operations without cross-component request/response flows. Estimated code review effort: 2/5 (Low-Medium) Related PRs: None specified. Suggested labels: enhancement, performance, tests Suggested reviewers: None specified. 🐰 A cache that once grew without a bound, 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/renderer/src/components/native-chat/native-chat-composer-scope-cache.ts (1)
1-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTrim the header comment to comply with the brevity guideline.
This comment spans 7 lines; the guideline for
**/*.{ts,tsx,js,jsx}requires "why" comments to stay to one or two lines.✏️ Suggested trim
-// Shared LRU bound for the native-chat composer's per-scope caches (draft text -// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The -// caches exist so an in-progress message survives the composer unmounting on a -// TUI/GUI toggle, but a scope key for a permanently-removed pane is never -// revisited, so without a bound its unsent entry would linger for the renderer's -// whole session. delete-then-set keeps the actively-edited scope most-recent so -// eviction only sheds the oldest untouched scopes. +// Bounds per-scope caches (keyed by targetPtyId ?? terminalTabId) so unsent +// state for permanently-removed panes doesn't linger for the whole session.As per coding guidelines, "Keep comments short — one or two lines, capturing only the non-obvious reason."
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 9a11e626-a01d-4ee3-8da6-28cb065cbf2c
📒 Files selected for processing (5)
src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.tssrc/renderer/src/components/native-chat/native-chat-composer-scope-cache.tssrc/renderer/src/components/native-chat/native-chat-draft-cache.test.tssrc/renderer/src/components/native-chat/native-chat-draft-cache.tssrc/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts
…e caches Two module-level caches in native-chat-pending.ts capped their per-key arrays (8 entries) but never bounded the KEY count: - commandMarkerCache — keyed by paneKey\0agent\0sessionId; sessionId changes on every /clear and paneKey embeds an ephemeral leafId, so a distinct key was stranded per (pane, session) for the renderer's whole life. Only test-only clear() ever removed keys. - pendingSendCache — self-cleans on the normal empty-drain path, but a pane closed with an unconfirmed send (agent crash / early close) left its non-empty entry keyed forever. Route both writes through the existing setBoundedScopeCacheEntry LRU helper (cap 128) that #7566 already applied to the draft/attachment caches in the same folder — same pattern, same file family, was just missed here. Values are tiny so this is a slow leak, but real and unbounded. Tests (red->green): appending 133 distinct scopes evicts the oldest and keeps the most-recent 128; without the cap all 133 survive.
…e caches (#7643) Two module-level caches in native-chat-pending.ts capped their per-key arrays (8 entries) but never bounded the KEY count: - commandMarkerCache — keyed by paneKey\0agent\0sessionId; sessionId changes on every /clear and paneKey embeds an ephemeral leafId, so a distinct key was stranded per (pane, session) for the renderer's whole life. Only test-only clear() ever removed keys. - pendingSendCache — self-cleans on the normal empty-drain path, but a pane closed with an unconfirmed send (agent crash / early close) left its non-empty entry keyed forever. Route both writes through the existing setBoundedScopeCacheEntry LRU helper (cap 128) that #7566 already applied to the draft/attachment caches in the same folder — same pattern, same file family, was just missed here. Values are tiny so this is a slow leak, but real and unbounded. Tests (red->green): appending 133 distinct scopes evicts the oldest and keeps the most-recent 128; without the cap all 133 survive.
…lyai#7566) Co-authored-by: Orca <help@stably.ai>
…e caches (stablyai#7643) Two module-level caches in native-chat-pending.ts capped their per-key arrays (8 entries) but never bounded the KEY count: - commandMarkerCache — keyed by paneKey\0agent\0sessionId; sessionId changes on every /clear and paneKey embeds an ephemeral leafId, so a distinct key was stranded per (pane, session) for the renderer's whole life. Only test-only clear() ever removed keys. - pendingSendCache — self-cleans on the normal empty-drain path, but a pane closed with an unconfirmed send (agent crash / early close) left its non-empty entry keyed forever. Route both writes through the existing setBoundedScopeCacheEntry LRU helper (cap 128) that stablyai#7566 already applied to the draft/attachment caches in the same folder — same pattern, same file family, was just missed here. Values are tiny so this is a slow leak, but real and unbounded. Tests (red->green): appending 133 distinct scopes evicts the oldest and keeps the most-recent 128; without the cap all 133 survive.
Part of a full-codebase memory-leak audit — final PR (2 leaks, same subsystem/mechanism).
Description
The native-chat composer keeps two module-level caches so a typed-but-unsent message survives the composer unmounting on a TUI/GUI toggle:
draftCache(native-chat-draft-cache.ts) — the in-progress draft text.attachmentCache(use-native-chat-composer-attachments.ts) — pending image-attachment paths.Both are keyed by
targetPtyId ?? terminalTabId. When a pane/tab is permanently removed while holding an unsent draft/attachment, its scope key is never revisited, so the entry lingered for the renderer's whole session (keys are never reused).Fix: a shared
setBoundedScopeCacheEntryhelper (delete-then-set LRU, cap 128) that both write paths route through.Evidence
native-chat-draft-cache.ts:writeNativeChatDraftCacheonly.deleted on empty draft, else.set()— no bound.use-native-chat-composer-attachments.ts:writeNativeChatAttachmentCachesame shape.targetPtyId ?? terminalTabIdis a fresh, never-reused id, so a removed pane's entry is unreachable and permanent.Proof of fix
native-chat-composer-scope-cache.test.ts: the shared helper caps at the limit, evicts the oldest untouched key, keeps re-set keys, and moves a re-set key to most-recent.Typecheck (web) + oxlint clean.
ELI5
When you start typing a message to an agent but flip back to the terminal, the app stashes your half-written note so it's still there when you flip back. If you closed that pane for good, the stashed note was kept forever. Now the stash holds at most ~128 notes and forgets the oldest untouched ones — and the note you're actively typing is always kept.
Trade-offs
delete-then-setkeeps the pane you're actively typing in most-recent so it's never the one evicted.store → components/native-chatimports exist). The bound is self-contained in the native-chat layer and matches the codebase's existing FIFO/LRU convention (rememberUnwatchableRoot).Made with Orca 🐋