Skip to content

Commit ba8d9bf

Browse files
Copilotstephentoub
andcommitted
Fix sse-retry hang: add timeout to background SSE task await during disposal
Revert the previous incorrect fix in McpSessionHandler.SendRequestAsync that targeted a race condition between background/foreground GET streams. That race doesn't actually occur because the conformance test server only sends the tool response when a NEW GET request arrives in handleGetSSEStream, not through previously-established background streams. The actual hang occurs in StreamableHttpClientSessionTransport.DisposeAsync() and SseClientSessionTransport.CloseAsync(), which await the background SSE receive task with no timeout. If cancellation doesn't promptly interrupt the SSE stream read (a platform-dependent behavior, especially on Windows), the await hangs indefinitely, preventing the client process from exiting and triggering the 5-minute conformance test timeout. Fix by using WaitAsync(TimeSpan.FromSeconds(10)) so disposal completes even if the background task doesn't respond to cancellation promptly. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 3379fd2 commit ba8d9bf

3 files changed

Lines changed: 9 additions & 27 deletions

File tree

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ private async Task CloseAsync()
114114
{
115115
if (_receiveTask != null)
116116
{
117-
await _receiveTask.ConfigureAwait(false);
117+
// Use a timeout to prevent hanging if cancellation doesn't
118+
// promptly interrupt the SSE stream read.
119+
await _receiveTask.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
118120
}
119121
}
120122
finally

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ public override async ValueTask DisposeAsync()
174174

175175
if (_getReceiveTask != null)
176176
{
177-
await _getReceiveTask.ConfigureAwait(false);
177+
// Use a timeout to prevent hanging if cancellation doesn't
178+
// promptly interrupt the background SSE stream read. This can
179+
// occur on some platforms where Stream.ReadAsync doesn't always
180+
// respect the cancellation token immediately.
181+
await _getReceiveTask.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
178182
}
179183
}
180184
catch (OperationCanceledException)

src/ModelContextProtocol.Core/McpSessionHandler.cs

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

531-
// Create a linked CTS that cancels when the response arrives through any channel.
532-
// This prevents transport retry loops from running indefinitely when a concurrent
533-
// receive stream (e.g., Streamable HTTP's background GET SSE) already delivered the
534-
// response to the session via the shared message channel.
535-
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
536-
_ = tcs.Task.ContinueWith(
537-
static (_, state) =>
538-
{
539-
try { ((CancellationTokenSource)state!).Cancel(); }
540-
catch (ObjectDisposedException) { }
541-
},
542-
sendCts,
543-
CancellationToken.None,
544-
TaskContinuationOptions.ExecuteSynchronously,
545-
TaskScheduler.Default);
546-
547-
try
548-
{
549-
await SendToRelatedTransportAsync(request, sendCts.Token).ConfigureAwait(false);
550-
}
551-
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
552-
{
553-
// The send was canceled because the response arrived through another channel
554-
// (e.g., a background GET SSE stream in the Streamable HTTP transport).
555-
}
531+
await SendToRelatedTransportAsync(request, cancellationToken).ConfigureAwait(false);
556532

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

0 commit comments

Comments
 (0)