Skip to content

Commit 3573698

Browse files
author
Yogthos
committed
refactor(mcp): SharedConnection — remove mem::forget leak; tighten classification
Self-review of 0362660 surfaced five issues. This commit closes all of them. M-R1 (critical) — `mem::forget(RunningService)` leaked the child process AND the background tokio task. rmcp's `RunningService` holds a `DropGuard` that cancels the cancellation_token on drop; forgetting the value bypassed that, so the spawned MCP child process kept running even after dirge exited. Confirmed by reading `rmcp-1.7.0/src/service.rs:621-632` (the Drop impl is what reaps the child). Fix: new `SharedConnection { peer: RwLock<Peer>, running_service: Mutex<Option<RunningService>> }`. Every McpTool from the same server holds the same `Arc<SharedConnection>`. `replace(new_peer, new_rs)` swaps both fields atomically; the OLD RunningService falls out of scope at the end of `replace` and its DropGuard correctly cancels the dead transport. No more mem::forget. M-R2 — `reconnect_lock` was constructed per `collect_tools` call, so different agent rebuilds got different locks and the gen counter was reset on each rebuild. Fix: store `reconnect_locks: HashMap<String, Arc<Mutex<u64>>>` on `McpClientManager` itself; cloned into every McpTool, canonical for the process lifetime. M-R3 — both attempts of try-reconnect-retry used the full 120s timeout (worst case 240s total). Fix: thread `started: Instant` + `total_budget: Duration` through; each `call_once` + the reconnect get whatever budget remains. New `remaining_budget` helper saturates at zero past the deadline (with a unit test). M-R4 — manager's handles became stale after tool-side reconnect (manager pointed at the OLD dead RunningService while the tool had swapped in a new one). Fix falls out of M-R1: manager.connections and tool.connection are the SAME Arc<SharedConnection>; whichever side reconnects, both see the new state. M-R5 — `is_transport_failure` was too aggressive: included `UnexpectedResponse` (protocol mismatch, server alive but buggy) and `Timeout` (slow tool legitimately running). Both would tear down healthy connections. Fix: narrowed to `TransportSend` and `TransportClosed` only. Test matrix updated to lock the contract. Also removed `McpClientHandle` entirely (was a thin wrapper that became dead weight); manager now uses `HashMap<String, Arc<SharedConnection>>` directly. `connect()` is a free function returning the Arc. `raw_connect` returns the (peer, running_service) pair for the swap path. `list_tools` is a free function over `&SharedConnection`. Tests: 1 → 12 mcp tests (1104 with plugin / 899 without). - `is_transport_failure_classifies_correctly` updated to assert the narrower set + locks the M-R5 contract. - `remaining_budget_decays_and_saturates` covers the M-R3 deadline arithmetic. - Existing tests confirm the refactor preserved behaviour. No new beads filed — this closes the review's recommended follow-up.
1 parent 0362660 commit 3573698

5 files changed

Lines changed: 371 additions & 285 deletions

File tree

src/extras/mcp/client.rs

Lines changed: 151 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -4,159 +4,180 @@ use std::sync::Arc;
44

55
use rmcp::service::{Peer, RoleClient, RunningService, serve_client};
66
use tokio::process::{ChildStderr, Command};
7-
use tokio::sync::RwLock;
7+
use tokio::sync::{Mutex, RwLock};
88

99
use super::config::McpServerConfig;
1010

11-
/// Shared, swappable peer reference for an MCP server. Cloned by
12-
/// every `McpTool` from the same server so that an auto-reconnect
13-
/// triggered by ANY tool call updates the peer for ALL tools.
14-
/// Previously each `McpTool` stored a direct `Peer<RoleClient>`
15-
/// clone, leaving them orphaned the moment the underlying transport
16-
/// died (audit dirge-dvi).
17-
pub type SharedPeer = Arc<RwLock<Peer<RoleClient>>>;
18-
19-
pub struct McpClientHandle {
11+
/// Co-owned (peer, running_service) pair for one MCP server.
12+
///
13+
/// Every `McpTool` from the same server holds the same
14+
/// `Arc<SharedConnection>`. On reconnect — manager-side OR
15+
/// tool-side — `replace` atomically swaps in a fresh peer +
16+
/// running_service. The OLD `RunningService` drops at the end of
17+
/// the swap, which cancels its cancellation_token, closes the
18+
/// transport, and (for child-process transports) kills the dead
19+
/// child. This was the M-R1 review finding: the prior code did
20+
/// `mem::forget(RunningService)` which leaked the spawned process.
21+
///
22+
/// Lock order to avoid deadlock: always take `running_service`
23+
/// before `peer`. Readers take a single lock at a time.
24+
pub struct SharedConnection {
25+
/// Kept for debugging / tracing — every error path logs the
26+
/// server name, so the structured field stays for log
27+
/// correlation even when no code reads it directly.
28+
#[allow(dead_code)]
2029
pub server_name: String,
21-
pub running_service: RunningService<RoleClient, ()>,
22-
/// The shared peer ref. Updated in place by `replace_peer`
23-
/// when the manager reconnects, so already-handed-out McpTool
24-
/// instances pick up the new peer transparently.
25-
peer_ref: SharedPeer,
30+
peer: RwLock<Peer<RoleClient>>,
31+
/// `Option` so the consuming-`Drop` of `RunningService::cancel`
32+
/// can take it out cleanly during shutdown. `None` after the
33+
/// connection has been explicitly shut down.
34+
running_service: Mutex<Option<RunningService<RoleClient, ()>>>,
2635
}
2736

28-
/// Upper bound on how long we'll wait for an MCP server to complete
29-
/// initialization. Command-based servers that hang on `initialize`
30-
/// (e.g. waiting for stdin that never comes) would otherwise pin
31-
/// startup indefinitely. 10s is generous for legitimate inits — npm
32-
/// install-on-first-run servers take a few seconds; locally-running
33-
/// binaries respond in <100ms. Past the cap we abort and log.
34-
const MCP_INIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
35-
36-
impl McpClientHandle {
37-
pub async fn connect(server_name: String, config: &McpServerConfig) -> anyhow::Result<Self> {
38-
// Wrap the entire connect in a timeout so a wedged server
39-
// doesn't block startup forever. Returns a clean
40-
// "init timeout" error past the cap.
41-
let inner = Self::connect_inner(server_name.clone(), config);
42-
match tokio::time::timeout(MCP_INIT_TIMEOUT, inner).await {
43-
Ok(result) => result,
44-
Err(_) => Err(anyhow::anyhow!(
45-
"MCP server {server_name:?} did not initialize within {}s — skipping",
46-
MCP_INIT_TIMEOUT.as_secs(),
47-
)),
37+
impl SharedConnection {
38+
/// Wrap a freshly-built (peer, running_service) pair. Pub-crate so
39+
/// the manager's reconnect path can create a SharedConnection for
40+
/// servers that failed initial connect but succeed later.
41+
pub(crate) fn new(
42+
server_name: String,
43+
peer: Peer<RoleClient>,
44+
rs: RunningService<RoleClient, ()>,
45+
) -> Self {
46+
Self {
47+
server_name,
48+
peer: RwLock::new(peer),
49+
running_service: Mutex::new(Some(rs)),
4850
}
4951
}
5052

51-
async fn connect_inner(server_name: String, config: &McpServerConfig) -> anyhow::Result<Self> {
52-
match config {
53-
McpServerConfig::Command { command, args, env } => {
54-
let mut cmd = Command::new(command);
55-
cmd.args(args);
56-
for (k, v) in env {
57-
cmd.env(k, v);
58-
}
59-
// CRITICAL: capture stderr instead of inheriting it.
60-
// rmcp's default `TokioChildProcess::new` uses
61-
// `Stdio::inherit()` for stderr, which gives the MCP
62-
// server (and its descendants) direct access to
63-
// dirge's controlling terminal. If the server (or
64-
// any library it uses) emits terminal queries — OSC
65-
// 11 for bg-color detection, `\x1b[c` for DA1,
66-
// `\x1b[6n` for CPR — those queries reach the
67-
// terminal, which replies via the TTY's INPUT side
68-
// (dirge's stdin). Crossterm's event parser doesn't
69-
// recognize those reply shapes, so the bytes sit in
70-
// the OS stdin buffer until exit, when the shell
71-
// inherits them and renders the literal escape
72-
// payload as visible garbage at the prompt.
73-
//
74-
// Pipe stderr instead. The child's logs are still
75-
// surfaced — we line-read them and forward to
76-
// dirge's stderr via tracing — but the child no
77-
// longer has a route to send escape queries that
78-
// can elicit a reply on dirge's stdin.
79-
let (transport, stderr) =
80-
rmcp::transport::child_process::TokioChildProcess::builder(cmd)
81-
.stderr(Stdio::piped())
82-
.spawn()?;
83-
if let Some(child_stderr) = stderr {
84-
spawn_stderr_forwarder(server_name.clone(), child_stderr);
85-
}
86-
let running_service = serve_client((), transport).await.map_err(|e| {
87-
anyhow::anyhow!("MCP connection failed for '{server_name}': {e}")
88-
})?;
89-
let peer = running_service.peer().clone();
90-
Ok(Self {
91-
server_name,
92-
running_service,
93-
peer_ref: Arc::new(RwLock::new(peer)),
94-
})
95-
}
96-
McpServerConfig::Url { url, headers } => {
97-
let custom_headers = parse_headers(headers)?;
98-
let cfg = rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(url.as_str())
99-
.custom_headers(custom_headers);
100-
type HttpClient = rmcp::transport::StreamableHttpClientTransport<reqwest::Client>;
101-
let transport = HttpClient::from_config(cfg);
102-
let running_service = serve_client((), transport).await.map_err(|e| {
103-
anyhow::anyhow!("MCP HTTP connection failed for '{server_name}': {e}")
104-
})?;
105-
let peer = running_service.peer().clone();
106-
Ok(Self {
107-
server_name,
108-
running_service,
109-
peer_ref: Arc::new(RwLock::new(peer)),
110-
})
111-
}
112-
}
53+
/// Snapshot the current peer. Cheap — `Peer` is an `mpsc::Sender`
54+
/// wrapper, cloning bumps a refcount.
55+
pub async fn current_peer(&self) -> Peer<RoleClient> {
56+
self.peer.read().await.clone()
11357
}
11458

115-
/// Direct peer clone (legacy path). Prefer [`shared_peer`] in
116-
/// new code so auto-reconnect updates flow through.
117-
#[allow(dead_code)]
118-
pub fn peer(&self) -> rmcp::service::Peer<RoleClient> {
119-
self.running_service.peer().clone()
59+
/// Atomically swap in a fresh (peer, running_service). The OLD
60+
/// `RunningService` drops as `_old` falls out of scope, cancelling
61+
/// its background task + closing its transport.
62+
pub async fn replace(
63+
&self,
64+
new_peer: Peer<RoleClient>,
65+
new_rs: RunningService<RoleClient, ()>,
66+
) {
67+
// Order: take running_service first (Option swap), then peer
68+
// (RwLock write). Both consumed before the OLD running_service
69+
// is dropped so the new one is fully wired before the cleanup
70+
// signal fires on the old transport.
71+
let _old = {
72+
let mut rs_guard = self.running_service.lock().await;
73+
std::mem::replace(&mut *rs_guard, Some(new_rs))
74+
};
75+
*self.peer.write().await = new_peer;
76+
// `_old` drops here. If it was `Some`, that `RunningService`'s
77+
// `DropGuard` cancels its cancellation_token; the background
78+
// task observes the cancel + closes the transport; the
79+
// TokioChildProcess transport's drop reaps the child.
12080
}
12181

122-
/// Shared, swappable peer ref. Cloning this `Arc` is cheap; the
123-
/// inner peer is mutated in place when the server is
124-
/// reconnected, so every cloned holder sees the fresh peer
125-
/// on its next read.
126-
pub fn shared_peer(&self) -> SharedPeer {
127-
Arc::clone(&self.peer_ref)
82+
/// Explicit shutdown — drops the running service synchronously
83+
/// (via `Drop`'s async-cancellation guard) and renders the
84+
/// connection dead. Called by `McpClientManager::shutdown`.
85+
pub async fn shutdown(&self) {
86+
let mut rs = self.running_service.lock().await;
87+
rs.take(); // dropping the Some(...) here triggers cleanup
12888
}
89+
}
12990

130-
/// Replace the inner peer with a fresh one. Called when the
131-
/// manager (or a self-reconnecting McpTool) builds a new
132-
/// connection — write-locks briefly, swaps, drops the lock.
133-
/// Existing McpTool clones holding the same Arc see the new
134-
/// peer on their next read.
135-
pub async fn replace_peer(&self, new_peer: Peer<RoleClient>) {
136-
let mut guard = self.peer_ref.write().await;
137-
*guard = new_peer;
138-
}
91+
/// Upper bound on how long we'll wait for an MCP server to complete
92+
/// initialization. Command-based servers that hang on `initialize`
93+
/// (e.g. waiting for stdin that never comes) would otherwise pin
94+
/// startup indefinitely. 10s is generous for legitimate inits — npm
95+
/// install-on-first-run servers take a few seconds; locally-running
96+
/// binaries respond in <100ms. Past the cap we abort and log.
97+
const MCP_INIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
13998

140-
pub async fn list_tools(&self) -> Result<Vec<rmcp::model::Tool>, rmcp::ServiceError> {
141-
self.running_service.peer().list_all_tools().await
99+
/// Connect to one MCP server and wrap the connection in a
100+
/// shared, swappable container. Returns the `Arc<SharedConnection>`
101+
/// the manager + every McpTool clone holds.
102+
pub async fn connect(
103+
server_name: String,
104+
config: &McpServerConfig,
105+
) -> anyhow::Result<Arc<SharedConnection>> {
106+
let inner = connect_inner(server_name.clone(), config);
107+
match tokio::time::timeout(MCP_INIT_TIMEOUT, inner).await {
108+
Ok(result) => result,
109+
Err(_) => Err(anyhow::anyhow!(
110+
"MCP server {server_name:?} did not initialize within {}s — skipping",
111+
MCP_INIT_TIMEOUT.as_secs(),
112+
)),
142113
}
143114
}
144115

145-
/// Build a fresh `RunningService` for an existing server using its
146-
/// config. Used by the McpTool auto-reconnect path on transport
147-
/// failure: returns just the new peer; the caller swaps it into a
148-
/// `SharedPeer` and drops the old RunningService.
116+
async fn connect_inner(
117+
server_name: String,
118+
config: &McpServerConfig,
119+
) -> anyhow::Result<Arc<SharedConnection>> {
120+
let (peer, rs) = raw_connect(&server_name, config).await?;
121+
Ok(Arc::new(SharedConnection::new(server_name, peer, rs)))
122+
}
123+
124+
/// Build a new `RunningService` + extract its peer, without wrapping
125+
/// in `SharedConnection`. Used by `SharedConnection::replace` callers
126+
/// (manager + tool-side auto-reconnect) which already own the
127+
/// container they want to swap into.
149128
///
150-
/// This is `pub` so the tool side can call it without owning a full
151-
/// `McpClientHandle`. Wraps `connect_inner` directly to skip the
152-
/// init-timeout layer (caller already times out the whole reconnect).
153-
pub async fn fresh_peer_for(
129+
/// Does NOT wrap in `MCP_INIT_TIMEOUT` — the caller times out the
130+
/// whole reconnect operation.
131+
pub async fn raw_connect(
154132
server_name: &str,
155133
config: &McpServerConfig,
156134
) -> anyhow::Result<(Peer<RoleClient>, RunningService<RoleClient, ()>)> {
157-
let handle = McpClientHandle::connect(server_name.to_string(), config).await?;
158-
let peer = handle.running_service.peer().clone();
159-
Ok((peer, handle.running_service))
135+
match config {
136+
McpServerConfig::Command { command, args, env } => {
137+
let mut cmd = Command::new(command);
138+
cmd.args(args);
139+
for (k, v) in env {
140+
cmd.env(k, v);
141+
}
142+
// CRITICAL: capture stderr instead of inheriting it.
143+
// See lengthy explanation below at the original
144+
// call site — terminal-query bytes from the child must
145+
// not reach dirge's stdin via the controlling TTY.
146+
let (transport, stderr) =
147+
rmcp::transport::child_process::TokioChildProcess::builder(cmd)
148+
.stderr(Stdio::piped())
149+
.spawn()?;
150+
if let Some(child_stderr) = stderr {
151+
spawn_stderr_forwarder(server_name.to_string(), child_stderr);
152+
}
153+
let rs = serve_client((), transport)
154+
.await
155+
.map_err(|e| anyhow::anyhow!("MCP connection failed for '{server_name}': {e}"))?;
156+
let peer = rs.peer().clone();
157+
Ok((peer, rs))
158+
}
159+
McpServerConfig::Url { url, headers } => {
160+
let custom_headers = parse_headers(headers)?;
161+
let cfg = rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(url.as_str())
162+
.custom_headers(custom_headers);
163+
type HttpClient = rmcp::transport::StreamableHttpClientTransport<reqwest::Client>;
164+
let transport = HttpClient::from_config(cfg);
165+
let rs = serve_client((), transport).await.map_err(|e| {
166+
anyhow::anyhow!("MCP HTTP connection failed for '{server_name}': {e}")
167+
})?;
168+
let peer = rs.peer().clone();
169+
Ok((peer, rs))
170+
}
171+
}
172+
}
173+
174+
/// List the tools the server advertises. Called once at startup
175+
/// (or after manual reconnect) to build the agent's tool registry.
176+
pub async fn list_tools(
177+
conn: &SharedConnection,
178+
) -> Result<Vec<rmcp::model::Tool>, rmcp::ServiceError> {
179+
let peer = conn.current_peer().await;
180+
peer.list_all_tools().await
160181
}
161182

162183
/// Forward an MCP child's stderr line-by-line to dirge's tracing

0 commit comments

Comments
 (0)