Skip to content

Commit 01a01e5

Browse files
committed
feat: add progress-aware request timeouts
1 parent c330fed commit 01a01e5

4 files changed

Lines changed: 443 additions & 23 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ name = "test_progress_subscriber"
252252
required-features = ["server", "client", "macros"]
253253
path = "tests/test_progress_subscriber.rs"
254254

255+
[[test]]
256+
name = "test_request_timeout_progress"
257+
required-features = ["server", "client", "macros"]
258+
path = "tests/test_request_timeout_progress.rs"
259+
255260
[[test]]
256261
name = "test_elicitation"
257262
required-features = ["elicitation", "client", "server"]

crates/rmcp/src/service.rs

Lines changed: 223 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ pub(crate) type MaybeBoxFuture<'a, T> = BoxFuture<'a, T>;
4242
#[cfg(feature = "local")]
4343
pub(crate) type MaybeBoxFuture<'a, T> = LocalBoxFuture<'a, T>;
4444

45+
#[cfg(feature = "server")]
46+
use crate::model::ClientNotification;
4547
#[cfg(feature = "server")]
4648
use crate::model::ServerJsonRpcMessage;
49+
#[cfg(feature = "client")]
50+
use crate::model::ServerNotification;
4751
use crate::{
4852
error::ErrorData as McpError,
4953
model::{
@@ -299,7 +303,37 @@ impl ProgressTokenProvider for AtomicU32Provider {
299303
}
300304
}
301305

306+
#[doc(hidden)]
307+
pub trait ProgressNotificationToken {
308+
fn progress_token(&self) -> Option<&ProgressToken>;
309+
}
310+
311+
#[cfg(feature = "server")]
312+
impl ProgressNotificationToken for ClientNotification {
313+
fn progress_token(&self) -> Option<&ProgressToken> {
314+
match self {
315+
ClientNotification::ProgressNotification(notification) => {
316+
Some(&notification.params.progress_token)
317+
}
318+
_ => None,
319+
}
320+
}
321+
}
322+
323+
#[cfg(feature = "client")]
324+
impl ProgressNotificationToken for ServerNotification {
325+
fn progress_token(&self) -> Option<&ProgressToken> {
326+
match self {
327+
ServerNotification::ProgressNotification(notification) => {
328+
Some(&notification.params.progress_token)
329+
}
330+
_ => None,
331+
}
332+
}
333+
}
334+
302335
type Responder<T> = tokio::sync::oneshot::Sender<T>;
336+
type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>;
303337

304338
/// A handle to a remote request
305339
///
@@ -314,40 +348,142 @@ pub struct RequestHandle<R: ServiceRole> {
314348
pub peer: Peer<R>,
315349
pub id: RequestId,
316350
pub progress_token: ProgressToken,
351+
progress_timeout_watchers: ProgressTimeoutWatchers,
352+
progress_reset_rx: Option<mpsc::Receiver<()>>,
317353
}
318354

319355
impl<R: ServiceRole> RequestHandle<R> {
320356
pub const REQUEST_TIMEOUT_REASON: &str = "request timeout";
321-
pub async fn await_response(self) -> Result<R::PeerResp, ServiceError> {
322-
if let Some(timeout) = self.options.timeout {
323-
let timeout_result = tokio::time::timeout(timeout, async move {
324-
self.rx.await.map_err(|_e| ServiceError::TransportClosed)?
325-
})
326-
.await;
327-
match timeout_result {
328-
Ok(response) => response,
329-
Err(_) => {
330-
let error = Err(ServiceError::Timeout { timeout });
331-
// cancel this request
332-
let notification = CancelledNotification {
333-
params: CancelledNotificationParam {
334-
request_id: self.id,
335-
reason: Some(Self::REQUEST_TIMEOUT_REASON.to_owned()),
336-
},
337-
method: crate::model::CancelledNotificationMethod,
338-
extensions: Default::default(),
339-
};
340-
let _ = self.peer.send_notification(notification.into()).await;
341-
error
357+
pub const REQUEST_MAX_TOTAL_TIMEOUT_REASON: &str = "maximum total timeout exceeded";
358+
359+
async fn send_timeout_cancel_notification(&self, reason: &str) {
360+
let notification = CancelledNotification {
361+
params: CancelledNotificationParam {
362+
request_id: self.id.clone(),
363+
reason: Some(reason.to_owned()),
364+
},
365+
method: crate::model::CancelledNotificationMethod,
366+
extensions: Default::default(),
367+
};
368+
let _ = self.peer.send_notification(notification.into()).await;
369+
}
370+
371+
async fn cleanup_progress_timeout_watcher(
372+
progress_timeout_watchers: &ProgressTimeoutWatchers,
373+
progress_token: &ProgressToken,
374+
has_progress_reset_rx: bool,
375+
) {
376+
if has_progress_reset_rx {
377+
progress_timeout_watchers
378+
.write()
379+
.await
380+
.remove(progress_token);
381+
}
382+
}
383+
384+
pub async fn await_response(mut self) -> Result<R::PeerResp, ServiceError> {
385+
let timeout = self.options.timeout;
386+
let max_total_timeout = self.options.max_total_timeout;
387+
let reset_timeout_on_progress = self.options.reset_timeout_on_progress;
388+
389+
let has_progress_reset_rx = self.progress_reset_rx.is_some();
390+
let progress_timeout_watchers = self.progress_timeout_watchers.clone();
391+
let progress_token = self.progress_token.clone();
392+
393+
let result =
394+
if timeout.is_some() && !reset_timeout_on_progress && max_total_timeout.is_none() {
395+
let timeout = timeout.expect("timeout is checked above");
396+
let timeout_result = tokio::time::timeout(timeout, &mut self.rx).await;
397+
match timeout_result {
398+
Ok(response) => response.map_err(|_e| ServiceError::TransportClosed)?,
399+
Err(_) => {
400+
let error = Err(ServiceError::Timeout { timeout });
401+
// cancel this request
402+
self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON)
403+
.await;
404+
error
405+
}
406+
}
407+
} else if timeout.is_none() && max_total_timeout.is_none() {
408+
(&mut self.rx)
409+
.await
410+
.map_err(|_e| ServiceError::TransportClosed)?
411+
} else {
412+
self.await_response_with_progress_timeout(
413+
timeout,
414+
max_total_timeout,
415+
reset_timeout_on_progress,
416+
)
417+
.await
418+
};
419+
420+
Self::cleanup_progress_timeout_watcher(
421+
&progress_timeout_watchers,
422+
&progress_token,
423+
has_progress_reset_rx,
424+
)
425+
.await;
426+
result
427+
}
428+
429+
async fn await_response_with_progress_timeout(
430+
&mut self,
431+
timeout: Option<Duration>,
432+
max_total_timeout: Option<Duration>,
433+
reset_timeout_on_progress: bool,
434+
) -> Result<R::PeerResp, ServiceError> {
435+
let mut idle_sleep = timeout.map(tokio::time::sleep).map(Box::pin);
436+
let mut max_total_sleep = max_total_timeout.map(tokio::time::sleep).map(Box::pin);
437+
438+
loop {
439+
tokio::select! {
440+
biased;
441+
442+
response = &mut self.rx => {
443+
return response.map_err(|_e| ServiceError::TransportClosed)?;
444+
}
445+
_ = async {
446+
if let Some(sleep) = idle_sleep.as_mut() {
447+
sleep.as_mut().await;
448+
}
449+
}, if idle_sleep.is_some() => {
450+
let timeout = timeout.expect("idle timeout exists when idle sleep exists");
451+
self.send_timeout_cancel_notification(Self::REQUEST_TIMEOUT_REASON).await;
452+
return Err(ServiceError::Timeout { timeout });
453+
}
454+
_ = async {
455+
if let Some(sleep) = max_total_sleep.as_mut() {
456+
sleep.as_mut().await;
457+
}
458+
}, if max_total_sleep.is_some() => {
459+
let timeout = max_total_timeout.expect("max total timeout exists when max total sleep exists");
460+
self.send_timeout_cancel_notification(Self::REQUEST_MAX_TOTAL_TIMEOUT_REASON).await;
461+
return Err(ServiceError::Timeout { timeout });
462+
}
463+
progress = async {
464+
match self.progress_reset_rx.as_mut() {
465+
Some(rx) => rx.recv().await,
466+
None => None,
467+
}
468+
}, if reset_timeout_on_progress && timeout.is_some() && self.progress_reset_rx.is_some() => {
469+
if progress.is_some() {
470+
if let (Some(timeout), Some(sleep)) = (timeout, idle_sleep.as_mut()) {
471+
sleep.as_mut().reset(tokio::time::Instant::now() + timeout);
472+
}
473+
}
342474
}
343475
}
344-
} else {
345-
self.rx.await.map_err(|_e| ServiceError::TransportClosed)?
346476
}
347477
}
348478

349479
/// Cancel this request
350480
pub async fn cancel(self, reason: Option<String>) -> Result<(), ServiceError> {
481+
Self::cleanup_progress_timeout_watcher(
482+
&self.progress_timeout_watchers,
483+
&self.progress_token,
484+
self.progress_reset_rx.is_some(),
485+
)
486+
.await;
351487
let notification = CancelledNotification {
352488
params: CancelledNotificationParam {
353489
request_id: self.id,
@@ -384,6 +520,7 @@ pub struct Peer<R: ServiceRole> {
384520
tx: mpsc::Sender<PeerSinkMessage<R>>,
385521
request_id_provider: Arc<dyn RequestIdProvider>,
386522
progress_token_provider: Arc<dyn ProgressTokenProvider>,
523+
progress_timeout_watchers: ProgressTimeoutWatchers,
387524
info: Arc<tokio::sync::OnceCell<R::PeerInfo>>,
388525
}
389526

@@ -403,12 +540,33 @@ type ProxyOutbound<R> = mpsc::Receiver<PeerSinkMessage<R>>;
403540
pub struct PeerRequestOptions {
404541
pub timeout: Option<Duration>,
405542
pub meta: Option<Meta>,
543+
/// Reset the request timeout when a matching progress notification is received.
544+
pub reset_timeout_on_progress: bool,
545+
/// Maximum total time to wait for the request, regardless of progress notifications.
546+
pub max_total_timeout: Option<Duration>,
406547
}
407548

408549
impl PeerRequestOptions {
409550
pub fn no_options() -> Self {
410551
Self::default()
411552
}
553+
554+
pub fn with_timeout(timeout: Duration) -> Self {
555+
Self {
556+
timeout: Some(timeout),
557+
..Self::default()
558+
}
559+
}
560+
561+
pub fn reset_timeout_on_progress(mut self) -> Self {
562+
self.reset_timeout_on_progress = true;
563+
self
564+
}
565+
566+
pub fn with_max_total_timeout(mut self, timeout: Duration) -> Self {
567+
self.max_total_timeout = Some(timeout);
568+
self
569+
}
412570
}
413571

414572
impl<R: ServiceRole> Peer<R> {
@@ -423,6 +581,7 @@ impl<R: ServiceRole> Peer<R> {
423581
tx,
424582
request_id_provider,
425583
progress_token_provider: Arc::new(AtomicU32ProgressTokenProvider::default()),
584+
progress_timeout_watchers: Default::default(),
426585
info: Arc::new(tokio::sync::OnceCell::new_with(peer_info)),
427586
},
428587
rx,
@@ -468,6 +627,16 @@ impl<R: ServiceRole> Peer<R> {
468627
request.get_meta_mut().extend(meta);
469628
}
470629
let (responder, receiver) = tokio::sync::oneshot::channel();
630+
let progress_reset_rx = if options.reset_timeout_on_progress && options.timeout.is_some() {
631+
let (sender, receiver) = mpsc::channel(1);
632+
self.progress_timeout_watchers
633+
.write()
634+
.await
635+
.insert(progress_token.clone(), sender);
636+
Some(receiver)
637+
} else {
638+
None
639+
};
471640
self.tx
472641
.send(PeerSinkMessage::Request {
473642
request,
@@ -482,8 +651,33 @@ impl<R: ServiceRole> Peer<R> {
482651
progress_token,
483652
options,
484653
peer: self.clone(),
654+
progress_timeout_watchers: self.progress_timeout_watchers.clone(),
655+
progress_reset_rx,
485656
})
486657
}
658+
659+
async fn notify_progress_timeout_watcher(&self, progress_token: &ProgressToken) {
660+
let sender = self
661+
.progress_timeout_watchers
662+
.read()
663+
.await
664+
.get(progress_token)
665+
.cloned();
666+
if let Some(sender) = sender {
667+
match sender.try_send(()) {
668+
Ok(()) => {}
669+
Err(mpsc::error::TrySendError::Full(_)) => {
670+
tracing::trace!(?progress_token, "progress timeout watcher channel is full");
671+
}
672+
Err(mpsc::error::TrySendError::Closed(_)) => {
673+
self.progress_timeout_watchers
674+
.write()
675+
.await
676+
.remove(progress_token);
677+
}
678+
}
679+
}
680+
}
487681
pub fn peer_info(&self) -> Option<&R::PeerInfo> {
488682
self.info.get()
489683
}
@@ -692,6 +886,7 @@ pub fn serve_directly<R, S, T, E, A>(
692886
) -> RunningService<R, S>
693887
where
694888
R: ServiceRole,
889+
R::PeerNot: ProgressNotificationToken,
695890
S: Service<R>,
696891
T: IntoTransport<R, E, A>,
697892
E: std::error::Error + Send + Sync + 'static,
@@ -708,6 +903,7 @@ pub fn serve_directly_with_ct<R, S, T, E, A>(
708903
) -> RunningService<R, S>
709904
where
710905
R: ServiceRole,
906+
R::PeerNot: ProgressNotificationToken,
711907
S: Service<R>,
712908
T: IntoTransport<R, E, A>,
713909
E: std::error::Error + Send + Sync + 'static,
@@ -748,6 +944,7 @@ fn serve_inner<R, S, T>(
748944
) -> RunningService<R, S>
749945
where
750946
R: ServiceRole,
947+
R::PeerNot: ProgressNotificationToken,
751948
S: Service<R>,
752949
T: Transport<R> + 'static,
753950
{
@@ -994,6 +1191,9 @@ where
9941191
}
9951192
Err(notification) => notification,
9961193
};
1194+
if let Some(progress_token) = notification.progress_token() {
1195+
peer.notify_progress_timeout_watcher(progress_token).await;
1196+
}
9971197
{
9981198
let service = shared_service.clone();
9991199
let mut extensions = Extensions::new();

crates/rmcp/src/service/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ macro_rules! method {
356356
let options = crate::service::PeerRequestOptions {
357357
timeout,
358358
meta: None,
359+
reset_timeout_on_progress: false,
360+
max_total_timeout: None,
359361
};
360362
let result = self
361363
.send_request_with_option(request, options)
@@ -383,6 +385,8 @@ macro_rules! method {
383385
let options = crate::service::PeerRequestOptions {
384386
timeout,
385387
meta: None,
388+
reset_timeout_on_progress: false,
389+
max_total_timeout: None,
386390
};
387391
let result = self
388392
.send_request_with_option(request, options)

0 commit comments

Comments
 (0)