Skip to content

Commit b7f2429

Browse files
Copilotstephentoub
andcommitted
Fix sse-retry hang: cancel foreground transport send when response arrives via background channel
The sse-retry conformance test hangs intermittently because of a race between the background GET SSE stream and the tools/call POST request at the conformance server. When the background GET arrives at the server AFTER the tools/call POST (due to CI scheduling delays), the server sends the tool response on the background GET stream instead of the foreground retry GET. The foreground retry GET then blocks forever waiting for data that never comes. This happens because the server's handleGetSSEStream checks pendingToolCallId and sends the tool response to whichever GET request is being handled at that moment. If background GET #1 is delayed and arrives after tools/call sets pendingToolCallId, it receives the response. The foreground retry GET #2 then arrives and finds pendingToolCallId=null, so it gets no response. Fix by creating a linked CancellationTokenSource in SendRequestAsync that cancels when the response TCS is completed (from any channel). This interrupts the blocked foreground transport send, allowing SendRequestAsync to proceed to the TCS await where the response is already available. The disposal timeout in transport DisposeAsync is kept as defense-in-depth for cases where the background GET stream doesn't respond promptly to cancellation during shutdown. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent ba8d9bf commit b7f2429

1 file changed

Lines changed: 26 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,32 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
528528
LogSendingRequest(EndpointName, request.Method);
529529
}
530530

531-
await SendToRelatedTransportAsync(request, cancellationToken).ConfigureAwait(false);
531+
// Cancel the transport send when the response TCS completes. This handles the case
532+
// where a concurrent background stream (e.g., Streamable HTTP's background GET SSE)
533+
// already delivered the response to the session via the shared message channel,
534+
// while the foreground transport send (a retry GET that the server left open without
535+
// sending a response) is still blocked waiting for data. Without this, the foreground
536+
// await at SendToRelatedTransportAsync would hang indefinitely.
537+
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
538+
_ = tcs.Task.ContinueWith(
539+
static (_, state) =>
540+
{
541+
try { ((CancellationTokenSource)state!).Cancel(); }
542+
catch (ObjectDisposedException) { }
543+
},
544+
sendCts,
545+
CancellationToken.None,
546+
TaskContinuationOptions.ExecuteSynchronously,
547+
TaskScheduler.Default);
548+
549+
try
550+
{
551+
await SendToRelatedTransportAsync(request, sendCts.Token).ConfigureAwait(false);
552+
}
553+
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
554+
{
555+
// The send was canceled because the response arrived through another channel.
556+
}
532557

533558
// Now that the request has been sent, register for cancellation. If we registered before,
534559
// a cancellation request could arrive before the server knew about that request ID, in which

0 commit comments

Comments
 (0)