Skip to content

[Bug]: WebKit renderer memory grows to 2.5 GB over 24h+ runtime — tool outputs fully rendered with no eviction #553

@raz123

Description

@raz123

Description

The WebKit WebContent renderer process (WKWebView) grows to 2.5 GB physical footprint after ~1.3 days of continuous runtime. This is a memory accumulation bug, not a crash-triggering leak — it degrades system performance over time as the process is paged out.

Physical footprint peak observed: 3.1 GB, stable at 2.5 GB before investigation.

Physical footprint:         2.5G
Physical footprint (peak):  3.1G

Live process analysis (vmmap PID 55616, macOS 26.5.1, Tauri variant):

Region Dirty Swapped Physical Cost % of 2.5G
WebKit Malloc (DOM, styles, layers) 183 MB 1,500 MB ~1,683 MB 67%
Graphics buffers (offscreen renders) 79 MB 609 MB ~688 MB 27%
V8/JS heap (DefaultMallocZone + MALLOC_SMALL) 9 MB 21 MB ~30 MB 1%
JS VM Gigacage + JIT 4 MB 16 MB ~20 MB 1%
Everything else 4 MB 54 MB ~58 MB 4%

2.7 million allocations in the WebKit Malloc zone, totaling 5.5 GB allocated.

Environment

  • CodeNomad variant: Tauri
  • OS: macOS 26.5.1 (25F80)
  • Runtime: ~1.3 days (launched 2026-06-13 01:39)
  • Observability: Renderer PID 55616, no remote debugging port open (no --inspect flag)

Root Cause

Three independent issues interact to produce the 2.5 GB:

1. Tool outputs rendered as full DOM with no size limits (P0)

Every tool call's output (bash, write, edit, read, etc.) is rendered as complete DOM nodes when expanded. Default state is expanded for all non-read tools.

stores/preferences.tsx:150 — default preference:

toolOutputExpansion: "expanded"

components/tool-call.tsx:638-641 — output section always expanded:

const outputSectionExpanded = () => {
    const override = outputSectionOverride()
    if (override !== null) return override
    return true  // <-- always expanded
}

components/tool-call.tsx:609-622defaultExpandedForTool returns true for all non-read tools.

components/tool-call/renderers/bash.tsx:60 — no size cap:

return [command, outputResult?.text].filter(Boolean).join("\n")

components/tool-call/renderers/bash.tsx:91-98 — rendered as full innerHTML:

<Show when={finalAnsiHtml()} fallback={...}>
    {(html) => <div innerHTML={html()} />}
</Show>

Message-level virtual scrolling (virtua, 800px overscan) exists, but within each visible message, every tool call's full output is a complete DOM subtree. WebKit's DOM tree + render layers + compositing multiplies text size by ~3-5x. This is why 67% of the physical footprint lives in WebKit Malloc — it's the DOM tree and render layers, not JS heap.

2. Message store never evicts data (P0)

The message store (stores/message-v2/instance-store.ts) accumulates all messages, parts, part data strings, permissions, questions, todos, and scroll state with zero automatic eviction.

instance-store.ts:578-579 — unbounded pending parts buffer:

function bufferPendingPart(entry: PendingPartEntry) {
    setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
}

instance-store.ts:714 — unbounded string concatenation during streaming:

part[input.field] = `${currentValue ?? ""}${input.delta}`

instance-store.ts:1162clearSession() exists but is only called on explicit user deletion.
instance-store.ts:1262clearInstance() only called on instance removal.
There is NO automatic eviction — no LRU, no TTL, no memory-pressure trigger.

3. Session state never cleaned on instance close (P1)

When an instance is stopped, removeInstance() (stores/instances.ts:626-668) cleans 7 stores but misses 15+ per-instance signals in session-state.ts:

function removeInstance(id: string) {
    setInstances(...);          // ✅
    removeLogContainer(id);     // ✅
    clearCommands(id);          // ✅
    clearPermissionQueue(id);   // ✅
    clearRepliedPermissions(id); // ✅
    clearQuestionQueue(id);     // ✅
    clearInstanceMetadata(id);  // ✅
    clearCacheForInstance(id);  // ✅
    messageStoreBus.unregisterInstance(id); // ✅
    clearInstanceDraftPrompts(id);         // ✅
    // ❌ sessions, activeSessionId, activeParentSessionId,
    //    agents, providers, messagesLoaded, messageLoadErrors,
    //    sessionInfoByInstance, threadTotalsByInstance,
    //    expandedSessionParents, sessionPagination, sessionSearch,
    //    instanceIndicatorCounts, loading, sessionThreadCache
}

The sessions signal (stores/session-state.ts:37) is Map<string, Map<string, Session>> keyed by instanceId — when an instance is closed, its Map entry is never deleted. All sessions for that instance persist forever.

Steps to Reproduce

  1. Launch CodeNomad (Tauri or Electron variant)
  2. Create a workspace/session and run several agent conversations with tool calls (bash, file writes, diffs — the larger the output, the faster the growth)
  3. Keep the app running for 24+ hours
  4. Observe renderer memory via Activity Monitor or vmmap:
    vmmap <WebContent PID> -summary

The process will show 2-3 GB physical footprint dominated by WebKit Malloc and graphics memory.

Expected Behavior

  • Renderer memory should stay below ~500 MB under sustained use
  • Sessions not actively viewed should not retain full DOM
  • Closed instances should not retain any session data
  • Old sessions should be evicted from the in-memory message store

Suggested Fixes

P0 — Truncate / virtualize tool output rendering

  1. Change default to collapsed — Show collapsed tool outputs with a "Show output" button, especially for outputs >10 KB
  2. Add size caps on rendered output — Cap bash and other tool output DOM at ~10,000 characters with a "Show full output" expand button
  3. Use IntersectionObserver for lazy DOM — Only create tool output DOM nodes when the tool call is near the viewport

P0 — Automatic message store eviction

  1. Auto-call clearSession() when user navigates away from a session (or after inactivity timeout)
  2. Add per-instance and global memory caps — e.g., 50 MB per instance, 200 MB global, with oldest-session-first eviction
  3. Add periodic memory pressure check — poll performance.memory?.usedJSHeapSize, trigger eviction when over threshold

P1 — Clean session state on instance close

  1. Add purgeInstanceSessions(instanceId) to stores/session-state.ts that clears all 15+ per-instance signal entries when an instance is removed
  2. Call it from removeInstance() in stores/instances.ts

P2 — Streaming delta optimization

  1. Buffer chunks in an array and join at end instead of repeated string concatenation (reduces O(n²) temporary allocations during large streaming responses)

Related Files

  • packages/ui/src/stores/message-v2/instance-store.ts — message store with no eviction
  • packages/ui/src/components/tool-call.tsx — tool call expansion defaults
  • packages/ui/src/components/tool-call/renderers/bash.tsx — no size cap on bash output
  • packages/ui/src/stores/session-state.ts — signals never cleaned on instance close
  • packages/ui/src/stores/instances.tsremoveInstance() missing session cleanup
  • packages/ui/src/stores/preferences.tsx — default toolOutputExpansion: "expanded"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions