diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index b6cc5e538..c73b41ea3 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -136,6 +136,14 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone { fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> { None } + #[doc(hidden)] + fn peer_cancels_subscriptions(_peer: &Peer) -> bool { + false + } + #[doc(hidden)] + fn is_subscription_request(_request: &Self::Req) -> bool { + false + } /// Invalidate any response cache affected by an inbound peer notification. /// /// The serve loop calls this for every notification *before* subscription @@ -522,6 +530,10 @@ impl ProgressNotificationToken for ServerNotification { } type Responder = tokio::sync::oneshot::Sender; +struct PendingResponse { + responder: Responder, + is_subscription: bool, +} type ProgressTimeoutWatchers = Arc>>>; type SubscriptionChannel = (mpsc::Sender, usize); type SubscriptionChannelMap = HashMap>; @@ -1348,7 +1360,7 @@ where } let mut local_responder_pool = - HashMap::>>::new(); + HashMap::>>::new(); let mut local_ct_pool = HashMap::::new(); let shared_service = Arc::new(service); // for return @@ -1445,7 +1457,7 @@ where Event::SendTaskResult(SendTaskResult::Request { id, result }) => { if let Err(e) = result && let Some(responder) = local_responder_pool.remove(&id) { - let _ = responder.send(Err(ServiceError::TransportSend(e))); + let _ = responder.responder.send(Err(ServiceError::TransportSend(e))); } } Event::SendTaskResult(SendTaskResult::Notification { @@ -1463,7 +1475,7 @@ where && let Some(request_id) = ¶m.request_id && let Some(responder) = local_responder_pool.remove(request_id) { tracing::info!(id = %request_id, reason = param.reason, "cancelled"); - let _response_result = responder.send(Err(ServiceError::Cancelled { + let _response_result = responder.responder.send(Err(ServiceError::Cancelled { reason: param.reason.clone(), })); } @@ -1500,7 +1512,10 @@ where id, responder, }) => { - local_responder_pool.insert(id.clone(), responder); + local_responder_pool.insert(id.clone(), PendingResponse { + responder, + is_subscription: R::is_subscription_request(&request), + }); let send = transport.send(JsonRpcMessage::request(request, id.clone())); { let id = id.clone(); @@ -1601,31 +1616,38 @@ where })) => { tracing::info!(?notification, "received notification"); R::invalidate_response_cache(&peer, ¬ification).await; - let cancellation_request_id = + let subscription_id = if let Some(cancelled) = R::peer_cancelled_params(¬ification) { let request_id = cancelled.request_id.clone(); - if let Some(request_id) = request_id.as_ref() { - if R::IS_CLIENT { - if let Some(responder) = - local_responder_pool.remove(request_id) + // Modern servers cancel listen requests; legacy peers cancel + // requests they originated, even when both directions share an ID. + if R::peer_cancels_subscriptions(&peer) { + if let Some(request_id) = request_id.as_ref() { + if local_responder_pool.get(request_id) + .is_some_and(|pending| pending.is_subscription) + && let Some(pending) = local_responder_pool.remove(request_id) { - let _ = responder.send(Err(ServiceError::Cancelled { + let _ = pending.responder.send(Err(ServiceError::Cancelled { reason: cancelled.reason.clone(), })); + } else { + tracing::debug!(%request_id, "ignoring cancellation of unknown subscription"); + continue; } - } else if let Some(ct) = local_ct_pool.remove(request_id) { + } + request_id + } else { + if let Some(request_id) = request_id.as_ref() + && let Some(ct) = local_ct_pool.remove(request_id) + { tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled"); ct.cancel(); } + None } - request_id } else { - None + notification.get_meta().subscription_id() }; - let subscription_id = notification - .get_meta() - .subscription_id() - .or(cancellation_request_id); if let Some(subscription_id) = subscription_id && let Some((sender, capacity)) = peer.subscription_sender(&subscription_id) @@ -1642,7 +1664,7 @@ where && let Some(responder) = local_responder_pool.remove(&subscription_id) { - let _ = responder + let _ = responder.responder .send(Err(ServiceError::SubscriptionLagged { capacity })); } peer.unregister_subscription(&subscription_id); @@ -1694,7 +1716,7 @@ where if let Some(responder) = remove_pending_request(&mut local_responder_pool, &id) { - let response_result = responder.send(Ok(result)); + let response_result = responder.responder.send(Ok(result)); if let Err(_error) = response_result { tracing::warn!(%id, "Error sending response"); } @@ -1715,7 +1737,7 @@ where } else { ServiceError::McpError(error) }; - let _response_result = responder.send(Err(service_error)); + let _response_result = responder.responder.send(Err(service_error)); if let Err(_error) = _response_result { tracing::warn!(%id, "Error sending response"); } @@ -1749,6 +1771,17 @@ where // Then drain any handler responses still in the channel // (handlers that finished after the loop broke). while let Some(m) = sink_proxy_rx.recv().await { + if let Some(id) = match &m { + JsonRpcMessage::Response(response) => Some(&response.id), + JsonRpcMessage::Error(error) => error.id.as_ref(), + _ => None, + } { + let Some(ct) = local_ct_pool.remove(id) else { + tracing::debug!(%id, "dropping response for cancelled request"); + continue; + }; + ct.cancel(); + } if let Err(error) = transport.send(m).await { tracing::error!(%error, "failed to send pending response during drain"); break; diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index 47eeace10..e5746115b 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -292,6 +292,20 @@ impl ServiceRole for RoleClient { } } + fn peer_cancels_subscriptions(peer: &Peer) -> bool { + // Discovery keeps modern lifecycle semantics even with an older application version. + !super::uses_legacy_lifecycle( + peer.peer_info() + .as_deref() + .map(|info| &info.protocol_version), + peer.client_request_metadata.get().is_some(), + ) + } + + fn is_subscription_request(request: &Self::Req) -> bool { + matches!(request, ClientRequest::SubscriptionsListenRequest(_)) + } + // SEP-2260: reject restricted server requests that arrived unassociated // with any in-flight outbound request. Without stream separation // (`Unknown`) the coarse in-flight check under-approximates the SHOULD. @@ -2193,6 +2207,232 @@ where mod tests { use super::*; + #[tokio::test] + async fn server_cancellation_retires_subscription_responder() { + tokio::task::LocalSet::new() + .run_until(async { + for (discover, version) in [ + (false, ProtocolVersion::V_2026_07_28), + (true, ProtocolVersion::V_2026_07_28), + (true, ProtocolVersion::V_2025_11_25), + ] { + tokio::time::timeout( + Duration::from_secs(5), + check_subscription_cancellation(discover, version), + ) + .await + .expect("subscription cancellation timed out"); + } + }) + .await; + } + + async fn check_subscription_cancellation(discover: bool, version: ProtocolVersion) { + use crate::model::{ + GetMeta, PingRequest, ServerInfo, SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, + }; + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let mut server = + crate::transport::IntoTransport::::into_transport(server_transport); + let info = ServerInfo { + protocol_version: version.clone(), + ..Default::default() + }; + let client = if discover { + let (client, ()) = tokio::join!( + serve_client_with_lifecycle( + (), + client_transport, + ClientLifecycleMode::Discover { + preferred_versions: vec![version.clone()] + } + ), + async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await + else { + panic!("expected discover request"); + }; + assert!(matches!(request.request, ClientRequest::DiscoverRequest(_))); + server + .send(ServerJsonRpcMessage::response( + ServerResult::DiscoverResult(DiscoverResult::new( + vec![version], + info.capabilities, + )), + request.id, + )) + .await + .unwrap(); + } + ); + client.unwrap() + } else { + super::super::serve_directly::( + (), + client_transport, + Some(info.into()), + ) + }; + let filter = SubscriptionFilter::default(); + let (subscription, ()) = tokio::join!(client.listen(filter.clone()), async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else { + panic!("expected listen request"); + }; + assert!(matches!( + request.request, + ClientRequest::SubscriptionsListenRequest(_) + )); + let mut ack: ServerNotification = SubscriptionsAcknowledgedNotification::new( + SubscriptionsAcknowledgedNotificationParams::new(filter), + ) + .into(); + ack.get_meta_mut().set_subscription_id(request.id); + server + .send(ServerJsonRpcMessage::notification(ack)) + .await + .unwrap(); + }); + let mut subscription = subscription.unwrap(); + let id = subscription.id().clone(); + let mut ordinary = client + .send_cancellable_request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + PeerRequestOptions::no_options(), + ) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Request(_)) + )); + for unknown in [ + ordinary.id.clone(), + RequestId::String(id.to_string().into()), + RequestId::String("unknown".into()), + ] { + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(unknown), + None, + )) + .into(), + )) + .await + .unwrap(); + } + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String("before-cancellation".into()), + )) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Response(_)) + )); + assert!(matches!( + subscription.request.as_mut().unwrap().rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + ordinary.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + assert!(matches!( + subscription.notifications.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(id.clone()), + Some("subscription ended".to_owned()), + )) + .into(), + )) + .await + .unwrap(); + // Ordered input makes the ping response a barrier for cancellation handling. + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String("after-cancellation".into()), + )) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Response(_)) + )); + assert!( + matches!( + subscription.request.as_mut().unwrap().rx.try_recv(), + Ok(Err(ServiceError::Cancelled { .. })) + ), + "the outbound responder must retire without a final response or disconnect" + ); + assert!(subscription.next().await.unwrap().is_none()); + assert!(matches!( + subscription.end(), + Some(SubscriptionEnd::Cancelled) + )); + assert!(client.peer().subscription_sender(&id).is_none()); + server + .send(ServerJsonRpcMessage::response( + ServerResult::empty(()), + ordinary.id.clone(), + )) + .await + .unwrap(); + assert!(ordinary.await_response().await.is_ok()); + + // Raw listen requests also retire, even without a registered notification channel. + let raw = client + .send_cancellable_request( + ClientRequest::SubscriptionsListenRequest(SubscriptionsListenRequest::new( + SubscriptionsListenRequestParams::new(SubscriptionFilter::default()), + )), + PeerRequestOptions::no_options(), + ) + .await + .unwrap(); + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Request(_)) + )); + server + .send(ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(raw.id.clone()), + None, + )) + .into(), + )) + .await + .unwrap(); + assert!(matches!( + raw.await_response().await, + Err(ServiceError::Cancelled { .. }) + )); + client.cancel().await.unwrap(); + } + #[tokio::test] async fn auto_startup_falls_back_when_discover_is_ignored() { use crate::model::{InitializeResult, ServerCapabilities}; diff --git a/crates/rmcp/tests/test_cancelled_response.rs b/crates/rmcp/tests/test_cancelled_response.rs index e46cddb13..9b115673b 100644 --- a/crates/rmcp/tests/test_cancelled_response.rs +++ b/crates/rmcp/tests/test_cancelled_response.rs @@ -3,8 +3,22 @@ //! until the request is cancelled, so its result is only produced *after* the //! cancellation — the service loop must drop it rather than write it to the wire. +#[cfg(all(feature = "client", not(feature = "local")))] +use std::sync::Arc; use std::{collections::BTreeSet, process::Stdio, time::Duration}; +#[cfg(all(feature = "client", not(feature = "local")))] +use rmcp::{ + ClientHandler, RoleClient, + model::{ + CancelledNotification, CancelledNotificationParam, ClientJsonRpcMessage, ClientRequest, + ClientResult, ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction, + ElicitationSchema, PingRequest, RequestId, ServerJsonRpcMessage, ServerNotification, + ServerRequest, ServerResult, + }, + service::{PeerRequestOptions, QuitReason, serve_directly}, + transport::{IntoTransport, Transport}, +}; use rmcp::{ ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, model::{ @@ -21,6 +35,7 @@ use tokio::{ const HELPER_ENV: &str = "RMCP_CANCELLED_RESPONSE_HELPER"; const READ_TIMEOUT: Duration = Duration::from_secs(10); +const STRING_REQUEST_ID: &str = "tool-request-2"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { @@ -43,7 +58,7 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { }), ) .await?; - collect_ids_until(&mut reader, 1, READ_TIMEOUT).await?; + collect_ids_until(&mut reader, "1", READ_TIMEOUT).await?; send_json( &mut writer, &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), @@ -56,7 +71,7 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &mut writer, &json!({ "jsonrpc": "2.0", - "id": 2, + "id": STRING_REQUEST_ID, "method": "tools/call", "params": { "name": "wait-for-cancel", "arguments": {} } }), @@ -67,21 +82,59 @@ async fn cancelled_request_receives_no_response() -> anyhow::Result<()> { &json!({ "jsonrpc": "2.0", "method": "notifications/cancelled", - "params": { "requestId": 2 } + "params": { "requestId": "unrelated-request" } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": STRING_REQUEST_ID } }), ) .await?; // A ping proves the server is alive past the cancellation, so the absence of - // an id=2 response is genuine suppression rather than a dead connection. + // a cancelled response is genuine suppression rather than a dead connection. send_json( &mut writer, &json!({ "jsonrpc": "2.0", "id": 3, "method": "ping" }), ) .await?; - let seen = collect_ids_until(&mut reader, 3, READ_TIMEOUT).await?; - assert!(seen.contains(&3)); - assert!(!seen.contains(&2)); + let seen = collect_ids_until(&mut reader, "3", READ_TIMEOUT).await?; + assert!(seen.contains("3")); + assert!(!seen.contains(STRING_REQUEST_ID)); + + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { "name": "complete-unless-cancelled", "arguments": {} } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": { "requestId": "4" } + }), + ) + .await?; + send_json( + &mut writer, + &json!({ "jsonrpc": "2.0", "id": 5, "method": "ping" }), + ) + .await?; + + let seen = collect_ids_until(&mut reader, "5", READ_TIMEOUT).await?; + assert!(seen.contains("4"), "string and numeric IDs must not alias"); + assert!(seen.contains("5")); drop(writer); wait_for_child(&mut child).await; @@ -97,14 +150,377 @@ impl ServerHandler for WaitForCancelServer { async fn call_tool( &self, - _request: CallToolRequestParams, + request: CallToolRequestParams, context: RequestContext, ) -> Result { + if request.name == "complete-unless-cancelled" { + tokio::select! { + _ = context.ct.cancelled() => {} + _ = tokio::time::sleep(Duration::from_millis(100)) => {} + } + return Ok(CallToolResult::success(vec![ContentBlock::text("completed")]).into()); + } context.ct.cancelled().await; Ok(CallToolResult::success(vec![ContentBlock::text("late response")]).into()) } } +#[cfg(all(feature = "client", not(feature = "local")))] +#[derive(Clone)] +struct WaitForReverseCancelClient { + events: tokio::sync::mpsc::UnboundedSender<&'static str>, + finish: Option>, + return_error: bool, +} + +#[cfg(all(feature = "client", not(feature = "local")))] +impl ClientHandler for WaitForReverseCancelClient { + async fn create_elicitation( + &self, + _request: ElicitRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + self.events.send("started").expect("test is listening"); + context.ct.cancelled().await; + self.events.send("cancelled").expect("test is listening"); + if let Some(finish) = &self.finish { + finish.notified().await; + } + if self.return_error { + return Err(McpError::internal_error("late handler error", None)); + } + Ok(ElicitResult::new(ElicitationAction::Decline)) + } +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[rstest::rstest] +#[case::initialize("initialize")] +#[case::direct_legacy("direct-legacy")] +#[case::direct_unknown("direct-unknown")] +#[tokio::test] +async fn peer_cancels_reverse_request_without_cancelling_outbound_request( + #[case] startup: &str, + #[values(false, true)] equal_ids: bool, +) -> anyhow::Result<()> { + tokio::time::timeout(READ_TIMEOUT, reverse_cancellation(startup, equal_ids)).await??; + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn reverse_cancellation(startup: &str, equal_ids: bool) -> anyhow::Result<()> { + use rmcp::model::ProtocolVersion; + let (client_transport, server_transport) = tokio::io::duplex(4096); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let handler = WaitForReverseCancelClient { + events: events_tx, + finish: None, + return_error: false, + }; + let mut server = IntoTransport::::into_transport(server_transport); + let mut info = InitializeResult::default(); + info.protocol_version = ProtocolVersion::V_2025_11_25; + let client = if startup == "initialize" { + let (client, handshake) = tokio::join!(handler.serve(client_transport), async { + let Some(ClientJsonRpcMessage::Request(request)) = server.receive().await else { + panic!("expected initialize request"); + }; + assert!(matches!( + request.request, + ClientRequest::InitializeRequest(_) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::InitializeResult(info.clone()), + request.id, + )) + .await?; + assert!(matches!( + server.receive().await, + Some(ClientJsonRpcMessage::Notification(_)) + )); + anyhow::Ok(()) + }); + handshake?; + client? + } else { + let peer_info = (startup == "direct-legacy").then(|| info.into()); + serve_directly::(handler, client_transport, peer_info) + }; + let mut outbound = client + .send_cancellable_request( + ClientRequest::PingRequest(PingRequest { + method: Default::default(), + extensions: Default::default(), + }), + PeerRequestOptions::no_options(), + ) + .await?; + let Some(ClientJsonRpcMessage::Request(outbound_request)) = server.receive().await else { + panic!("expected outbound ping request"); + }; + let id = if equal_ids { + outbound_request.id.clone() + } else { + RequestId::String("elicitation-1".into()) + }; + let mut request = reverse_elicitation("elicitation-1"); + if let ServerJsonRpcMessage::Request(request) = &mut request { + request.id = id.clone(); + } + server.send(request).await?; + assert_eq!(events_rx.recv().await, Some("started")); + let unknown = if equal_ids { + RequestId::String(id.to_string().into()) + } else { + outbound_request.id.clone() + }; + for cancel_id in [unknown, RequestId::String("unknown".into())] { + server.send(cancel_notification(cancel_id)).await?; + } + exchange_ping(&mut server, "before-cancel").await?; + assert!(matches!( + events_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + assert!(matches!( + outbound.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + // A cancellation can arrive after its response was already delivered. + server + .send(cancel_notification(RequestId::String( + "before-cancel".into(), + ))) + .await?; + server.send(cancel_notification(id)).await?; + assert_eq!(events_rx.recv().await, Some("cancelled")); + exchange_ping(&mut server, "after-cancel").await?; + assert!(matches!( + outbound.rx.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + server + .send(ServerJsonRpcMessage::response( + ServerResult::empty(()), + outbound_request.id, + )) + .await?; + assert!(matches!( + outbound.await_response().await?, + ServerResult::EmptyResult(_) + )); + + client.cancel().await?; + assert!( + server.receive().await.is_none(), + "no late response after handler drain" + ); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +fn cancel_notification(id: RequestId) -> ServerJsonRpcMessage { + ServerJsonRpcMessage::notification( + CancelledNotification::new(CancelledNotificationParam::new( + Some(id), + Some("request cancelled".to_owned()), + )) + .into(), + ) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn exchange_ping(server: &mut impl Transport, id: &str) -> anyhow::Result<()> { + server + .send(ServerJsonRpcMessage::request( + PingRequest { + method: Default::default(), + extensions: Default::default(), + } + .into(), + RequestId::String(id.into()), + )) + .await?; + let Some(ClientJsonRpcMessage::Response(response)) = server.receive().await else { + panic!("expected ping response"); + }; + assert_eq!(response.id, RequestId::String(id.into())); + assert!(matches!(response.result, ClientResult::EmptyResult(_))); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +struct ChannelClientTransport { + incoming: tokio::sync::mpsc::UnboundedReceiver, + outgoing: tokio::sync::mpsc::UnboundedSender, + eof_observed: Arc, +} + +#[cfg(all(feature = "client", not(feature = "local")))] +impl Transport for ChannelClientTransport { + type Error = std::io::Error; + + fn send( + &mut self, + item: ClientJsonRpcMessage, + ) -> impl Future> + Send + 'static { + let outgoing = self.outgoing.clone(); + async move { + outgoing.send(item).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "test receiver closed") + }) + } + } + + async fn receive(&mut self) -> Option { + let message = self.incoming.recv().await; + if message.is_none() { + self.eof_observed.notify_one(); + } + message + } + + async fn close(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[cfg(all(feature = "client", not(feature = "local")))] +fn reverse_elicitation(id: &str) -> ServerJsonRpcMessage { + ServerJsonRpcMessage::request( + ServerRequest::ElicitRequest(ElicitRequest::new( + ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Continue the operation?".to_owned(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("empty elicitation schema is valid"), + }, + )), + RequestId::String(id.into()), + ) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[tokio::test] +async fn graceful_close_drains_uncancelled_reverse_response() -> anyhow::Result<()> { + const REVERSE_REQUEST_ID: &str = "elicitation-close-drain"; + + let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel(); + let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel(); + let eof_observed = Arc::new(tokio::sync::Notify::new()); + let finish = Arc::new(tokio::sync::Notify::new()); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut client = serve_directly::( + WaitForReverseCancelClient { + events: events_tx, + finish: Some(finish.clone()), + return_error: false, + }, + ChannelClientTransport { + incoming, + outgoing, + eof_observed, + }, + None, + ); + + to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?; + assert_eq!(events_rx.recv().await, Some("started")); + + let (close_result, cancellation_event) = tokio::join!(client.close(), async { + let event = events_rx.recv().await; + finish.notify_one(); + event + }); + assert!(matches!(close_result?, QuitReason::Cancelled)); + assert_eq!(cancellation_event, Some("cancelled")); + + let Some(ClientJsonRpcMessage::Response(response)) = + tokio::time::timeout(READ_TIMEOUT, from_client.recv()).await? + else { + panic!("expected in-flight response during graceful close"); + }; + assert_eq!(response.id, RequestId::String(REVERSE_REQUEST_ID.into())); + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +#[tokio::test] +async fn cancelled_reverse_request_stays_suppressed_during_eof_drain() -> anyhow::Result<()> { + for return_error in [false, true] { + tokio::time::timeout( + READ_TIMEOUT, + cancelled_reverse_response_at_eof(return_error), + ) + .await??; + } + Ok(()) +} + +#[cfg(all(feature = "client", not(feature = "local")))] +async fn cancelled_reverse_response_at_eof(return_error: bool) -> anyhow::Result<()> { + const REVERSE_REQUEST_ID: &str = "elicitation-before-eof"; + + let (to_client, incoming) = tokio::sync::mpsc::unbounded_channel(); + let (outgoing, mut from_client) = tokio::sync::mpsc::unbounded_channel(); + let eof_observed = Arc::new(tokio::sync::Notify::new()); + let finish = Arc::new(tokio::sync::Notify::new()); + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = serve_directly::( + WaitForReverseCancelClient { + events: events_tx, + finish: Some(finish.clone()), + return_error, + }, + ChannelClientTransport { + incoming, + outgoing, + eof_observed: eof_observed.clone(), + }, + None, + ); + + to_client.send(reverse_elicitation(REVERSE_REQUEST_ID))?; + assert_eq!( + tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?, + Some("started") + ); + + to_client.send(ServerJsonRpcMessage::notification( + ServerNotification::CancelledNotification(CancelledNotification::new( + CancelledNotificationParam::new( + Some(RequestId::String(REVERSE_REQUEST_ID.into())), + Some("user cancelled".to_owned()), + ), + )), + ))?; + assert_eq!( + tokio::time::timeout(READ_TIMEOUT, events_rx.recv()).await?, + Some("cancelled") + ); + + let eof = eof_observed.notified(); + drop(to_client); + tokio::time::timeout(READ_TIMEOUT, eof).await?; + finish.notify_one(); + + assert!( + matches!( + tokio::time::timeout(READ_TIMEOUT, client.waiting()).await??, + QuitReason::Closed + ), + "client must close after input EOF" + ); + assert!( + from_client.recv().await.is_none(), + "cancelled reverse request must not be sent during EOF drain (return_error={return_error})" + ); + Ok(()) +} + #[tokio::test] async fn cancelled_response_helper() -> anyhow::Result<()> { if std::env::var(HELPER_ENV).as_deref() != Ok("1") { @@ -172,9 +588,9 @@ where /// (then a short grace read to catch any straggler) or the timeout elapses. async fn collect_ids_until( reader: &mut BufReader, - stop_id: u64, + stop_id: &str, timeout: Duration, -) -> anyhow::Result> +) -> anyhow::Result> where R: tokio::io::AsyncRead + Unpin, { @@ -200,8 +616,13 @@ where let Ok(value) = serde_json::from_str::(trimmed) else { continue; }; - if let Some(id) = value.get("id").and_then(Value::as_u64) { - seen.insert(id); + let id = match value.get("id") { + Some(Value::String(id)) => Some(id.clone()), + Some(Value::Number(id)) => Some(id.to_string()), + _ => None, + }; + if let Some(id) = id { + seen.insert(id.clone()); if id == stop_id { // Give any late (incorrectly-sent) response a brief window to arrive. deadline = tokio::time::Instant::now() + Duration::from_millis(300);