Session backends and MCP helpers are never evicted — unbounded process/memory growth on long-running servers

Author: bendelaneyCreated Aug 11, 2026Updated Sep 20, 2026

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 against v0.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

  1. The subprocess is intentionally kept alive between turns. From beginPersistentTurn in packages/shared/src/agent/claude-agent.ts:

    ending that channel at result completes the turn WITHOUT closing the real query (the subprocess, and its background sub-agents, stay alive)

    That's a reasonable warm-start optimization.

  2. Teardown works correctly when it is called: ClaudeAgent.destroy()teardownPersistentQuery() → aborts the controller, which is the documented SIGTERM/SIGKILL backstop.

  3. 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 setInterval over SessionManager.ts returns 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 setInterval that disposes any session whose lastMessageAt exceeds a configurable idle TTL, reusing the existing disposeManagedAgentRuntime() (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