[BUG] Desktop app artifact-preview renderer balloons to 7.6 GB, driving the macOS memory pressure that makes the Claude Code daemon evict background sessions

Status Fixed / completed
Reported on v2.1.199
Maintainer reply None cached
Activity 4 comments · opened Jul 3, 2026 · closed Aug 25, 2026

Environment

  • Claude desktop app: 1.18286.0 (macOS)
  • Claude Code CLI: 2.1.199
  • OS: macOS 26.5.1 (25F80), arm64, 16 GB RAM
  • Heavy claude agents user: ~10–15 concurrent background sessions, ~70 local sessions total on disk

Summary

Two Anthropic products form a self-inflicted feedback loop on the same machine:

  1. A desktop-app renderer ballooned to a 7.65 GB real footprint (mostly compressed by macOS, so it's invisible in ps RSS — only top -o mem / footprint show it). Timeline evidence points at the claude.ai/code artifact HTML-preview window: the renderer spawned within a minute of [Preview] Created Claude page preview for an artifact (a static HTML page of inline SVGs), then sat open ~4.7 h with the app's periodic capturePreviewScreenshotIfChanged running against it.
  2. That pushes kern.memorystatus_vm_pressure_level to 2 (warn).
  3. The Claude Code daemon (claude daemon) treats that as low memory (evictions correlate with pressure level ≥ 2 regardless of free bytes — see Evidence) and retires settled bg workers on every new spawn.
  4. Net effect: background sessions "randomly disconnect" 40–75× per day. The daemon log shows 225 eviction events over 4 days, firing with 58–250 MB free.
  5. This also breaks mobile Remote Control: an evicted session drops off the roster and does not restart, so from the phone it just goes unreachable — the mobile app offers no way to revive it; the only workaround is a shell on the host (e.g. ssh) to resume it via claude agents.

The daemon's eviction behavior is reasonable in isolation; the desktop app being the dominant source of the pressure is the bug. Users experience it as "my bg sessions keep dying," with no visible culprit because the renderer's RSS looks tiny.

Evidence

Renderer footprint (window sat idle, untouched for hours):

$ top -l 1 -o mem -stats pid,mem,command
PID    MEM   COMMAND
80601  7655M Claude Helper (R   ← desktop app renderer, ~4h40 old

ps RSS for the same process read ~0–331 MB — macOS had compressed the heap (system compressor held ~28 GB of data in 7.2 GB at the time).

Renderer identity — the artifact preview window (~/Library/Logs/Claude/main.log): the ballooned renderer spawned at ~09:35 (from its etime); the only window creation in that interval is:

2026-07-03 09:34:31 [info] [Preview] Created Claude page context { serverId: 'html-preview-…', totalContexts: 1 }
2026-07-03 09:34:31 [info] [Preview] Created Claude page preview { serverId: 'html-preview-…' }
2026-07-03 09:34:31 [warn] [Preview] capturePreviewScreenshotIfChanged failed: {

The previewed artifact was a self-contained static HTML page (an icon-comparison grid of inline SVGs) — nothing that should grow, yet the renderer reached 7.65 GB and no [Preview] destroy event ever fired. For contrast, the app's main renderer (session sidebar, same uptime) stayed at ~660 MB.

Daemon evictions driven by that pressure (~/.claude/daemon.log):

[2026-07-03T11:58:21] [bg] bg: low memory (92MB free) — retiring settled workers before spawning 6cf7960f
[2026-07-03T12:01:53] [bg] bg: low memory (131MB free) — retiring settled workers before spawning 851b7308

225 occurrences over 4 days (75 on 06-30, 49 on 07-01, 61 on 07-02, 40 on 07-03).

Causality confirmed: killing renderer PID 80601 dropped kern.memorystatus_vm_pressure_level from 2 → 1 within seconds, and the evictions stopped.

The trigger tracks the macOS pressure level, not free bytes: evictions fired at free-RAM values anywhere from 58 MB to 1.3 GB (median ~150 MB), but correlate exactly with kern.memorystatus_vm_pressure_level >= 2 — including the instant recovery when the renderer died. This matches the memory-pressure reaping behavior described in the 2.1.193 release notes and #73440.

Expected behavior

  • An artifact preview of a static HTML page should not grow without bound; the preview renderer should be bounded, and an idle preview window should be unloaded or its capture loop stopped.
  • Ideally the daemon's low-memory threshold would be user-tunable and documented (like CLAUDE_CODE_DISABLE_BG_SHELL_PRESSURE_REAP is for background shells) — but fixing the renderer leak removes the trigger.

Related issues (searched — none report this loop)

  • #73440 — same memory-pressure trigger family, different victim: the memory-pressure reaping of background shell commands added in 2.1.193 (per the release notes) killing active run_in_background tasks.
  • #72719 — bg workers crash-loop under memory pressure, misreported as "exit 1 before init".
  • #62048 — desktop app OOM on 8 GB M2 from the hardcoded 4 GB VM; a different desktop memory mechanism (the always-warm Cowork VM also contributes ~1.1 GB here).
  • #73631 — daemon-side accumulation of stale "done" sessions.

None identify a desktop renderer as the pressure source, nor the RSS-vs-compressed-footprint blindspot that hides it.

Steps to reproduce

  1. On a 16 GB Mac, keep ~10 concurrent background sessions running (visible in claude agents).
  2. In the Claude desktop app, open a claude.ai/code artifact HTML preview and leave that window open for several hours.
  3. Watch top -o mem: the preview renderer's footprint climbs into the GBs while ps RSS stays small.
  4. Watch ~/.claude/daemon.log: every bg spawn logs bg: low memory — retiring settled workers, and previously-settled bg sessions drop off claude agents.

---

Edit: the initial version attributed the balloon to the main window mirroring local sessions; log-timeline analysis shows the ballooned renderer was the artifact HTML-preview window (the main renderer stayed at ~660 MB). Body corrected accordingly.

View original on GitHub ↗

4 Comments

vguerci · 1 month ago

Follow-up — recovery is self-defeating under pressure. As an experiment I restarted ~10 previously-evicted sessions in sequence. Each spawn logged bg: low memory — retiring settled workers before spawning <id> and killed the sessions restarted moments before (they settle within seconds of resuming, making them immediately eligible). All but the last few were dead again in under a minute:

[2026-07-03T12:39:15] bg: low memory (163MB free) — retiring settled workers before spawning 2d1bd440
[2026-07-03T12:39:18] bg: low memory (170MB free) — retiring settled workers before spawning 2edabea6
[2026-07-03T12:39:20] bg: low memory (166MB free) — retiring settled workers before spawning f71f426c
[2026-07-03T12:39:23] bg: low memory (157MB free) — retiring settled workers before spawning 851b7308
[2026-07-03T12:39:29] bg: low memory (145MB free) — retiring settled workers before spawning 77175487
[2026-07-03T12:39:33] bg: low memory (148MB free) — retiring settled workers before spawning 42de252c
[2026-07-03T12:39:37] bg: low memory (165MB free) — retiring settled workers before spawning 41dcca8e
[2026-07-03T12:39:42] bg: low memory (152MB free) — retiring settled workers before spawning 182e357d

So once a machine sits at pressure level 2, a bg-session fleet cannot be restored at all: restarting N sessions kills ~N-1 of them. Aggregate cost of the resumed CLI processes was ~7 GB (~300–700 MB each), which itself sustains the pressure — the mechanism feeds itself.

simondambe · 1 month ago

The macOS eviction reproduces with the desktop app closed entirely — so it's driven by general system memory pressure, not only the preview renderer. Worth decoupling in the fix: the renderer leak is one source of pressure; the retiring settled workers eviction is the shared bug, and fixing only the renderer leaves it in place for any other source.

From the 2.1.201 binary, the macOS trigger never looks at free memory:

function Gve() {               // gate run before each bg spawn
  let e = o_f();               // threshold flag, default 1024 MB
  if (e <= 0) return false;
  if (!macos) return os.freemem() < e;   // Linux/Windows: free-bytes based
  return a_f() >= 2;           // macOS: kern.memorystatus_vm_pressure_level >= 2
}

It fires whenever kern.memorystatus_vm_pressure_level >= 2 ("warn"). The (NN MB free) in the log line is computed separately and is display-only — which is why evictions fire across such a wide free-RAM range. On Apple Silicon, level 2 is routine under normal compression, so this reaps healthy fleets.

Three asks:

  1. Escape hatch for the worker reaper — the bg-shell reaper has CLAUDE_CODE_DISABLE_BG_SHELL_PRESSURE_REAP; the session/worker path (Gve) has none.
  2. Trigger on level ≥ 4 (critical), not ≥ 2 (warn) — warn isn't a safe signal to kill in-progress work on Apple Silicon.
  3. Grace period before a worker becomes evictable — a session resumed seconds ago settles immediately and dies on the next spawn, so restarting a fleet kills N−1 (per the existing follow-up).

Regression landed in 2.1.193 (25 Jun) ("automatic memory-pressure reaping"); before it, pressure caused swapping rather than dropped sessions.

byheaven · 1 month ago

Reproduced on two consecutive days on app 1.21459.0 and 1.21459.1 (macOS, Apple Silicon, 24 GB) — with several additional data points that narrow the trigger beyond what's in the OP:

The artifact pane is NOT required — merely focusing a session that has an artifact is enough

Both occurrences line up to the second with setFocusedSession on a session that had previously produced an artifact. The app immediately registers an html-preview context and spawns the renderer that then leaks:

2026-07-16 11:54:59 [info] [CCD] LocalSessions.setFocusedSession: sessionId=session_01P3Gw…
2026-07-16 11:54:59 [info] handoff: publishing session_01P3Gw…
2026-07-16 11:54:59 [info] [Launch] Updating active servers store { count: 1, servers: '[{"serverId":"html-preview-1784174099415-0",…,"externalUrl":"https://claude.ai/code/artifact/6631d458-…","status":"running"}]' }

The renderer's spawn time (ps -o lstart) matches this log line to the second in both cases. The artifact side panel was never opened on day 2.

Leak characteristics (matching OP)

  • ~1.5 MB/s (~100 MB/min) steady growth, 0% CPU, measured via repeated top -l 1 -pid <PID>
  • Day 1: 7.4 GB after ~4 h; Day 2: 5.5 GB after ~1.5 h
  • Almost fully compressed by macOS (CMPRSMEM), so ps RSS showed ~100 MB — invisible in Activity Monitor's default view; filled 4 GB of swap
  • Renderer has zero open network sockets (lsof), so the growth is a local loop (timer/poll accumulation), not stream buffering

Additional data points

  1. Leaks continue after the session ends — and even for archived sessions. Day 1's renderer was still leaking hours after the underlying session had finished and been archived. On one occurrence the view was restored at wake-from-sleep with no visible window at all (hidden webview; setFocusedSession fired during wake restore).
  2. Both sessions were remote — Claude Code sessions running on another device (Mac Mini) in the same account, viewed from this machine's desktop app. Their transcripts don't exist locally; the viewer window alone produces the leak.
  3. Kill behavior: kill <renderer PID> releases the memory; the view respawns once (crash-reload) and resumes leaking at the same rate; a second kill makes it stay dead. Killing the renderer doesn't affect the underlying CLI session.
  4. Corroborating renderer-side warning at the exact spawn second in claude.ai-web.log:

``
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 $eipc_message$…_claude.settings_$_AppPreferences_$_preferencesChanged listeners added.
``
(Same EIPC listener-accumulation family as #67667 / #66127 / #67780.)

Happy to provide fuller logs or run an instrumented build if useful.

simondambe · 1 month ago

Asks #2 and #3 shipped — the retiring settled workers evictions have stopped. Decompiling 2.1.211:

function XRs(){                                    // was Gve()
  let e = et("tengu_bg_low_mem_mb", 1024) * 1024 * 1024;
  if (e <= 0) return { lowMem: !1, level: void 0 };
  if (qt() !== "macos") return { lowMem: C2d.freemem() < e, level: void 0 };
  let t = _jy();
  return { lowMem: t !== void 0 && t >= gjy, level: t };   // gjy = 4, was 2
}
function w2d(){ return et("tengu_bg_retire_grace_bridged_min", 480) * 60000; }  // 8h grace
  • #2: trigger constant is now 4 (critical), was 2 (warn).
  • #3: new tengu_bg_retire_grace_bridged_min, default 480 min, before a settled worker is evictable.