Summary
An MCP-mode server (memtrace mcp) cannot be stopped by any documented means:
memtrace stop does not apply to it — by its own help text it stops "the running memtrace start process" only.
- The native
memtrace mcp process ignores SIGTERM. Only SIGKILL ends it.
- Because the npm shim's documented contract is to "let the child's own exit drive the shim's exit", the shim also never exits — so a SIGTERM to the whole tree leaves both processes alive.
Net effect: the only way to stop an MCP-mode server is kill -9, which then skips whatever cleanup the process would otherwise do. Consistent with that, several kinds of state are left behind (details in Secondary findings).
Environment
|
|
| memtrace |
1.1.6-nightly.20260823.977d333 (nightly channel, global npm install) |
| Node / npm |
v22.23.2 / 10.9.8 |
| OS |
Ubuntu 24.04.4 LTS on WSL2, kernel 6.6.114.1-microsoft-standard-WSL2, x86_64 |
| Install path |
/usr/lib/node_modules/memtrace, shim symlinked at /usr/bin/memtrace |
| MCP client |
Claude Code (stdio transport) |
| MemDB mode |
local |
Reproduction
-
Let an MCP client spawn memtrace mcp over stdio (here: Claude Code).
-
Try the documented stop path:
$ memtrace status
MemDB mode: local
Data dir: /home/user/dev/<repo>/.memdb
Store present: yes
Graph counts: not loaded (status never opens a local MemDB store)
No reachable owner. Run `memtrace start`; unsafe legacy/mixed scope is refused before open.
status reports no owner and points at memtrace start, even though three memtrace processes are running. memtrace stop has nothing to act on.
-
Send SIGTERM directly to each PID — not to the shim alone, so signal forwarding is not a factor:
$ kill -TERM 77763 4926 4918 # cortex daemon, native mcp, npm shim
$ sleep 3 && ps -eo pid,ppid,stat,cmd | grep memtrace
4918 4878 Sl+ node /usr/bin/memtrace mcp
4926 4918 Sl+ /usr/lib/node_modules/memtrace/node_modules/@memtrace/linux-x64/bin/memtrace mcp
The memcortex-daemon (77763) exited on SIGTERM as expected. Both memtrace mcp processes survived and stayed in state Sl+.
-
SIGKILL is required:
$ kill -KILL 4926 4918 && sleep 2 && ps -eo pid,cmd | grep memtrace
# (nothing)
Process tree observed
claude (4878)
└── node /usr/bin/memtrace mcp (4918) ← npm shim
└── .../@memtrace/linux-x64/bin/memtrace mcp (4926) ← native MCP server
└── .../memcortex-daemon <store> <episodes.ndjson> <ingest.offset> 500 \
--serve <store>/cortex.sock (77763) ← Cortex daemon
Expected vs actual
|
Expected |
Actual |
SIGTERM to native memtrace mcp |
graceful shutdown: stop the Cortex daemon, release the lease, remove the socket, exit |
signal ignored; process keeps running |
| SIGTERM to the shim |
forwards to child, child exits, shim exits |
forwards correctly, but child never exits, so the shim waits forever |
| CLI stop path |
some supported way to stop an MCP-mode server, or stop/status at least acknowledging that MCP-mode servers exist |
stop targets memtrace start only; status reports "No reachable owner" while three processes are live |
The shim side looks correct and is not the bug: bin/memtrace.js:363-372 spawns the child asynchronously and calls forwardSignalsTo(child), and lib/child-supervisor.js forwards SIGINT/SIGTERM/SIGHUP by design. The missing piece is a SIGTERM handler in the native binary's mcp mode. Note that child-supervisor.js's header comment describes exactly this orphaning class of bug for start and says the mcp branch was fixed — but the fix addressed forwarding, not the child's own handling, so the mcp path still cannot be terminated by SIGTERM.
Secondary findings (state left behind)
These follow from the fact that the only way out is SIGKILL, but at least the first also occurs on a clean SIGTERM:
1. Cortex socket is not removed. The memcortex-daemon exits on SIGTERM but leaves its Unix socket behind:
$ ls -la ~/.memtrace/cortex-store/cortex.sock
srwxr-xr-x 1 user user 0 Aug 24 12:03 /home/user/.memtrace/cortex-store/cortex.sock
2. stdio-helper leases accumulate indefinitely. ~/.memtrace/stdio-helpers/ held 36 <pid>.lease files (each containing the string stdio); 35 belonged to PIDs that no longer existed, with mtimes spanning five days. Nothing appears to reap them, either at exit or on later startup:
$ cd ~/.memtrace/stdio-helpers && for f in *.lease; do
kill -0 "${f%.lease}" 2>/dev/null || echo "stale: $f"; done | wc -l
35
Since these are named by PID and never cleaned, a recycled PID will also collide with a stale lease.
3. Stale memtrace start runtime registry entries, with duplicate ports. ~/.memtrace/runtimes/ held three descriptors for dead PIDs (15147, 1536, 595120), two of which claim "uiPort": 3030:
{ "pid": 15147, "cwd": ".../repo-a", "uiPort": 3030, ... }
{ "pid": 1536, "cwd": ".../repo-b", "uiPort": 3030, ... }
{ "pid": 595120,"cwd": ".../repo-c", "uiPort": 3032, ... }
Given the child-supervisor.js comments about "already running" / port-in-use reports, stale descriptors that are never invalidated against a liveness check look like a contributing factor there too.
Suggested fixes
- Install a SIGTERM (and SIGHUP) handler in the native binary's
mcp mode that runs the same shutdown path as stdin-EOF: terminate/detach the Cortex daemon, unlink the socket, drop the lease, exit non-hanging.
- Unlink
cortex.sock on daemon shutdown, and treat an existing-but-unconnectable socket as stale on startup rather than trusting its presence.
- Reap
stdio-helpers/*.lease whose PID is dead — on startup at minimum, since a SIGKILLed process can never clean up its own lease. Same for runtimes/*.json: validate pid + processStartedAtEpochSecs for liveness before honoring a descriptor or reporting a port as taken.
- Make the CLI honest about MCP-mode servers: have
status list them instead of reporting "No reachable owner", and give stop a way to target them (or state explicitly that MCP-mode lifetime is owned by the client's stdio).
Workaround
pkill -KILL -f 'memtrace mcp'; pkill -TERM -f memcortex-daemon
rm -f ~/.memtrace/cortex-store/cortex.sock
cd ~/.memtrace/stdio-helpers && for f in *.lease; do
kill -0 "${f%.lease}" 2>/dev/null || rm -f "$f"
done
Note that this kills the MCP server out from under any live client, which then needs to reconnect or respawn it.
Summary
An MCP-mode server (
memtrace mcp) cannot be stopped by any documented means:memtrace stopdoes not apply to it — by its own help text it stops "the runningmemtrace startprocess" only.memtrace mcpprocess ignores SIGTERM. Only SIGKILL ends it.Net effect: the only way to stop an MCP-mode server is
kill -9, which then skips whatever cleanup the process would otherwise do. Consistent with that, several kinds of state are left behind (details in Secondary findings).Environment
1.1.6-nightly.20260823.977d333(nightly channel, global npm install)/usr/lib/node_modules/memtrace, shim symlinked at/usr/bin/memtraceReproduction
Let an MCP client spawn
memtrace mcpover stdio (here: Claude Code).Try the documented stop path:
statusreports no owner and points atmemtrace start, even though three memtrace processes are running.memtrace stophas nothing to act on.Send SIGTERM directly to each PID — not to the shim alone, so signal forwarding is not a factor:
The
memcortex-daemon(77763) exited on SIGTERM as expected. Bothmemtrace mcpprocesses survived and stayed in stateSl+.SIGKILL is required:
Process tree observed
Expected vs actual
memtrace mcpstop/statusat least acknowledging that MCP-mode servers existstoptargetsmemtrace startonly;statusreports "No reachable owner" while three processes are liveThe shim side looks correct and is not the bug:
bin/memtrace.js:363-372spawns the child asynchronously and callsforwardSignalsTo(child), andlib/child-supervisor.jsforwardsSIGINT/SIGTERM/SIGHUPby design. The missing piece is a SIGTERM handler in the native binary'smcpmode. Note thatchild-supervisor.js's header comment describes exactly this orphaning class of bug forstartand says themcpbranch was fixed — but the fix addressed forwarding, not the child's own handling, so themcppath still cannot be terminated by SIGTERM.Secondary findings (state left behind)
These follow from the fact that the only way out is SIGKILL, but at least the first also occurs on a clean SIGTERM:
1. Cortex socket is not removed. The
memcortex-daemonexits on SIGTERM but leaves its Unix socket behind:2. stdio-helper leases accumulate indefinitely.
~/.memtrace/stdio-helpers/held 36<pid>.leasefiles (each containing the stringstdio); 35 belonged to PIDs that no longer existed, with mtimes spanning five days. Nothing appears to reap them, either at exit or on later startup:Since these are named by PID and never cleaned, a recycled PID will also collide with a stale lease.
3. Stale
memtrace startruntime registry entries, with duplicate ports.~/.memtrace/runtimes/held three descriptors for dead PIDs (15147, 1536, 595120), two of which claim"uiPort": 3030:{ "pid": 15147, "cwd": ".../repo-a", "uiPort": 3030, ... } { "pid": 1536, "cwd": ".../repo-b", "uiPort": 3030, ... } { "pid": 595120,"cwd": ".../repo-c", "uiPort": 3032, ... }Given the
child-supervisor.jscomments about "already running" / port-in-use reports, stale descriptors that are never invalidated against a liveness check look like a contributing factor there too.Suggested fixes
mcpmode that runs the same shutdown path as stdin-EOF: terminate/detach the Cortex daemon, unlink the socket, drop the lease, exit non-hanging.cortex.sockon daemon shutdown, and treat an existing-but-unconnectable socket as stale on startup rather than trusting its presence.stdio-helpers/*.leasewhose PID is dead — on startup at minimum, since a SIGKILLed process can never clean up its own lease. Same forruntimes/*.json: validatepid+processStartedAtEpochSecsfor liveness before honoring a descriptor or reporting a port as taken.statuslist them instead of reporting "No reachable owner", and givestopa way to target them (or state explicitly that MCP-mode lifetime is owned by the client's stdio).Workaround
Note that this kills the MCP server out from under any live client, which then needs to reconnect or respawn it.