fix(mcp): a failed handshake must not strand the child process
Nightly Build / build (push) Successful in 4m5s
Nightly Build / build (push) Successful in 4m5s
An MCP server whose `initialize` answer is an error — a broken or version- mismatched connector — starts fine and then never exits. `McpServer::start` returned `Err` correctly, but the `Child` lives in the read-loop task rather than in the returned value, so `kill_on_drop` followed a task nothing ever drops. Every retry therefore left a live process holding three pipes and a pidfd, and the supervisor's retry ceiling is deliberately not permanent. The end state was not a dead connector but a dead instance: the process hit its 1024-descriptor limit, `accept()` began failing with EMFILE, and incoming connections queued on a socket nobody could accept from — while the process, the port and every other connector still looked healthy. Observed in production at ~5h from the first bad handshake to unreachable, with 229 orphaned interpreters. `stop_server`/`stop_all` had the same hole from the other side: they document the dropped handle as killing the process, but the task holds its end of stdin, so the child stayed blocked on a read that would never return. Both close with one seam. `McpServer` now owns a oneshot sender whose receiver the read-loop selects on; nothing ever sends, so the drop is the message. That covers a deliberate stop, the last `Arc` going away, and a `?` in `start()` unwinding past the local binding before it was ever returned — including the caller's `timeout`, which drops the same future. The loop then kills and, as importantly, reaps: an unreaped child trades the orphan for a zombie holding the same pipes. Both leaks are covered by tests that fail without the fix. Also raise LimitNOFILE to 65536: the installers write it, and update.sh heals an existing unit additively, leaving an admin's own value alone. The leak is the bug, but 1024 for a process sharing descriptors between the listener, every user's SQLite handles and three pipes per connector is thin regardless.
This commit is contained in:
@@ -410,8 +410,9 @@ impl McpManager {
|
||||
Ok(tool_names)
|
||||
}
|
||||
|
||||
/// Stops a running server (dropping the client → `kill_on_drop`) and forgets
|
||||
/// it. DB removal is the caller's responsibility.
|
||||
/// Stops a running server (dropping the last handle kills its child — see
|
||||
/// `McpServer::_kill_on_drop`) and forgets it. DB removal is the caller's
|
||||
/// responsibility.
|
||||
pub fn stop_server(&self, name: &str) {
|
||||
self.servers.write().unwrap().remove(name);
|
||||
self.errors.write().unwrap().remove(name);
|
||||
@@ -423,8 +424,8 @@ impl McpManager {
|
||||
self.respawns.write().unwrap().remove(name);
|
||||
}
|
||||
|
||||
/// Stops **every** running server (each dropped client → `kill_on_drop` kills
|
||||
/// its child process) and forgets them. Used when a per-user container is
|
||||
/// Stops **every** running server (dropping each handle kills its child — see
|
||||
/// `McpServer::_kill_on_drop`) and forgets them. Used when a per-user container is
|
||||
/// recreated (§6 remount): the old `docker exec -i` children are bound to the
|
||||
/// now-gone container, so they must be torn down before reconnecting against
|
||||
/// the fresh one via [`connect_all`](Self::connect_all).
|
||||
|
||||
Reference in New Issue
Block a user