Summary
Every time the worker dies without a graceful shutdown (SIGKILL, crash, OOM kill), its chroma-mcp subprocess tree survives — and the next worker generation silently discards the only record of it. One leaked uvx → uv → python chroma-mcp tree per ungraceful worker death, unbounded across restarts.
On an 8 GB macOS machine (Apple Silicon, macOS 25.5.0, claude-mem 13.11.0) this accumulated 682 orphaned processes (341 uvx wrappers + 341 python instances) in ~15 hours, filling 21.7 GB of swap and driving the load average to 293 until macOS started showing the "system has run out of application memory" dialog. Memory pressure makes workers die more often, which leaks more orphans — a self-reinforcing spiral.
Likely a major contributor to the process-accumulation reports in #3270 and related to the leak scenarios described in #3298.
Root cause
The in-process lifecycle in ChromaMcpManager is well defended (disposeCurrentSubprocess(), tree-kill on failed connect, onclose sweep — #2313). The hole is at the worker-process-death boundary, and it takes three steps:
- Worker dies ungracefully. The chroma-mcp child is not tied to the worker's lifetime (no process group ownership), so it survives and re-parents to init/launchd.
supervisor.json still records its PID under the chroma-mcp id.
- Next worker boots.
ProcessRegistry.initialize() loads the persisted entries and calls pruneDeadEntries() — which only removes entries whose PID is dead (if (isPidAlive(info.pid)) continue;). The orphan's entry is alive, so it is kept... but nothing acts on it.
- New worker connects.
connectInternal() → registerManagedProcess() → registry.register('chroma-mcp', …) overwrites the old entry with the new child's PID. The orphan's PID — which the registry knew about moments earlier — is forgotten without ever being signaled.
The writer lock does not help: it records the worker's PID, not the chroma child's, so isChromaWriterLockLive() correctly reports the lock stale (owner dead) and the new worker proceeds — right past the still-running previous chroma-mcp.
Reproduction (macOS/Linux)
# 1. Let the worker connect chroma-mcp, note the subprocess PID
# (supervisor.json → processes["chroma-mcp"].pid)
# 2. Simulate a crash:
kill -9 <worker-pid>
# 3. Observe: chroma-mcp tree survives with PPID 1, entry still in supervisor.json
# 4. Let hooks (or `worker-service.cjs start`) bring up a new worker and
# trigger any chroma call
# 5. Observe: a second chroma-mcp tree is now running; the registry entry
# was overwritten; the orphan from step 3 lives forever
Repeat 2–5 and the orphan count grows by one each cycle. Under real-world conditions (OOM pressure, version-switch restarts, #3298-style thrash) this happens continuously.
Evidence from the affected machine
- 341 orphaned
uv tool uvx --python 3.13 … --from chroma-mcp==0.2.6 chroma-mcp --client-type persistent --data-dir ~/.claude-mem/chroma wrappers, PPID 1, plus their 341 python children.
- All spawned across ~15 h, matching worker restart churn.
vm.swapusage: 21.7 GB used of 22.5 GB; load average 293 on an 8-core machine.
- Force-quit dialog attributed 32.71 GB to the Claude app (sum of the orphan trees).
Proposed fix
Before spawning a replacement subprocess, consult the persisted registry entry for chroma-mcp:
- entry PID dead → just unregister it;
- entry PID alive and its command line verifiably a chroma-mcp launcher (
ps -p <pid> -o command= — guards against PID reuse) → killProcessTree(pid) then unregister;
- anything unexpected → best-effort, never block the connect path.
I have this implemented and validated (96/96 in the chroma/supervisor test suites, plus a live kill-9 reproduction on the affected machine where the new worker logged Reaping orphaned chroma-mcp left by a previous worker generation {pid=…} and the orphan was gone). PR incoming, will link it here.
Note: the same boot-time pattern may deserve consideration for stale-but-alive sdk entries too (#3270 mentions 58+ accumulated claude-mem processes), but this issue and the accompanying PR intentionally scope to the chroma-mcp leak.
🇧🇷 Versão em português (resumo)
Resumo
Toda vez que o worker morre sem shutdown gracioso (SIGKILL, crash, OOM), o subprocesso chroma-mcp sobrevive — e a geração seguinte do worker descarta silenciosamente o único registro dele. Resultado: uma árvore uvx → uv → python chroma-mcp vazada por morte abrupta do worker, sem limite.
Num Mac de 8 GB (macOS 25.5.0, claude-mem 13.11.0) isso acumulou 682 processos órfãos em ~15 horas (341 wrappers uvx + 341 instâncias python), enchendo 21,7 GB de swap, com load average de 293 e o alerta do macOS de "memória de aplicativos esgotada". A pressão de memória mata mais workers, que vazam mais órfãos — uma espiral que se retroalimenta.
Causa raiz
O ciclo de vida dentro do processo do worker é bem defendido (#2313). O buraco está na fronteira entre gerações, em três passos:
- Worker morre abruptamente. O filho chroma-mcp não está atrelado à vida do worker, sobrevive e reparenta para o init/launchd. O
supervisor.json ainda registra o PID dele.
- Próximo worker inicia.
pruneDeadEntries() só remove entradas de PIDs mortos — a entrada do órfão (vivo) fica no registro, mas nada age sobre ela.
- Novo worker conecta.
registerManagedProcess() sobrescreve a entrada com o PID do filho novo. O PID do órfão — que o registro conhecia segundos antes — é esquecido sem nunca receber sinal.
Correção proposta
Antes de cada spawn, consultar a entrada persistida do chroma-mcp: PID morto → só remove do registro; PID vivo e comprovadamente um chroma-mcp (checagem da linha de comando via ps, contra reuso de PID) → tree-kill e remove. Tudo best-effort: nunca bloqueia a conexão.
Implementado e validado (96/96 testes + reprodução real com kill -9 na máquina afetada). PR: #3302.
Summary
Every time the worker dies without a graceful shutdown (SIGKILL, crash, OOM kill), its chroma-mcp subprocess tree survives — and the next worker generation silently discards the only record of it. One leaked
uvx → uv → python chroma-mcptree per ungraceful worker death, unbounded across restarts.On an 8 GB macOS machine (Apple Silicon, macOS 25.5.0, claude-mem 13.11.0) this accumulated 682 orphaned processes (341 uvx wrappers + 341 python instances) in ~15 hours, filling 21.7 GB of swap and driving the load average to 293 until macOS started showing the "system has run out of application memory" dialog. Memory pressure makes workers die more often, which leaks more orphans — a self-reinforcing spiral.
Likely a major contributor to the process-accumulation reports in #3270 and related to the leak scenarios described in #3298.
Root cause
The in-process lifecycle in
ChromaMcpManageris well defended (disposeCurrentSubprocess(), tree-kill on failed connect,onclosesweep — #2313). The hole is at the worker-process-death boundary, and it takes three steps:supervisor.jsonstill records its PID under thechroma-mcpid.ProcessRegistry.initialize()loads the persisted entries and callspruneDeadEntries()— which only removes entries whose PID is dead (if (isPidAlive(info.pid)) continue;). The orphan's entry is alive, so it is kept... but nothing acts on it.connectInternal()→registerManagedProcess()→registry.register('chroma-mcp', …)overwrites the old entry with the new child's PID. The orphan's PID — which the registry knew about moments earlier — is forgotten without ever being signaled.The writer lock does not help: it records the worker's PID, not the chroma child's, so
isChromaWriterLockLive()correctly reports the lock stale (owner dead) and the new worker proceeds — right past the still-running previous chroma-mcp.Reproduction (macOS/Linux)
Repeat 2–5 and the orphan count grows by one each cycle. Under real-world conditions (OOM pressure, version-switch restarts, #3298-style thrash) this happens continuously.
Evidence from the affected machine
uv tool uvx --python 3.13 … --from chroma-mcp==0.2.6 chroma-mcp --client-type persistent --data-dir ~/.claude-mem/chromawrappers, PPID 1, plus their 341 python children.vm.swapusage: 21.7 GB used of 22.5 GB; load average 293 on an 8-core machine.Proposed fix
Before spawning a replacement subprocess, consult the persisted registry entry for
chroma-mcp:ps -p <pid> -o command=— guards against PID reuse) →killProcessTree(pid)then unregister;I have this implemented and validated (96/96 in the chroma/supervisor test suites, plus a live kill-9 reproduction on the affected machine where the new worker logged
Reaping orphaned chroma-mcp left by a previous worker generation {pid=…}and the orphan was gone). PR incoming, will link it here.Note: the same boot-time pattern may deserve consideration for stale-but-alive
sdkentries too (#3270 mentions 58+ accumulated claude-mem processes), but this issue and the accompanying PR intentionally scope to the chroma-mcp leak.🇧🇷 Versão em português (resumo)
Resumo
Toda vez que o worker morre sem shutdown gracioso (SIGKILL, crash, OOM), o subprocesso chroma-mcp sobrevive — e a geração seguinte do worker descarta silenciosamente o único registro dele. Resultado: uma árvore
uvx → uv → python chroma-mcpvazada por morte abrupta do worker, sem limite.Num Mac de 8 GB (macOS 25.5.0, claude-mem 13.11.0) isso acumulou 682 processos órfãos em ~15 horas (341 wrappers uvx + 341 instâncias python), enchendo 21,7 GB de swap, com load average de 293 e o alerta do macOS de "memória de aplicativos esgotada". A pressão de memória mata mais workers, que vazam mais órfãos — uma espiral que se retroalimenta.
Causa raiz
O ciclo de vida dentro do processo do worker é bem defendido (#2313). O buraco está na fronteira entre gerações, em três passos:
supervisor.jsonainda registra o PID dele.pruneDeadEntries()só remove entradas de PIDs mortos — a entrada do órfão (vivo) fica no registro, mas nada age sobre ela.registerManagedProcess()sobrescreve a entrada com o PID do filho novo. O PID do órfão — que o registro conhecia segundos antes — é esquecido sem nunca receber sinal.Correção proposta
Antes de cada spawn, consultar a entrada persistida do
chroma-mcp: PID morto → só remove do registro; PID vivo e comprovadamente um chroma-mcp (checagem da linha de comando viaps, contra reuso de PID) → tree-kill e remove. Tudo best-effort: nunca bloqueia a conexão.Implementado e validado (96/96 testes + reprodução real com
kill -9na máquina afetada). PR: #3302.