Summary
In v2.4.3, each Claude Code session spawns a persistent mcp-server.ts bun process for memforge-client. When the parent Claude Code process exits, the MCP child is NOT reaped — it gets adopted by launchd (PPID becomes 1) and keeps running indefinitely, continuing to poll the local claude-mem SQLite DB and retrying the in-memory sync queue.
Over days of normal usage, these orphans accumulate with no self-cleanup, bloating RAM and wasting CPU on stale sync retries that target a sync queue the parent will never drain.
Evidence (single macOS workstation, 2026-04-21)
$ ps -eo pid,ppid,etime,rss,args | awk '$2==1 && /memforge-client\/2\.4\.3\/src\/mcp/' | wc -l
154
$ ps -eo rss,args | awk '/memforge-client\/2\.4\.3\/src\/mcp/ {sum+=$1} END {print sum/1024 " MB"}'
3530 MB # 154 orphans + 3 living = ~23 MB average each
$ ps -eo pid,ppid,etime,args | awk '$2==1 && /memforge-client\/2\.4\.3\/src\/mcp/ {print}' | sort -k3 -r | head -3
44463 1 23:06:09 bun /Users/.../memforge-client/2.4.3/src/mcp/mcp-server.ts
52038 1 23:03:10 bun /Users/.../memforge-client/2.4.3/src/mcp/mcp-server.ts
53271 1 23:02:59 bun /Users/.../memforge-client/2.4.3/src/mcp/mcp-server.ts
- 154 orphan MCPs accumulated over ~24 hours
- 3.5 GB resident set size (~23 MB per process)
- Oldest orphan: 23 hours old, PPID=1 (launchd)
- Only 3 MCPs had living parents — matching the 3 active Claude Code sessions
Cleanup restored 1.4 GB free memory (was 214 MB):
$ ps -eo pid,ppid,args | awk '$2==1 && /memforge-client\/2\.4\.3\/src\/mcp/ {print $1}' | xargs kill -TERM
# All 154 responded to SIGTERM within 2 seconds.
Expected behavior
When the parent Claude Code process exits, the MCP server should exit within seconds — either because:
- Claude Code sends a stdio close/EOF on the MCP pipe and the server shuts down on stdin EOF, or
- The server polls for parent liveness and exits on orphan detection (
process.ppid === 1), or
- A
SIGHUP on parent exit triggers graceful shutdown.
Actual behavior
- stdin EOF does not trigger exit — the bun process stays alive
- No parent-liveness watchdog
- The in-process sync poller keeps running with
adaptive polling (per comments in src/sync/sync-poller.ts), retrying items from ~/.memforge/.sync-queue.json with ever-growing retryCount (one item reached retryCount: 17,840 in our env)
- Parent exit leaves the child as a launchd-adopted orphan
Suggested fixes (pick one or combine)
A — stdin EOF shutdown (minimal, standard for MCP stdio servers):
process.stdin.on('end', () => {
logger.info('stdin EOF — parent gone, shutting down');
// flush pending sync, then process.exit(0)
});
process.stdin.on('close', () => process.exit(0));
B — parent-liveness watchdog (defensive, catches edge cases where stdin stays open):
const PARENT_PID = process.ppid;
setInterval(() => {
if (process.ppid !== PARENT_PID && process.ppid === 1) {
logger.info('Adopted by launchd — parent died, exiting');
// flush pending sync, then process.exit(0)
}
}, 30_000);
C — SIGHUP handler:
process.on('SIGHUP', () => {
// flush + exit(0)
});
Most robust = A + B combined. Claude Code's MCP protocol closes stdio on session end, so A handles the normal case. B catches the pathological case where stdio stays open (e.g. kernel bug, zombie pipe).
Environment
- OS: macOS (Darwin, Apple Silicon)
- Shell: zsh
- Bun: 1.3.5
- memforge-client: 2.4.3
- claude-mem: 12.3.7 (unrelated — claude-mem's own mcp-server.cjs does exit cleanly)
- Parent Claude Code: various versions over 24h, sessions opened/closed normally
Related
Why this matters
On machines with many Claude Code sessions over time:
- 3.5 GB wasted per ~24h of normal usage (scales linearly)
- Stale sync-poller retries hit the memforge server with
retryCount into the tens of thousands
- Makes
claude-mem.db file locking noisier (N+1 readers instead of 2-3)
Happy to test any patch against our workstation.
Summary
In
v2.4.3, each Claude Code session spawns a persistentmcp-server.tsbun process for memforge-client. When the parent Claude Code process exits, the MCP child is NOT reaped — it gets adopted bylaunchd(PPID becomes 1) and keeps running indefinitely, continuing to poll the local claude-mem SQLite DB and retrying the in-memory sync queue.Over days of normal usage, these orphans accumulate with no self-cleanup, bloating RAM and wasting CPU on stale sync retries that target a sync queue the parent will never drain.
Evidence (single macOS workstation, 2026-04-21)
Cleanup restored 1.4 GB free memory (was 214 MB):
Expected behavior
When the parent Claude Code process exits, the MCP server should exit within seconds — either because:
process.ppid === 1), orSIGHUPon parent exit triggers graceful shutdown.Actual behavior
adaptive polling(per comments insrc/sync/sync-poller.ts), retrying items from~/.memforge/.sync-queue.jsonwith ever-growingretryCount(one item reached retryCount: 17,840 in our env)Suggested fixes (pick one or combine)
A — stdin EOF shutdown (minimal, standard for MCP stdio servers):
B — parent-liveness watchdog (defensive, catches edge cases where stdin stays open):
C — SIGHUP handler:
Most robust = A + B combined. Claude Code's MCP protocol closes stdio on session end, so A handles the normal case. B catches the pathological case where stdio stays open (e.g. kernel bug, zombie pipe).
Environment
Related
src/sync/sync-poller.tsheader comment says "MCP server must stay alive" — that holds while parent is alive, but not after parent exits.Why this matters
On machines with many Claude Code sessions over time:
retryCountinto the tens of thousandsclaude-mem.dbfile locking noisier (N+1 readers instead of 2-3)Happy to test any patch against our workstation.