Claude Desktop: scheduled-task/background `claude` CLI sessions never exit → memory exhaustion over 1–2 days

Status Open
Reported on v2.1.187
Maintainer reply None cached
Activity 3 comments · opened Jun 29, 2026

Summary

Headless claude CLI sessions spawned by Claude Desktop (macOS) for scheduled tasks / background agents do not terminate after their run completes or hangs. They accumulate indefinitely — one per scheduled fire — each retaining its full plugin + MCP-server stack in RAM. Over ~1–2 days of uptime on a 16 GB machine this exhausts memory and drives the system into a swap death-spiral.

Environment

  • Claude Desktop (macOS), CLAUDE_CODE_ENTRYPOINT=claude-desktop
  • Bundled Claude Code CLI 2.1.187, Agent SDK 0.3.187
  • Apple Silicon (M4), 16 GB RAM, macOS 26.2

Observed impact (real instance)

  • 151 leaked claude CLI processes + 151 Helpers/disclaimer wrappers + ~160 orphaned MCP children (context7-mcp, node, etc.).
  • Load average ~134 on a 10-core machine — processes blocked on swap I/O, not CPU-bound.
  • Swap ~19.8 GB / 20 GB used; ~900M swapins; system-wide free memory ~25%.
  • Oldest leaked session alive ~47 h (since previous boot). New leaked sessions appear on a steady cadence matching scheduled-task cron intervals.
  • Disk is not involved (plenty free). Purely process/memory accumulation.
  • Reboot fully resets it (count → 0, load → ~3), then it rebuilds over 1–2 days (~7 leaked sessions accumulated within 45 min of a fresh boot, with ~28 enabled scheduled tasks).

Repro

  1. Run Claude Desktop with several recurring scheduled tasks (a sub-hourly one accelerates it).
  2. Leave the app running 1–2 days without rebooting.
  3. Observe:

``
ps -Ao comm | sed -E 's|.*/||' | sort | uniq -c | sort -rn | head # hundreds of claude/disclaimer/context7-mcp/node
uptime # load average in the dozens-to-hundreds
sysctl vm.swapusage # swap near-full
`
Each leaked CLI process traces
claudeHelpers/disclaimer → the Desktop app, launched with --allow-dangerously-skip-permissions --permission-mode auto --disallowedTools AskUserQuestion`, and never exits.

Note for triage

Interactive and background sessions are indistinguishable by command-line flags and by environment (same entrypoint, both carry --allow-dangerously-skip-permissions/--permission-mode, sometimes --resume/--mcp-config), which makes safe external cleanup hard.

Expected

After a scheduled-task / background-agent run completes or times out, the claude CLI process and its MCP child processes should be torn down. Consider capping concurrent background sessions and adding an idle/hung-session timeout.

---
Filed by an affected user. Happy to provide process dumps, logs, or further detail on request.

View original on GitHub ↗

3 Comments

CHDrew423 · 1 month ago

Independent confirmation on newer builds, plus three details that may help triage.

Desktop app 1.20186.0 with bundled CLI 2.1.205, macOS (Darwin 24.6.0), ~30 scheduled-task runs/day: ~140 leaked claude processes accumulated over 31 hours (12-core Mac pinned at load 25-40); after a cleanup, it rebuilt from 2 to 18 sessions overnight. Reproduces with hooks fully removed ("hooks": {}), and the leaked CLI processes here had zero MCP children, so the leak does not depend on hooks or MCP teardown. All transcripts end with a completed final assistant message.

  1. The app knows the sessions are done. Its own session registry (session-management MCP, list_sessions) reports isRunning: false for these sessions while their processes persist. Interactive chat sessions leak identically, not just scheduled runs.
  1. There is a third leaked process per session: a /bin/zsh -l PTY helper shell, a direct child of the app, which survives even after the session's disclaimer + claude pair is killed. It ignores SIGTERM and needs SIGKILL; the app logs its exit benignly ("Shell PTY for session ... exited with code 0").
  1. External cleanup triggers a false security dialog. SIGKILLing a leaked claude process makes the app log query error: Claude Code process terminated by signal SIGKILL and pop a "Blocked by endpoint security" dialog advising the user to allowlist Team ID Q6L2SF6YDW, even with no endpoint security installed. SIGTERM avoids it: the pair exits within seconds, logged as exited with code 143, no dialog, and the session still resumes cleanly in the app afterward.

Re the triage note about safe external cleanup being hard: an hourly launchd job that SIGTERMs any matching process older than 6 hours (then SIGKILLs survivors after a 15s grace) has been a workable stopgap here. Age is the only usable discriminator, and since externally killed sessions resume cleanly in the app, the worst case is interrupting a very long interactive session mid-turn, not losing it.

louij2 · 19 days ago

Corroborating data point, same failure mode as this issue (and closely related to #80885):

Environment: macOS 26.6, Claude Desktop, Claude Code binary 2.1.222, Apple Silicon, 16 GB RAM, 10-core.

Observed:

  • 68 days of continuous uptime, no reboot.
  • 76 leaked claude CLI processes found alive under .../Claude/claude-code/2.1.222/claude.app/Contents/MacOS/claude, together consuming ~396% CPU and ~3.1 GB RSS.
  • list_sessions returned 63 non-archived sessions, spread across the current day plus a few from the prior few days — every single one reported isRunning: false, yet the OS-level processes were still alive and actively burning CPU. Same isRunning-false-but-alive mismatch as the parent issue.
  • System load average was 33–57 on a 10-core machine (should be <10 at rest), 84% system-time / 4% idle, and physical memory was essentially exhausted (18 MB "unused" out of 16 GB, 7.3 GB sitting in the compressor) — consistent with the swap/memory-pressure pattern described here for longer uptimes.
  • Fix: fully quitting (Cmd+Q) and relaunching the Claude Desktop app dropped the count from 76 processes / 396% CPU to 2 processes / 9.9% CPU immediately, and load average began draining right away. Pinned sessions persisted across the relaunch, so no work was lost.

Notably these weren't obviously all scheduled-task fires — some appear to be regular sessions that were closed/backgrounded in the UI during the day. So the leak doesn't seem strictly limited to the scheduled-task path; the common thread across all of them seems to be the session registry marking a session done/backgrounded without the underlying OS process actually being torn down.

Happy to provide the raw ps/vm_stat output if useful for triage.

christian-apollo · 1 day ago

Still present on Claude Code 2.1.247 / Claude Desktop 1.40609.0 (macOS 26.6.2, arm64, 14 cores, 36 GB). Adding a data point plus what looks like direct evidence of the root cause, which I think narrows down the speculation in #71424.

The leak: stdin is never closed

Every leaked session has fd 0 still open as a unix socket:

$ lsof -p 1693
claude 1693  0u  unix ->0x9549c39393593587      <- stdin, never closed
claude 1693  1u  unix ->0xd797dee826952213
claude 1693  7u  unix /tmp/cc-socks/1693.sock
claude 1693  8u  IPv4 TCP ...:55416->...:443 (ESTABLISHED)
claude 1693 12u  IPv4 TCP ...:54129->...:443 (ESTABLISHED)
claude 1693 18u  IPv4 TCP ...:64354->...:443 (ESTABLISHED)

The session runs with --input-format stream-json and blocks reading stdin. The Desktop app never closes that socket and never sends a shutdown, so the child never sees EOF and the read never returns. It is not a hung event loop or an unref'd timer, it is a legitimate blocking read on a pipe the parent left open. Process state is S throughout.

This also suggests the fix is cheap on the app side: close the child's stdin at session teardown and the existing read path should unwind on its own.

These leaks are not idle

Both issues focus on memory. The leaked sessions also burn CPU continuously:

  • Each accumulates 11 to 18 minutes of CPU time over ~6 hours of being "finished", roughly 1.6% of a core each, steady.
  • With 86 leaked sessions that is about 1.4 cores permanently consumed doing nothing but keepalive.
  • Each holds 3 to 5 ESTABLISHED TLS connections to the API. 86 sessions is ~300 idle server-side connections from one machine.

Snapshot before/after cleanup

86 leaked sessions accumulated in 12h51m of app uptime, one per session, none exited.

| | before | after killing 424 leaked PIDs |
|---|---|---|
| Load average | 104 | 25 |
| CPU idle | 0.2% (77% sys) | ~10% (74% user) |
| PhysMem used / free | 35G / 210M | 21G / 14G |
| Compressor | 13G | 3.7G |
| Swap | 11.4G / 12G | 2.4G / 4G |
| Processes / claude sessions | 1080 / 87 | 620 / 2 |

kernel_task was at 162%, and pages-decompressed had reached 3.3 billion. The machine was not CPU-bound, it was thrashing: ~25 GB was held by leaked sessions and their MCP children (~4 MCP servers per session, 344 helper processes totalling 11.9 GB).

Two notes for anyone writing cleanup tooling

1. /tmp/cc-socks/<pid>.sock also leaks. 89 socket files for 87 live sessions. Same lifecycle problem, easy to miss.

2. Killing a leaked session can kill live work. This is a sharper version of the "interactive and background sessions are indistinguishable" note in #72308. Long-running jobs a session started (compilers, test runners, dev servers) are descendants of that session's process. A leaked session that has been idle for six hours can still own a build that started two minutes ago. I killed a 54-minute compile and a static-analysis run this way, because I checked whether the session was busy rather than whether its subtree was.

Any external reaper needs to walk the descendant tree and skip a session if any descendant is active, and even that races. Which is really an argument for fixing it in the app rather than papering over it with a reaper.

The app already records the discriminator it says it lacks

The "interactive and background sessions are indistinguishable" note is true of the process table, but not of the Desktop app's own state. Each session's record under ~/Library/Application Support/Claude/claude-code-sessions/**/local_<uuid>.json carries a top-level scheduledTaskId, set for scheduled-routine runs and absent for human conversations:

$ jq -r '[.title, (.scheduledTaskId // "-")] | @tsv' local_<uuid>.json
Nightly inbox sweep        nightly-inbox-sweep     <- scheduled routine
Debugging a memory leak    -                       <- human conversation

cliSessionId in the same record maps to the sessionId in ~/.claude/sessions/<pid>.json, which gives a clean pid → class mapping. list_sessions also exposes an isRunning flag that is correct in practice, though it appears to be computed at runtime rather than persisted.

This matters because idle time alone is not a safe signal, and I learned that the hard way. My first reaper used only "transcript untouched for N minutes" and, on its first real pass, flagged the live conversation I was using to write it. I had simply been away for six hours. A walked-away chat and a leaked session are byte-for-byte alike: same never-closed stdin, same self-held cc-socks entry, same child count, same idle CPU.

Gating on scheduledTaskId fixed it. On a machine with 51 live sessions, 47 were leaked scheduled-routine runs and 4 were human chats; the gate reaped 30 routine leaks across two passes and left all four conversations untouched. So a safe external reaper is possible today, but only because that field happens to be on disk, which is a fairly strong hint that the app has everything it needs to close these itself at teardown.

Workaround

Quit and reopen the Desktop app. Every leaked session is a child of the main Claude process, so they all get reaped at once.