Scheduled task runner never exits the CLI subprocess: every run leaks a process + its MCP servers (~92 GB over two days)
Summary
Every run of a scheduled task leaves its claude CLI subprocess alive forever. The run completes normally, emits its result, and then the process parks in its event loop instead of exiting — holding ~250-380 MB plus every MCP server it started. On a weekday */10 schedule this accumulated 937 processes and ~92 GB of physical footprint over two working days, on a 36 GB machine.
The task itself is a short read-only job (an HTTP call plus a few MCP reads) that finishes in 24-25 seconds. It spawns no background jobs and starts nothing long-running, so nothing in the task prompt can hold the process open.
Environment
- Claude Desktop 1.34493.1
- Bundled Claude Code CLI 2.1.237
- macOS 26.6.2, arm64
Reproduction
- Create a scheduled task with a short recurring cron (mine was
*/10 9-18 * * 1-5). - Let it fire. Watch the spawned
claudeprocess after the run's final result lands. - The process never exits. Repeat every interval and they stack up.
Evidence that the run is finished but the process is not
Observed on one run's pid, watched for 15 minutes after completion:
The run is over. Its transcript stopped growing at a fixed byte count and never changed again:
run finished 17:42:22, transcript frozen at 71,670 bytes
17:42:44 pid S 0:02.09 541344 KB
17:57:32 pid S 0:15.79 544400 KB <- 15 min later, still alive
It also keeps burning ~1.5% CPU while parked (0:02.09 -> 0:15.79 of CPU with no work to do), and RSS creeps ~3 MB per idle 15 minutes.
It is parked in the event loop, not working. sample <pid>:
2444 Thread_... DispatchQueue_1: com.apple.main-thread (serial)
2444 start (in dyld)
... (in claude)
2422 kevent64 (in libsystem_kernel.dylib)
stdin is still open. lsof -a -p <pid> -d 0,1,2:
claude <pid> 0u unix 0xfbe9... ->0xf47e...
claude <pid> 1u unix 0x4183... ->0xb9cd...
claude <pid> 2u unix 0xbd54... ->0xa599...
fd 0 is still a live unix socket to the parent. The process tree is:
<pid> <- claude, --input-format stream-json
|
+-- Claude.app/Contents/Helpers/disclaimer
|
+-- Claude.app (the desktop app)
The CLI is launched with --input-format stream-json, which reads stdin until EOF. The desktop app's scheduled-task runner neither closes that socket nor reaps the child after the final result, so the CLI waits for input that will never arrive.
MCP servers multiply the cost
Each leaked session keeps every MCP server it started. With five desktop extensions enabled, one finished run held 10 processes:
<pid> claude 541 MB
+-- uv run ... aws-api-mcp-server x4 ~110 MB each
| +-- python -m awslabs... x4 ~128 MB each
+-- node ... mcp-server-kubernetes 160 MB
That is ~1.6 GB per completed run that should have exited.
Scale
Over two working days (cron excluded the weekend), from a clean boot:
| | |
|---|---|
| Processes in the Claude tree | 937 |
| Summed physical footprint | ~92 GB (measured: 78.7 GB across 791 of them via vmmap --summary) |
| Resident | ~30 GB on a 36 GB machine |
| Compressor | 18 GB, holding 6.8M pages |
| Free RAM | 73 MB |
| Load average | 59, with 79% system CPU |
The machine was unusable. After killing the leaked sessions: tree RSS 34.2 GB -> 9.8 GB, compressor 18 GB -> 6.0 GB, load 59 -> 5.5.
Expected
After a scheduled run emits its final result, the runner should close the CLI's stdin (or terminate the child), the CLI should shut down its MCP clients and exit, and no process from that run should survive.
Workaround
An external reaper keyed on cwd plus an age floor above the task's runtime. This is unpleasant to get right — the age floor has to clear the longest legitimate run, and selecting on process age alone risks killing an in-flight one.