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
- Spawn
qmd mcp from a parent process with piped stdin.
- Kill the parent (or close its stdin).
- 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.
Summary
When
qmd mcpruns 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'sStdioServerTransportregisters stdindata/errorlisteners only — it never listens forend/close. So on parent-death EOF the transport'sonclosenever fires and nothing tears the process down.The HTTP path already handles shutdown (SIGTERM / explicit exit), but the stdio path (
startMcpServer, roughlysrc/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
qmd mcpfrom a parent process with piped stdin.ps -o pid,ppid,rss,command -p <pid>.Suggested fix
Exit when stdin closes, after
await server.connect(transport):Caveat — do the teardown cleanly
process.exit()during native-addon unload has bitten qmd before:process.exit()during native addon unload)So the stdio EOF handler should release the embedding model / store gracefully before exiting (or set
process.exitCodeand let the loop drain) to avoid re-triggering those exit-time crashes.Context
I patched the same
@modelcontextprotocol/sdkstdin-EOF gap in two of my own MCP servers with a one-lineprocess.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.