[plan-14] Child Process Ownership — every process the worker spawns dies with the worker
[plan-14] Child Process Ownership — every process the worker spawns dies with the worker
Tracker: #3602 · Design doc:
plans/14-child-process-ownership.md· Series: plan-14 #3602 · plan-15 #3603 · plan-16 #3604 · plan-17 #3605 · plan-18 #3606 · plan-19 #3607 · plan-20 #3608 · plan-21 #3609 · plan-22 #3610 · plan-23 #3611
Defect
The worker daemon spawns long-lived children — the chroma-mcp sidecar (uvx → uv → python), the headless claude observer subprocess, mcp-server.cjs, and sibling workers during recycles — but never takes ownership of their lifetime. Concretely:
- The supervisor registry (
supervisor.json) keys the sidecar under a single fixed name ("chroma-mcp"); every (re)spawn overwrites the entry without killing the previous PID, sopruneDeadEntries()only ever sees one live child while dozens accumulate. - Children are spawned via
StdioClientTransportwith nodetached/process-group, andclose()only SIGTERMs theuvwrapper, which does not forward to its python child. On systemd hosts orphans reparent tosystemd --user, soPPID==1sweeps find nothing. - Every abnormal exit path skips teardown: the 10 s
gracefulDeadlineMsexpires mid-sessionManager.shutdownAll()sochromaMcpManager.stop()is never reached (#3459);performGracefulShutdownthrowsServer is not running.andprocess.exit(0)runs before the registry sweep (#3153); theEADDRINUSE"refusing to start duplicate" path exits after background-init already spawned a chroma pair (#3216); SIGKILL after a restart timeout skips everything. ChromaMcpManager.callTool()retry re-entersensureConnected()after teardown with no shutdown latch, respawning the tree it just killed;connectInternal()'s catch swallowstransport.close()failures and nulls the only handle to a live ~500 MB python child.- Nothing bounds or recycles a child by resource usage (a single chroma-mcp reaching 23 GB footprint invisible to RSS), and nothing sweeps orphans at boot.
Because the version-mismatch recycle loop (plan-16) can restart the worker hundreds of times per hour, this leak becomes hundreds of processes and tens of GB of swap in a day. Fixing the loop stops the pump; only ownership stops the leak.
Children
- #2896 — chroma-mcp subprocess leak: unbounded process accumulation via reconnection loop
- #2950 — chroma-mcp subprocess leak on macOS — zombie uv+python3.13 process pairs accumulate across sessions
- #2959 — tracking: chroma-mcp subprocess leak — zombie uv+python process pairs accumulate due to silent kill failures and orphan socket inheritance
- #3149 — chroma-mcp subprocess leaked on every session (never reaped) + CLAUDE_MEM_DATA_DIR ignored by chroma-mcp launch
- #3153 — Worker leaks one chroma-mcp process pair per restart — supervisor.json overwritten, previous chroma never reaped
- #3175 — chroma-mcp processes orphan on session exit → accumulate, hold uv cache lock, bloat ~/.cache/uv
- #3191 — In-process memory leak: single live chroma-mcp subprocess grew to 23 GB footprint — worker should bound and recycle it
- #3205 — chroma-mcp process leak: stale marketplace version triggers an unbounded worker-recycle loop, and recycled workers orphan chroma-mcp to PID 1
- #3216 — Stale-version recycle loop leaks chroma-mcp processes (759 procs / ~71 GB RSS) until the machine OOMs
- #3218 — chroma-mcp orphan leak still present on 13.10.3 — OOMs an 8GB machine in ~20 min (regression of #1063 / #1077)
- #3230 — chroma-mcp subprocesses leak and are never reaped — 1000+ orphaned procs / ~48 GB RAM after a day
- #3270 — Process leak: 58+ claude-mem processes accumulate during a session
- #3301 — Chroma-mcp orphan leak across worker generations: live orphan's registry entry is overwritten without a kill
- #3326 — chroma-mcp server leaks a new process pair every 1-3 min, never reaps old ones
- #3382 — Orphaned chroma-mcp processes accumulate — supervisor tracks 1, hundreds leak
- #3404 — Worker daemon never reaps headless claude observer subprocesses — 203 accumulated over 4 days
- #3413 — chroma-mcp orphans span 5 uv build generations (supervisor tracks 1 of 23); orphans page out so the leak is silent
- #3459 — Graceful shutdown leaks chroma-mcp too: the 10s deadline expires mid-drain, so chromaMcpManager.stop() is never reached
- #3540 — [Windows] chroma-mcp respawn teardown correlates with orphaned uv builds-v0 temp dirs — 144GB confirmed
Related PRs to evaluate/rebase: #3302 (kill-before-overwrite), #3465 (shutdown reap backstop + terminal latch), #3462 (connectionGeneration guards), #3192 (memory watchdog), #3232 (per-PID registry keys + orphan sweep), #3405 (registry-verified reap), #2536, #2920, #1175.
Fix sequence
- Registry keyed by PID, not role.
ProcessRegistry.register()records{pid, pgid, startToken, cmdlineFingerprint, dataDir}per spawn; before any new chroma/observer spawn, kill the recorded predecessor tree (verified by cmdline fingerprint), then unregister. Overwrite-without-kill becomes impossible by construction. - Spawn every child in its own process group (
detached: true+unref()on POSIX; Job Object /CREATE_NEW_PROCESS_GROUP+taskkill /Ton Windows) sokill(-pgid)reachespython, not justuv. The Windows kill uses a graceful step before/Fso uv can cleanbuilds-v0/.tmp*(#3540). - One teardown path that always runs.
runShutdownSequencegets a bounded reap backstop:process.on('exit'|'SIGTERM'|'SIGINT'|'uncaughtException')→ registry cascade kill with its own deadline; graceful order becomeschroma stop → observer abort → http close → dbso the expensiveshutdownAll()can no longer starve child teardown;ERR_SERVER_NOT_RUNNINGand theEADDRINUSEduplicate path both go through the same cascade. - Shutdown latch.
chromaMcpManager.stop({terminal:true})sets a latch;ensureConnected()/callTool()throwChromaUnavailableErrorafter it.stop()is memoized.connectInternal()failures kill the spawned PID before nulling the transport. - Boot-time and periodic sweep, registry-independent. On worker start and every supervisor tick: kill any
chroma-mcp --data-dir <ours>/claude … --resume <observer-id>/mcp-server.cjsprocess whose parent is not a live claude-mem worker (walk by cmdline + data-dir, notPPID==1). - Resource bound per child. Every 5 min sum footprint (PSS/private, not RSS) across the child tree; above
CLAUDE_MEM_CHROMA_MEMORY_LIMIT_MB(default 2048) recycle through the same kill-before-spawn path. The same watchdog aborts observer subprocesses idle past their deadline. - Chroma launch honors
CLAUDE_MEM_DATA_DIRfor--data-dir(#3149) so multiple config dirs cannot share one store by accident.
Test matrix
| Host | Trigger | Required behavior |
|---|---|---|
| macOS / Linux (PID 1) / Linux (systemd --user subreaper) / Windows (Bun) | worker restart ×20 |
exactly one chroma-mcp tree alive after each; supervisor.json has one live chroma entry |
| all | SIGKILL worker, restart | boot sweep kills the orphaned tree within 5 s of new worker health |
| all | graceful shutdown with 40 s of pending summarize work | chroma tree gone before process exit; no respawn after stop() |
| all | EADDRINUSE duplicate start |
duplicate exits with zero children left |
| all | chroma child footprint > limit | recycled once, not looped; old tree gone |
| all | 4 h idle worker | observer subprocess count == active sessions |
| Windows | taskkill path |
no new %LOCALAPPDATA%\\\uv\cache\builds-v0\.tmp* dirs older than 10 min |
The matrix lives in CI (tests/infrastructure/process-ownership.test.ts + a Docker systemd --user job + the Windows job). A regression must fail CI before a user can file.
Out of scope
Ghost LISTEN sockets, stale PID/spawn/writer locks and reclaim (plan-15). The version-mismatch recycle loop and stale-path successor spawn that pump the leak (plan-16). Chroma spawn arguments, pins and sync watermarks (plan-22).
Source: thedotmack/claude-mem