Skip to content

Commit 417bdbb

Browse files
ericstjCopilot
andcommitted
Fix test hang: use instrumented xUnit with async void GC root fix
xUnit v3's TestRunner.RunTest wraps test execution in an async void callback (required by ExecutionContext.Run's ContextCallback signature). When the test's async continuation chain forms a cycle with no external GC root (no I/O, no timers), the entire object graph is collectible. In Release mode on Ubuntu, the JIT's aggressive optimizations make this reproducible — the async void state machine gets collected, the test never completes, and the test host hangs. The fix extracts the async void body into an async Task method and holds a reference to the returned Task, keeping the entire async chain GC-rooted for the lifetime of RunTest. Also reverts prior ConnectAsync/McpSessionHandler workarounds that were attempting to address this from the product side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7916e99 commit 417bdbb

File tree

5 files changed

+23
-98
lines changed

5 files changed

+23
-98
lines changed

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 3 additions & 44 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-
Task processingTask = _sessionHandler.ProcessMessagesAsync(CancellationToken.None);
536+
_ = _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-
Task<InitializeResult> initializeTask = SendRequestAsync(
546+
var initializeResponse = await SendRequestAsync(
547547
RequestMethods.Initialize,
548548
new InitializeRequestParams
549549
{
@@ -553,48 +553,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
553553
},
554554
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
555555
McpJsonUtilities.JsonContext.Default.InitializeResult,
556-
cancellationToken: initializationCts.Token).AsTask();
557-
558-
// Race the initialize request against the processing loop and an independently-
559-
// rooted timeout. Task.Delay creates a System.Threading.Timer rooted in the
560-
// runtime's timer queue, which provides a GC root for the WhenAny continuation
561-
// chain (Timer Queue → Timer → Task.Delay → WhenAny → ConnectAsync). This
562-
// ensures ConnectAsync always resumes even in edge cases where the
563-
// initializeTask/processingTask chains become otherwise GC-unreachable.
564-
// Use a dedicated CTS (not the caller's cancellationToken) so external
565-
// cancellation flows through initializeTask via initializationCts, preserving
566-
// the expected OperationCanceledException propagation path.
567-
using var delayCts = new CancellationTokenSource();
568-
Task delayTask = Task.Delay(_options.InitializationTimeout, delayCts.Token);
569-
try
570-
{
571-
Task completed = await Task.WhenAny(initializeTask, processingTask, delayTask).ConfigureAwait(false);
572-
573-
if (completed == processingTask && !initializeTask.IsCompleted)
574-
{
575-
// The server process exited before initialization completed. Re-sweep
576-
// pending requests to signal the TCS in case ProcessMessagesCoreAsync's
577-
// sweep missed it due to ConcurrentDictionary's non-atomic iteration.
578-
// The TCS uses RunContinuationsAsynchronously, so initializeTask may not
579-
// be completed yet, but it will complete shortly — await it below.
580-
_sessionHandler.FailPendingRequests();
581-
}
582-
// When delayTask wins, the CancelAfter timer (same timeout, started earlier)
583-
// will cancel initializationCts.Token, causing the WaitAsync in SendRequestAsync
584-
// to throw OperationCanceledException. The catch block below converts that to
585-
// TimeoutException, preserving the expected exception type for callers.
586-
}
587-
finally
588-
{
589-
// Cancel the delay timer to avoid leaking it after ConnectAsync completes.
590-
#if NET
591-
await delayCts.CancelAsync().ConfigureAwait(false);
592-
#else
593-
delayCts.Cancel();
594-
#endif
595-
}
596-
597-
var initializeResponse = await initializeTask.ConfigureAwait(false);
556+
cancellationToken: initializationCts.Token).ConfigureAwait(false);
598557

599558
// Store server information
600559
if (_logger.IsEnabled(LogLevel.Information))

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,6 @@ internal static bool SupportsPrimingEvent(string? protocolVersion)
9292
private CancellationTokenSource? _messageProcessingCts;
9393
private Task? _messageProcessingTask;
9494

95-
// Gate used to make the completion flag + sweep in ProcessMessagesCoreAsync mutually exclusive
96-
// with the flag check in SendRequestAsync. This ensures that either the sweep finds the TCS or
97-
// SendRequestAsync sees the flag, even if ConcurrentDictionary's non-atomic iteration races
98-
// with a concurrent add. The lock also provides full memory barriers on all architectures.
99-
private readonly object _completionSweepGate = new();
100-
private bool _messageProcessingComplete;
101-
10295
/// <summary>
10396
/// Initializes a new instance of the <see cref="McpSessionHandler"/> class.
10497
/// </summary>
@@ -331,49 +324,21 @@ ex is OperationCanceledException &&
331324
await allHandlersCompleted.Task.ConfigureAwait(false);
332325
}
333326

327+
// Fail any pending requests, as they'll never be satisfied.
334328
// If the transport's channel was completed with a ClientTransportClosedException,
335329
// propagate it so callers can access the structured completion details.
336330
Exception pendingException =
337331
_transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion &&
338332
completion.Exception?.InnerException is { } innerException
339333
? innerException
340334
: new IOException("The server shut down unexpectedly.");
341-
342-
// Fail any pending requests, as they'll never be satisfied.
343-
// The lock ensures mutual exclusion with SendRequestAsync's flag check:
344-
// either our sweep finds the TCS, or SendRequestAsync sees the flag — even if
345-
// ConcurrentDictionary's non-atomic iteration misses a concurrent add.
346-
lock (_completionSweepGate)
335+
foreach (var entry in _pendingRequests)
347336
{
348-
_messageProcessingComplete = true;
349-
350-
foreach (var entry in _pendingRequests)
351-
{
352-
entry.Value.TrySetException(pendingException);
353-
}
337+
entry.Value.TrySetException(pendingException);
354338
}
355339
}
356340
}
357341

358-
/// <summary>
359-
/// Signals all pending requests with an appropriate exception derived from the transport's
360-
/// channel completion state. Called as a fallback when ProcessMessagesCoreAsync has completed
361-
/// but a concurrent SendRequestAsync may have been missed by the cleanup sweep.
362-
/// </summary>
363-
internal void FailPendingRequests()
364-
{
365-
Exception pendingException =
366-
_transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion &&
367-
completion.Exception?.InnerException is { } innerException
368-
? innerException
369-
: new IOException("The server shut down unexpectedly.");
370-
371-
foreach (var entry in _pendingRequests)
372-
{
373-
entry.Value.TrySetException(pendingException);
374-
}
375-
}
376-
377342
/// <summary>
378343
/// Resolves <see cref="ClientCompletionDetails"/> from the transport's channel completion.
379344
/// If the channel was completed with a <see cref="ClientTransportClosedException"/>, the wrapped
@@ -611,22 +576,6 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
611576

612577
var tcs = new TaskCompletionSource<JsonRpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
613578
_pendingRequests[request.Id] = tcs;
614-
615-
// If message processing has already completed (transport closed), fail the request
616-
// immediately. The lock ensures mutual exclusion with ProcessMessagesCoreAsync's
617-
// sweep: either the sweep found this TCS, or we see the flag here. This eliminates
618-
// the race where ConcurrentDictionary's non-atomic iteration misses a concurrent add.
619-
lock (_completionSweepGate)
620-
{
621-
if (_messageProcessingComplete)
622-
{
623-
if (_pendingRequests.TryRemove(request.Id, out var removed))
624-
{
625-
removed.TrySetException(new IOException("The server shut down unexpectedly."));
626-
}
627-
}
628-
}
629-
630579
try
631580
{
632581
if (addTags)
176 KB
Binary file not shown.
478 KB
Binary file not shown.

tests/Directory.Build.props

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,21 @@
88
<!-- Suppress the obsolete SSE warning in test projects that need to test legacy SSE -->
99
<NoWarn>$(NoWarn);MCP9004</NoWarn>
1010
</PropertyGroup>
11+
12+
<!--
13+
Replace xUnit v3 DLLs with instrumented builds that fix a GC-collectibility bug
14+
in TestRunner.RunTest's async void state machine. The instrumented build extracts
15+
the async void body into an async Task method and holds a reference to the returned
16+
Task, preventing the entire async chain from being garbage-collected when the only
17+
references form a cycle.
18+
See: https://github.com/modelcontextprotocol/csharp-sdk/pull/1449
19+
TODO: Remove once xUnit ships a fix upstream.
20+
-->
21+
<PropertyGroup>
22+
<InstrumentedXunitDir>$(MSBuildThisFileDirectory)Common\InstrumentedXunit\</InstrumentedXunitDir>
23+
</PropertyGroup>
24+
<Target Name="ReplaceXunitWithInstrumented" AfterTargets="Build" Condition="Exists('$(InstrumentedXunitDir)xunit.v3.core.dll')">
25+
<Copy SourceFiles="$(InstrumentedXunitDir)xunit.v3.core.dll" DestinationFolder="$(OutputPath)" OverwriteReadOnlyFiles="true" />
26+
<Copy SourceFiles="$(InstrumentedXunitDir)xunit.v3.common.dll" DestinationFolder="$(OutputPath)" OverwriteReadOnlyFiles="true" />
27+
</Target>
1128
</Project>

0 commit comments

Comments
 (0)