Session backends and MCP helpers are never evicted — unbounded process/memory growth on long-running servers
Summary
On a long-running headless server, every session that is ever opened leaves a claude subprocess alive forever. Nothing evicts them. After 28 days of server uptime I had 23 backend processes for 15 distinct sessions, holding 2.29 GB RSS and burning ~22% of one CPU core continuously while completely idle.
This appears to be a missing eviction policy rather than a broken teardown path — the teardown code is correct, it's just never invoked for idle sessions.
Environment
- craft-agents-oss
v0.11.1(also verified againstv0.11.4) - macOS 15 (Darwin 25.5.0), Apple Silicon
- Headless server via
bun run packages/server/src/index.ts, started by a launchd agent, running continuously
Measurements
23 backend processes, all children of the server PID:
| metric | value |
|---|---|
| distinct sessions | 15 |
| processes | 23 |
| total RSS | 2.29 GB |
| combined CPU | 1.11 CPU-seconds per 5 wall-seconds (~22% of a core) |
| oldest process | 25 days |
| longest idle | last transcript write 18 days before I killed it |
Per-process idle cost is ~0.05 CPU-sec every 5 seconds — identical across all ages, consistent with a fixed-interval heartbeat rather than work. One 25-day-old process had accumulated 93 minutes of CPU time doing nothing.
Several sessions had multiple backends: one session (long-coral) had 4 processes, and five others had 2 each.
Note: %CPU from plain ps is a lifetime average and makes these look near-zero; sampling ps -o time= twice is what surfaces the ongoing cost.
Root cause
The subprocess is intentionally kept alive between turns. From
beginPersistentTurninpackages/shared/src/agent/claude-agent.ts:ending that channel at
resultcompletes the turn WITHOUT closing the real query (the subprocess, and its background sub-agents, stay alive)That's a reasonable warm-start optimization.
Teardown works correctly when it is called:
ClaudeAgent.destroy()→teardownPersistentQuery()→ aborts the controller, which is the documented SIGTERM/SIGKILL backstop.But nothing calls it for an idle session. In
packages/server-core/src/sessions/SessionManager.ts:disposeManagedAgentRuntime()has only two callers, both runtime-config changes ('restart-required runtime change','runtime config refresh')- the other
agent.dispose()fires only on explicit session destroy grep setIntervaloverSessionManager.tsreturns no matches in either v0.11.1 or v0.11.4 — there is no periodic sweep of any kind
So a backend lives until the session is explicitly deleted, a runtime-config change forces a restart, or the server exits. Idleness is never a trigger.
ManagedSession already carries lastMessageAt, so the signal needed for eviction is present.
Related: orphaned MCP helpers
A second, separate leak. The server also spawns per-session MCP helpers — @supabase/mcp-server-supabase, mcp-remote, and unified-network-interceptor.ts bridges. These are children of the server, not of the backend, so they survive the backend's death and are never reaped either.
After terminating all 23 backends, 20 MCP helper processes remained, all idle, with ages matching the sessions that had just been killed (up to 25 days old). Their cwd is the repo or workspace root rather than the session directory, so they can't be attributed to a session from the outside.
Suggested fix
An idle-eviction sweep in SessionManager, e.g.:
- a
setIntervalthat disposes any session whoselastMessageAtexceeds a configurable idle TTL, reusing the existingdisposeManagedAgentRuntime()(state is already durable — sessions resume from disk via--resume, so eviction is transparent to the user beyond a cold start on the next message) - an env var such as
CRAFT_SESSION_IDLE_TTL_MS(0= never evict, preserving today's behavior) - optionally a cap on concurrent live backends with LRU eviction
- tie MCP helper lifetime to the owning session so they're disposed alongside it
That "state is already durable" point is worth emphasizing: on my install, 214 session directories existed on disk but only 23 had a live process — so a session with no backing process is already the normal, working state for the overwhelming majority of sessions. Eviction would just make more sessions look like the ones that already work fine.
Workaround
For anyone hitting this before a fix lands: periodically SIGTERM the server's claude children that have been idle past some threshold. They terminate cleanly (all 23 of mine exited on plain SIGTERM, no SIGKILL needed), the server stays healthy, and sessions respawn on next open. Using the session directory's mtime as the idle signal works well, with a CPU-delta sample as a guard against killing a session that's actually working.
The measurements and code tracing here were done with Claude Code on my machine — I'm filing it because it's my install that hit it. If a maintainer wants detail beyond what's written above, I'm happy to dig, but I'd need to re-run the diagnostics rather than answer from the top of my head.
Source: craft-ai-agents/craft-agents-oss