Skip to content

memtrace mcp ignores SIGTERM and has no supported stop path; leaves stale socket, leases, and runtime registry entries #79

Description

@MartinJepsen

Summary

An MCP-mode server (memtrace mcp) cannot be stopped by any documented means:

  1. memtrace stop does not apply to it — by its own help text it stops "the running memtrace start process" only.
  2. The native memtrace mcp process ignores SIGTERM. Only SIGKILL ends it.
  3. 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

  1. Let an MCP client spawn memtrace mcp over stdio (here: Claude Code).

  2. 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.

  3. 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+.

  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions