Evidence
interfaces/hermes-plugin/mcp_client.py:51-58:
@property
def alive(self) -> bool:
# A child whose stdout reader has ended (EOF or reader crash) can
# never answer again, so it counts as dead even if the PID lingers
return (self._proc is not None and self._proc.poll() is None and not self._reader_dead)
interfaces/hermes-plugin/provider.py:305-312:
if self._client is None or not self._client.alive:
client = McpStdioClient(self._server_command(), env=self._child_env(), cwd=...)
client.start()
self._client = client # old client dropped, stop() never called
stop() (mcp_client.py:109-…) is the only path that closes stdin, waits, and kills the process group.
Why it's a bug
When only the reader thread died (_reader_dead true, poll() is None), the old node dist/interfaces/mcp/server.js keeps running with its embedding model and database connection open, and nothing ever signals it; each recurrence adds another ~300 MB process. When the process did exit, it is never wait()ed until CPython's GC finalises the Popen, holding pipe FDs meanwhile. Trigger is a reader-thread exception (malformed frame, decode error) on the stdio transport — rare, so P3.
Suggested direction
Capture the old client and call old.stop() (best-effort, short grace) before assigning the replacement; stop() already tolerates an exited process.
Related: #41, #17.
Evidence
interfaces/hermes-plugin/mcp_client.py:51-58:interfaces/hermes-plugin/provider.py:305-312:stop()(mcp_client.py:109-…) is the only path that closes stdin, waits, and kills the process group.Why it's a bug
When only the reader thread died (
_reader_deadtrue,poll() is None), the oldnode dist/interfaces/mcp/server.jskeeps running with its embedding model and database connection open, and nothing ever signals it; each recurrence adds another ~300 MB process. When the process did exit, it is neverwait()ed until CPython's GC finalises thePopen, holding pipe FDs meanwhile. Trigger is a reader-thread exception (malformed frame, decode error) on the stdio transport — rare, so P3.Suggested direction
Capture the old client and call
old.stop()(best-effort, short grace) before assigning the replacement;stop()already tolerates an exited process.Related: #41, #17.