lean_ctx/core/gateway/pool.rs
1//! Persistent downstream MCP session pool (#1078).
2//!
3//! Without pooling, every `ctx_tools` list/call reopened a connection — spawning
4//! a fresh child process and re-running the MCP `initialize` handshake — so each
5//! tool call paid the full spawn+handshake latency. This pool keeps one live
6//! [`ClientService`] per distinct wiring and reuses it across calls:
7//!
8//! - **keyed** by the resolved transport (same command/args/env/caps/url → same
9//! session), so two different servers never share a child,
10//! - **idle-evicted**: a session unused for `IDLE_TTL` is dropped on the next
11//! access (closing the child's stdin → the server exits), swept opportunistically
12//! on every [`acquire`],
13//! - **liveness-checked**: [`acquire`] also drops any session whose transport has
14//! closed (the child exited/crashed) *before* handing one out, so a request is
15//! never sent into a dead pipe — and callers never have to blindly re-send a
16//! request to recover (which could double-execute a non-idempotent tool).
17//!
18//! The map lock is a `std::sync::Mutex` held only for short, await-free critical
19//! sections (the slow `open()` runs outside the lock), which keeps [`clear`]
20//! callable from the synchronous config/catalog paths.
21
22use std::collections::HashMap;
23use std::collections::hash_map::DefaultHasher;
24use std::hash::{Hash, Hasher};
25use std::sync::{Arc, Mutex, OnceLock};
26use std::time::{Duration, Instant};
27
28use super::client::{self, ClientService};
29use super::config::ResolvedTransport;
30
31/// Drop a pooled session after this long without use, so an idle child process
32/// does not linger. The next call for that wiring transparently reopens.
33const IDLE_TTL: Duration = Duration::from_mins(5);
34
35struct Entry {
36 service: Arc<ClientService>,
37 last_used: Instant,
38}
39
40fn pool() -> &'static Mutex<HashMap<u64, Entry>> {
41 static POOL: OnceLock<Mutex<HashMap<u64, Entry>>> = OnceLock::new();
42 POOL.get_or_init(|| Mutex::new(HashMap::new()))
43}
44
45/// Stable identity for a resolved transport: same wiring → same key → same
46/// pooled session. Derived from the transport's `Debug` form so every field
47/// (command/args/env/binary pin/capabilities, or url/headers) is captured.
48#[must_use]
49pub fn key(transport: &ResolvedTransport) -> u64 {
50 let mut h = DefaultHasher::new();
51 format!("{transport:?}").hash(&mut h);
52 h.finish()
53}
54
55/// A live session for `transport`, reusing a pooled one when present + fresh, or
56/// opening (and caching) a new one. Sweeps idle sessions on the way in.
57pub async fn acquire(
58 transport: &ResolvedTransport,
59 timeout: Duration,
60) -> Result<Arc<ClientService>, String> {
61 let k = key(transport);
62 {
63 let mut map = lock();
64 let now = Instant::now();
65 // Sweep on the way in: drop sessions that are idle-expired *or* whose
66 // transport has closed (child exited/crashed). Dropping an Entry releases
67 // its `ClientService`, which tears down the child. A dead session is thus
68 // never handed out, so callers never send into a dead pipe.
69 map.retain(|_, e| now.duration_since(e.last_used) < IDLE_TTL && !e.service.is_closed());
70 if let Some(entry) = map.get_mut(&k) {
71 entry.last_used = now;
72 return Ok(entry.service.clone());
73 }
74 }
75
76 // Open outside the lock: a slow connect must not block other servers, and we
77 // never hold a std Mutex across an await.
78 let service = Arc::new(client::open(transport, timeout).await?);
79 let mut map = lock();
80 // A racing first-call may have inserted already; last writer wins and the
81 // loser's child is closed when its Arc drops.
82 map.insert(
83 k,
84 Entry {
85 service: service.clone(),
86 last_used: Instant::now(),
87 },
88 );
89 Ok(service)
90}
91
92/// Drop the pooled session for `key` (e.g. after a transport-level failure), so
93/// the next [`acquire`] reopens a fresh one.
94pub fn evict(key: u64) {
95 lock().remove(&key);
96}
97
98/// Drop every pooled session (closing all children). Called when the gateway
99/// wiring changes (install/remove/revoke → [`super::catalog::invalidate`]).
100pub fn clear() {
101 lock().clear();
102}
103
104/// Number of live pooled sessions (test/diagnostic helper).
105#[must_use]
106pub fn len() -> usize {
107 lock().len()
108}
109
110fn lock() -> std::sync::MutexGuard<'static, HashMap<u64, Entry>> {
111 // A poisoned lock only means a previous holder panicked mid-map-op; the map
112 // is still structurally valid, so recover rather than propagate the panic.
113 pool()
114 .lock()
115 .unwrap_or_else(std::sync::PoisonError::into_inner)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use std::collections::BTreeMap;
122
123 fn stdio(cmd: &str) -> ResolvedTransport {
124 ResolvedTransport::Stdio {
125 command: cmd.into(),
126 args: vec![],
127 env: BTreeMap::new(),
128 binary_sha256: String::new(),
129 capabilities: None,
130 }
131 }
132
133 #[test]
134 fn key_is_stable_and_wiring_sensitive() {
135 assert_eq!(key(&stdio("a")), key(&stdio("a")), "same wiring → same key");
136 assert_ne!(
137 key(&stdio("a")),
138 key(&stdio("b")),
139 "different command → different key"
140 );
141 }
142
143 #[test]
144 fn evict_and_clear_are_safe_when_empty() {
145 clear();
146 evict(key(&stdio("never-pooled")));
147 clear();
148 assert_eq!(len(), 0);
149 }
150}