Skip to main content

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}