Skip to content

Commit deab680

Browse files
committed
fix(handler): single-flight on-demand probe per session
The boolean `claim_probe` rate-limiter let the first list request enter the probe path, but **followers in the same burst skipped the probe entirely** and resolved to PendingRoots immediately — returning empty *before* the first probe's `peer.list_roots()` came back. Trace from a Claude Code claude-vscode session opening a new workspace: 02:17:19.950 resolver: roots-capable, roots pending (req 1) 02:17:19.950 resolver: roots-capable, roots pending (req 2 — skipped probe) 02:17:19.973 on-demand probe populated roots (req 1's probe finally landed) Two of the three list responses (tools/list + prompts/list + resources/list, fired within 1ms by Claude Code at init) returned empty. The probe success at +23ms triggered a notifications/ tools/list_changed which the CLI variant honors but the VS Code extension doesn't refetch on, so the empty initial list is what the panel kept showing. Replace the boolean with a per-session `tokio::sync::Mutex` for single-flight semantics. First request acquires the lock, fires the probe, populates `session_roots`. Followers await the same lock; on acquire, they re-check `session_roots.get(sid)` and exit early since the predecessor already populated. Net result: one upstream `peer.list_roots()` call, three list responses with the correct routing decision. Kept the cool-down semantic too: `should_throttle_probe` + `mark_probe_completed` rate-limit *sequential* probe attempts when the previous one errored, so a peer that's failing list_roots doesn't get hammered. Distinct from the lock — the lock is concurrency, the throttle is failure recovery. Doesn't help the upstream client bug (Claude Code claude-vscode doesn't refetch on tools/list_changed) but means the *first* response is correct, which is the only one some clients ever read. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent a83e288 commit deab680

2 files changed

Lines changed: 87 additions & 37 deletions

File tree

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,8 @@ impl McpMuxGatewayHandler {
206206
client_id: &str,
207207
) {
208208
let Some(sid) = session_id else { return };
209-
// Already have a definitive answer (Some(roots) — possibly empty).
209+
// Fast path: already have a definitive answer (Some(roots),
210+
// possibly empty). No probe needed.
210211
if self.services.session_roots.get(sid).is_some() {
211212
return;
212213
}
@@ -220,18 +221,45 @@ impl McpMuxGatewayHandler {
220221
{
221222
return;
222223
}
223-
// Throttle: at most one probe per session per second so a
224-
// request burst doesn't multiply upstream calls.
225-
if !self
224+
// Cool-down after a recent failed probe so we don't hammer a
225+
// peer whose previous list_roots() errored. Doesn't apply
226+
// when a probe is currently *running* — that's the
227+
// probe_lock's job below.
228+
if self
226229
.services
227230
.session_roots
228-
.claim_probe(sid, std::time::Duration::from_secs(1))
231+
.should_throttle_probe(sid, std::time::Duration::from_secs(1))
229232
{
230233
return;
231234
}
232235

236+
// Single-flight: serialize concurrent probes per session so a
237+
// burst of three list calls (tools/list + prompts/list +
238+
// resources/list within milliseconds) doesn't fan out three
239+
// upstream `peer.list_roots()` calls. The first request enters
240+
// the critical section, fires the probe, populates
241+
// session_roots; the second and third await the same lock,
242+
// then re-check session_roots and exit early.
243+
//
244+
// Without this, the followers used to skip the probe entirely
245+
// (boolean `claim_probe` flag) and resolve to PendingRoots —
246+
// exactly the empty-tools-list bug Claude Code's VS Code
247+
// extension was hitting.
248+
let lock = self.services.session_roots.probe_lock(sid);
249+
let _guard = lock.lock().await;
250+
251+
// Recheck after acquiring the lock — the predecessor probe may
252+
// have already populated the registry.
253+
if self.services.session_roots.get(sid).is_some() {
254+
return;
255+
}
256+
233257
const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(300);
234-
match tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await {
258+
let outcome = tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await;
259+
// Stamp completion regardless of success/failure so the
260+
// sequential cool-down kicks in for the next caller.
261+
self.services.session_roots.mark_probe_completed(sid);
262+
match outcome {
235263
Ok(Ok(result)) => {
236264
let uris: Vec<String> = result.roots.iter().map(|r| r.uri.to_string()).collect();
237265
self.services
@@ -276,15 +304,15 @@ impl McpMuxGatewayHandler {
276304
%client_id,
277305
session_id = %sid,
278306
error = %e,
279-
"[FeatureSetResolver] on-demand probe failed (will retry on next request)",
307+
"[FeatureSetResolver] on-demand probe failed (will retry on next request after throttle)",
280308
);
281309
}
282310
Err(_elapsed) => {
283311
debug!(
284312
%client_id,
285313
session_id = %sid,
286314
budget_ms = PROBE_BUDGET.as_millis(),
287-
"[FeatureSetResolver] on-demand probe timed out (will retry on next request)",
315+
"[FeatureSetResolver] on-demand probe timed out (will retry on next request after throttle)",
288316
);
289317
}
290318
}

crates/mcpmux-gateway/src/services/session_roots.rs

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,27 @@ pub struct SessionRootsRegistry {
3838
roots_capable: DashMap<String, bool>,
3939
/// `session_id -> Instant of the last on-demand `list_roots()` probe`.
4040
///
41-
/// Used by the request-time re-probe path in the MCP handler to avoid
42-
/// firing N parallel `list_roots()` calls when a roots-capable session
43-
/// hits a burst of `tools/list` / `prompts/list` / `resources/list` in
44-
/// quick succession. The handler calls `claim_probe(sid, throttle)`
45-
/// before firing; if it returns false, another probe was attempted
46-
/// recently and this one is skipped.
41+
/// Used by [`Self::should_throttle_probe`] to avoid hammering a
42+
/// failing client when its previous probe already errored out
43+
/// recently. Only stamped after a probe attempt completes (success
44+
/// or failure), not on entry — so concurrent in-flight probes
45+
/// coordinate via [`Self::probe_lock`] instead of this throttle.
4746
last_probe: DashMap<String, Instant>,
47+
/// Per-session mutex guarding `peer.list_roots()` probe attempts.
48+
///
49+
/// Single-flight semantics: when a burst of three list requests
50+
/// (`tools/list` + `prompts/list` + `resources/list`) hits a
51+
/// roots-pending session within milliseconds, only one upstream
52+
/// `list_roots()` call should be in flight. The other two block on
53+
/// the same lock; once the first attempt populates `map`, the
54+
/// followers re-check `map.get(sid)` and skip the upstream call
55+
/// entirely.
56+
///
57+
/// Without this, a boolean "already tried" flag let the followers
58+
/// see `roots_pending` and return empty *before* the first probe's
59+
/// result landed — exactly the bug that left Claude Code's
60+
/// VS Code extension showing only the meta tools.
61+
probe_lock: DashMap<String, Arc<tokio::sync::Mutex<()>>>,
4862
}
4963

5064
impl SessionRootsRegistry {
@@ -54,33 +68,40 @@ impl SessionRootsRegistry {
5468
last_resolution: DashMap::new(),
5569
roots_capable: DashMap::new(),
5670
last_probe: DashMap::new(),
71+
probe_lock: DashMap::new(),
5772
})
5873
}
5974

60-
/// Try to claim a probe slot for `session_id`. Returns `true` if it's
61-
/// been at least `throttle` since the last attempt for this session
62-
/// (or if there's never been one) — and stamps the new attempt
63-
/// atomically. Returns `false` if a probe was already attempted
64-
/// within the throttle window.
75+
/// Get (or create) the per-session probe lock. The returned Arc is
76+
/// what the handler awaits to serialize concurrent probes — see
77+
/// [`Self::probe_lock`] for the rationale.
78+
pub fn probe_lock(&self, session_id: &str) -> Arc<tokio::sync::Mutex<()>> {
79+
self.probe_lock
80+
.entry(session_id.to_string())
81+
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
82+
.clone()
83+
}
84+
85+
/// Should we skip an on-demand probe because the previous attempt
86+
/// completed (success or failure) within the last `throttle`?
6587
///
66-
/// The handler calls this before firing `peer.list_roots()` so a
67-
/// burst of three `tools/list` / `prompts/list` / `resources/list`
68-
/// calls in 50 ms results in at most one upstream probe.
69-
pub fn claim_probe(&self, session_id: &str, throttle: Duration) -> bool {
70-
let now = Instant::now();
71-
match self.last_probe.entry(session_id.to_string()) {
72-
dashmap::mapref::entry::Entry::Occupied(mut e) => {
73-
if now.duration_since(*e.get()) < throttle {
74-
return false;
75-
}
76-
e.insert(now);
77-
true
78-
}
79-
dashmap::mapref::entry::Entry::Vacant(e) => {
80-
e.insert(now);
81-
true
82-
}
83-
}
88+
/// Distinct from `probe_lock`: the lock serializes *concurrent*
89+
/// probes; this rate-limit prevents *sequential* probes from
90+
/// hammering a peer whose previous attempt errored.
91+
pub fn should_throttle_probe(&self, session_id: &str, throttle: Duration) -> bool {
92+
let Some(last) = self.last_probe.get(session_id) else {
93+
return false;
94+
};
95+
Instant::now().duration_since(*last) < throttle
96+
}
97+
98+
/// Stamp the completion of an on-demand probe so the next caller
99+
/// observes the throttle. Called after the probe returns (regardless
100+
/// of success or failure) so successive probes back off only when
101+
/// the previous one actually finished.
102+
pub fn mark_probe_completed(&self, session_id: &str) {
103+
self.last_probe
104+
.insert(session_id.to_string(), Instant::now());
84105
}
85106

86107
/// Record whether a session declared the MCP `roots` capability on
@@ -122,6 +143,7 @@ impl SessionRootsRegistry {
122143
self.last_resolution.remove(session_id);
123144
self.roots_capable.remove(session_id);
124145
self.last_probe.remove(session_id);
146+
self.probe_lock.remove(session_id);
125147
}
126148

127149
/// Compare-and-set the session's resolved feature-set id. Returns `true`

0 commit comments

Comments
 (0)