Skip to content

Commit bc2c5f3

Browse files
authored
fix(transport): cancel in-flight request on stateless streamable-HTTP client disconnect (#857) (#967)
A stateless streamable-HTTP request is one-shot (no session, no resumption), so if the client drops the response before the handler finishes, the request is terminal and should be cancelled. Previously the handler kept running with its `RequestContext::ct` never firing, so long-running or destructive tools could not observe a client disconnect. Give each stateless request its own cancellation token via `serve_directly_with_ct` and cancel it when the client disconnects. This covers both stateless paths: `serve_negotiated_request_directly` (per-request version negotiation) and the non-negotiated path. In each: - A disconnect while the handler is still producing its first message cancels it. The guard is disarmed once the handler emits anything, so a normal response is never cancelled. - The SSE response stream is wrapped in a guard that cancels the handler if the stream is dropped before it ends naturally. - When a negotiated request replies directly (JSON mode, or a non-OK status), the receiver is dropped, so a still-running handler is cancelled rather than left emitting into a closed channel. Without this its terminal send fails before adding the termination permit and the serve loop parks forever. Stateful (resumable) mode is intentionally left unchanged: there a disconnect may be recovered via `Last-Event-ID`, so cancelling on disconnect would break resumption. Adds a regression test covering both stateless sub-modes (SSE and JSON).
1 parent 9df629e commit bc2c5f3

3 files changed

Lines changed: 317 additions & 10 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,12 @@ required-features = [
426426
"transport-streamable-http-client-reqwest",
427427
]
428428
path = "tests/test_streamable_http_connection_reuse.rs"
429+
430+
[[test]]
431+
name = "test_streamable_http_disconnect_cancel"
432+
required-features = [
433+
"server",
434+
"transport-streamable-http-server",
435+
"reqwest",
436+
]
437+
path = "tests/test_streamable_http_disconnect_cancel.rs"

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
use std::{
2-
borrow::Cow, collections::HashMap, convert::Infallible, fmt::Display, sync::Arc, time::Duration,
2+
borrow::Cow,
3+
collections::HashMap,
4+
convert::Infallible,
5+
fmt::Display,
6+
pin::Pin,
7+
sync::Arc,
8+
task::{Context, Poll},
9+
time::Duration,
310
};
411

512
use bytes::Bytes;
6-
use futures::{StreamExt, future::BoxFuture};
13+
use futures::{Stream, StreamExt, future::BoxFuture};
714
use http::{HeaderMap, Method, Request, Response, header::ALLOW};
815
use http_body::Body;
916
use http_body_util::{BodyExt, Full, combinators::BoxBody};
17+
use pin_project_lite::pin_project;
1018
use tokio_stream::wrappers::ReceiverStream;
1119
use tokio_util::sync::CancellationToken;
1220

@@ -22,7 +30,7 @@ use crate::{
2230
ProtocolVersion, RequestId, ServerJsonRpcMessage,
2331
},
2432
serve_server,
25-
service::serve_directly,
33+
service::serve_directly_with_ct,
2634
transport::{
2735
OneshotTransport, TransportAdapterIdentity,
2836
common::{
@@ -852,14 +860,28 @@ where
852860
request.request.extensions_mut().insert(parts);
853861
let (transport, mut receiver) =
854862
OneshotTransport::<RoleServer>::new(ClientJsonRpcMessage::Request(request));
855-
let service = serve_directly(service, transport, peer_info);
863+
// Give this stateless request its own cancellation token so a client
864+
// disconnect can cancel the in-flight handler (#857), as in the
865+
// non-negotiated stateless path below.
866+
let request_ct = CancellationToken::new();
867+
let service = serve_directly_with_ct(service, transport, peer_info, request_ct.clone());
856868
tokio::spawn(async move {
857869
let _ = service.waiting().await;
858870
});
859871

860872
let cancel = self.config.cancellation_token.child_token();
873+
// Cancel the handler if the client disconnects while it is still
874+
// producing its first message (this future is dropped before
875+
// `receiver.recv()` completes). Disarmed once the handler emits
876+
// anything, so a normal response is never cancelled.
877+
let mut disconnect_guard = Some(request_ct.clone().drop_guard());
861878
let first = tokio::select! {
862-
message = receiver.recv() => message,
879+
message = receiver.recv() => {
880+
if let Some(guard) = disconnect_guard.take() {
881+
guard.disarm();
882+
}
883+
message
884+
}
863885
_ = cancel.cancelled() => None,
864886
}
865887
.ok_or_else(|| {
@@ -870,17 +892,26 @@ where
870892
})?;
871893

872894
if self.config.json_response || jsonrpc_http_status(&first) != http::StatusCode::OK {
895+
// This message is the whole reply, so `receiver` is dropped here and
896+
// anything the handler emits afterwards is undeliverable. Cancel it so
897+
// a still-running handler stops instead of running on unobserved: its
898+
// terminal `send` would otherwise fail before adding the termination
899+
// permit, leaving the serve loop parked forever. A no-op when the
900+
// handler already completed.
901+
request_ct.cancel();
873902
return jsonrpc_message_response(first, true);
874903
}
875904

905+
// The handler may still be streaming, so guard the response: dropping it
906+
// (client disconnect) must cancel the handler.
876907
let stream = futures::stream::once(async move { first })
877908
.chain(ReceiverStream::new(receiver))
878909
.map(|message| {
879910
tracing::trace!(?message);
880911
ServerSseMessage::from_message(message)
881912
});
882913
Ok(sse_stream_response(
883-
stream,
914+
CancelOnDisconnect::new(stream, request_ct),
884915
self.config.sse_keep_alive,
885916
self.config.cancellation_token.child_token(),
886917
))
@@ -1544,7 +1575,13 @@ where
15441575
request.request.extensions_mut().insert(part);
15451576
let (transport, mut receiver) =
15461577
OneshotTransport::<RoleServer>::new(ClientJsonRpcMessage::Request(request));
1547-
let service = serve_directly(service, transport, peer_info);
1578+
// Give this stateless request its own cancellation token so a
1579+
// client disconnect can cancel the in-flight handler (#857). A
1580+
// stateless request is one-shot (no session, no resumption), so a
1581+
// dropped response is terminal and safe to cancel.
1582+
let request_ct = CancellationToken::new();
1583+
let service =
1584+
serve_directly_with_ct(service, transport, peer_info, request_ct.clone());
15481585
tokio::spawn(async move {
15491586
// on service created
15501587
let _ = service.waiting().await;
@@ -1554,8 +1591,19 @@ where
15541591
// emits an intermediate notification or request, preserve
15551592
// the complete message sequence by falling back to SSE.
15561593
let cancel = self.config.cancellation_token.child_token();
1594+
// Cancel the handler if the client disconnects while it is
1595+
// still producing its first message (this future is dropped
1596+
// before `receiver.recv()` completes). Disarmed once the
1597+
// handler emits anything, so a normal response is never
1598+
// cancelled.
1599+
let mut disconnect_guard = Some(request_ct.clone().drop_guard());
15571600
let Some(message) = (tokio::select! {
1558-
res = receiver.recv() => res,
1601+
res = receiver.recv() => {
1602+
if let Some(guard) = disconnect_guard.take() {
1603+
guard.disarm();
1604+
}
1605+
res
1606+
}
15591607
_ = cancel.cancelled() => None,
15601608
}) else {
15611609
return Err(internal_error_response("empty response")(
@@ -1579,6 +1627,9 @@ where
15791627
.body(Full::new(Bytes::from(body)).boxed())
15801628
.expect("valid response"))
15811629
} else {
1630+
// The handler emitted an intermediate message and is still
1631+
// running, so guard the streamed sequence too: dropping it
1632+
// (client disconnect) must cancel the handler.
15821633
let first = futures::stream::once(async move {
15831634
ServerSseMessage::from_message(message)
15841635
});
@@ -1587,17 +1638,19 @@ where
15871638
ServerSseMessage::from_message(message)
15881639
});
15891640
Ok(sse_stream_response(
1590-
first.chain(remaining),
1641+
CancelOnDisconnect::new(first.chain(remaining), request_ct),
15911642
self.config.sse_keep_alive,
15921643
self.config.cancellation_token.child_token(),
15931644
))
15941645
}
15951646
} else {
1596-
// SSE mode (default): original behaviour preserved unchanged
1647+
// SSE mode (default): cancel the handler if the client
1648+
// disconnects (drops the response stream) before it completes.
15971649
let stream = ReceiverStream::new(receiver).map(|message| {
15981650
tracing::trace!(?message);
15991651
ServerSseMessage::from_message(message)
16001652
});
1653+
let stream = CancelOnDisconnect::new(stream, request_ct);
16011654
Ok(sse_stream_response(
16021655
stream,
16031656
self.config.sse_keep_alive,
@@ -1680,3 +1733,52 @@ where
16801733
})
16811734
}
16821735
}
1736+
1737+
pin_project! {
1738+
/// Wraps a stateless SSE response stream so a client disconnect cancels the
1739+
/// in-flight request.
1740+
///
1741+
/// A stateless streamable-HTTP request is one-shot: it has no session and no
1742+
/// resumption, so a dropped response stream means the client is gone for
1743+
/// good. When the stream is dropped *before* it ends naturally, the request's
1744+
/// cancellation token is fired, which stops the dedicated `serve_directly`
1745+
/// loop and cancels the handler's `RequestContext::ct` (see #857). If the
1746+
/// stream ends naturally (the request completed), the guard is disarmed so
1747+
/// normal completion cancels nothing.
1748+
struct CancelOnDisconnect<S> {
1749+
#[pin]
1750+
inner: S,
1751+
ct: Option<CancellationToken>,
1752+
}
1753+
impl<S> PinnedDrop for CancelOnDisconnect<S> {
1754+
fn drop(this: Pin<&mut Self>) {
1755+
let this = this.project();
1756+
if let Some(ct) = this.ct.take() {
1757+
ct.cancel();
1758+
}
1759+
}
1760+
}
1761+
}
1762+
1763+
impl<S> CancelOnDisconnect<S> {
1764+
fn new(inner: S, ct: CancellationToken) -> Self {
1765+
Self {
1766+
inner,
1767+
ct: Some(ct),
1768+
}
1769+
}
1770+
}
1771+
1772+
impl<S: Stream> Stream for CancelOnDisconnect<S> {
1773+
type Item = S::Item;
1774+
1775+
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1776+
let this = self.project();
1777+
let polled = this.inner.poll_next(cx);
1778+
if let Poll::Ready(None) = &polled {
1779+
// Ended naturally: the request completed, so don't cancel on drop.
1780+
*this.ct = None;
1781+
}
1782+
polled
1783+
}
1784+
}

0 commit comments

Comments
 (0)