Skip to content

qmd mcp (stdio) doesn't exit on stdin EOF — orphans to PID 1 and leaks RAM after parent dies #751

Description

@punkhop

Summary

When qmd mcp runs in stdio mode and its parent process (an MCP client like Claude Code / Codex) dies, the qmd process does not exit. It reparents to launchd/init (PPID=1) and lingers indefinitely, holding the open SQLite store + the native embedding model in memory. Across a day of client sessions these pile up — I was seeing multiple orphaned MCP servers eating hundreds of MB each on macOS.

Root cause

@modelcontextprotocol/sdk's StdioServerTransport registers stdin data/error listeners only — it never listens for end/close. So on parent-death EOF the transport's onclose never fires and nothing tears the process down.

The HTTP path already handles shutdown (SIGTERM / explicit exit), but the stdio path (startMcpServer, roughly src/mcp/server.ts) has no EOF/onclose/SIGTERM handling. Because qmd holds a native embedding binding + an open store, the event loop stays alive even with stdin gone, so it never exits naturally.

Repro

  1. Spawn qmd mcp from a parent process with piped stdin.
  2. Kill the parent (or close its stdin).
  3. Observe the qmd process survive, reparented to PID 1: ps -o pid,ppid,rss,command -p <pid>.

Suggested fix

Exit when stdin closes, after await server.connect(transport):

process.stdin.on("close", () => {
  // tear down the store + embedding model first, then exit
  shutdown();        // close SQLite, release native model
  process.exit(0);
});

Caveat — do the teardown cleanly

process.exit() during native-addon unload has bitten qmd before:

So the stdio EOF handler should release the embedding model / store gracefully before exiting (or set process.exitCode and let the loop drain) to avoid re-triggering those exit-time crashes.

Context

I patched the same @modelcontextprotocol/sdk stdin-EOF gap in two of my own MCP servers with a one-line process.stdin.on("close", …); qmd is the remaining offender in my setup and the only one I can't patch since it's an installed package. Happy to send a PR if you'd like — just wanted to flag the exit-time-crash interaction first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions