fix(transport): cancel in-flight request on streamable-HTTP client disconnect (#857)#967
Merged
DaleSeo merged 1 commit intoJul 17, 2026
Conversation
DaleSeo
reviewed
Jul 10, 2026
DaleSeo
left a comment
Member
There was a problem hiding this comment.
Thanks for the fix, @ameyypawar! I left a couple of comments.
| }); | ||
| Ok(futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner))) | ||
| let stream = futures::stream::iter(priming).chain(ReceiverStream::new(receiver.inner)); | ||
| Ok(CancelOnDisconnect::new(stream, handle.clone(), request_id)) |
Member
There was a problem hiding this comment.
Could we also handle client disconnects after a request-wise SSE stream has been resumed? resume() currently returns a plain ReceiverStream without the disconnect guard.
| // blocking. If the channel is momentarily full the request is not | ||
| // cancelled early, but the session keep-alive/idle timeout still reaps | ||
| // it — no worse than before this fix. | ||
| let _ = self.handle.event_tx.try_send(SessionEvent::ClientMessage { |
Member
There was a problem hiding this comment.
Could we make sure the disconnect cancellation isn’t silently dropped when the session event channel is full? Since the result of try_send is ignored, a busy session could still leave the handler running until cleanup.
ameyypawar
force-pushed
the
fix/857-streamable-http-disconnect-cancel
branch
from
July 13, 2026 03:26
10c947e to
aaec423
Compare
Contributor
|
@DaleSeo Can you take another look after the revisions? And @ameyypawar It looks like there is a test failure to look into. |
ameyypawar
force-pushed
the
fix/857-streamable-http-disconnect-cancel
branch
from
July 17, 2026 08:57
aaec423 to
74c65c4
Compare
… client disconnect (modelcontextprotocol#857) 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).
ameyypawar
force-pushed
the
fix/857-streamable-http-disconnect-cancel
branch
from
July 17, 2026 09:12
74c65c4 to
9092067
Compare
DaleSeo
approved these changes
Jul 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #857.
Problem
On the streamable-HTTP server transport, when a client disconnects while a tool handler is still running, the server-side future is not cancelled —
RequestContext::ctnever fires. Long-running or destructive tools keep running after the client is gone (Ctrl-C, read timeout, network drop). Only an explicitnotifications/cancelledfires the token today.Scope: stateless mode
This fixes the stateless path (the issue's reproduction). A stateless streamable-HTTP request is one-shot — no session, no resumption — so a dropped response is terminal and safe to cancel.
Stateful (resumable) mode is intentionally left unchanged. There an in-flight SSE stream can be resumed via
Last-Event-ID(the transport keeps the request channel alive and advertisesretry), so treating a disconnect as a cancellation would break resumption — disconnection is not cancellation for a resumable transport. Cancelling in-flight requests on the resumable path needs a reconnect grace-period design and is out of scope here.Fix
Give each stateless request its own
CancellationTokenviaserve_directly_with_ct, and cancel it when the client disconnects:CancelOnDisconnectguard on the response stream fires the token if the stream is dropped before it ends naturally. It disarms on natural completion, so a normal response is never cancelled.Cancelling the token ends the dedicated
serve_directlyloop, which cancels the handler'sRequestContext::ct.Notes:
Tests
Adds
tests/test_streamable_http_disconnect_cancel.rscovering both stateless sub-modes (SSE and JSON). Each test fails without the fix (the handler runs to its timeout) and passes with it. The full non-local test suite passes, andclippy --all-features -D warningsandcargo fmtare clean.