Skip to content

Commit 37976fc

Browse files
ericstjCopilot
andcommitted
Race initialize request against processing loop for timing-independent fix
When a server process exits immediately, the message processing loop can complete its pending-request sweep before SendRequestAsync registers the initialize TCS. The existing flag+check pattern handles most interleavings but ConcurrentDictionary iteration is non-atomic, leaving edge cases in Debug builds with wider timing windows. Use Task.WhenAny to race the initialize request against the processing task. If the processing loop exits first (EOF on stdout), we detect it immediately and throw IOException instead of hanging indefinitely. The flag-based defense in McpSessionHandler.SendRequestAsync is kept as defense-in-depth for post-initialization requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8d8df28 commit 37976fc

1 file changed

Lines changed: 18 additions & 3 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
533533
{
534534
// We don't want the ConnectAsync token to cancel the message processing loop after we've successfully connected.
535535
// The session handler handles cancelling the loop upon its disposal.
536-
_ = _sessionHandler.ProcessMessagesAsync(CancellationToken.None);
536+
var processingTask = _sessionHandler.ProcessMessagesAsync(CancellationToken.None);
537537

538538
// Perform initialization sequence
539539
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -543,7 +543,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
543543
{
544544
// Send initialize request
545545
string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion;
546-
var initializeResponse = await SendRequestAsync(
546+
var initializeTask = SendRequestAsync(
547547
RequestMethods.Initialize,
548548
new InitializeRequestParams
549549
{
@@ -553,7 +553,22 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
553553
},
554554
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
555555
McpJsonUtilities.JsonContext.Default.InitializeResult,
556-
cancellationToken: initializationCts.Token).ConfigureAwait(false);
556+
cancellationToken: initializationCts.Token).AsTask();
557+
558+
// Race the initialize request against the message processing loop.
559+
// If the processing loop exits first (e.g., the server process died and
560+
// its stdout closed), the initialize request will never get a response.
561+
// Detect this immediately rather than waiting for the initialization timeout.
562+
if (await Task.WhenAny(initializeTask, processingTask).ConfigureAwait(false) == processingTask)
563+
{
564+
// Observe the processing task so its exception isn't unobserved.
565+
try { await processingTask.ConfigureAwait(false); }
566+
catch { }
567+
568+
throw new IOException("Transport closed before initialization could complete.");
569+
}
570+
571+
var initializeResponse = await initializeTask.ConfigureAwait(false);
557572

558573
// Store server information
559574
if (_logger.IsEnabled(LogLevel.Information))

0 commit comments

Comments
 (0)