Skip to content

Commit 54779aa

Browse files
ericstjCopilot
andcommitted
Add Task.Delay as GC root in ConnectAsync WhenAny
The async chain from ConnectAsync through initializeTask/processingTask can become GC-unreachable on .NET 10/Ubuntu when the server process exits before responding. The CancelAfter timer roots the chain through CancellationToken registrations, but this path appears unreliable. Task.Delay creates a System.Threading.Timer that is directly rooted in the runtime's timer queue, providing a simple GC root chain: Timer Queue -> Timer -> Task.Delay -> WhenAny -> ConnectAsync. This ensures ConnectAsync always resumes within the initialization timeout, even if intermediate state machines are collected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 976f26c commit 54779aa

1 file changed

Lines changed: 21 additions & 7 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -555,15 +555,29 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
555555
McpJsonUtilities.JsonContext.Default.InitializeResult,
556556
cancellationToken: initializationCts.Token).AsTask();
557557

558-
// Race the initialize request against the processing loop. If the server process
559-
// exits before responding, processingTask completes first and we must ensure the
560-
// pending request TCS is signaled. The lock-based sweep in ProcessMessagesCoreAsync
561-
// handles most cases, but if ConcurrentDictionary's non-atomic iteration missed the
562-
// TCS, this explicit re-sweep catches it.
563-
if (await Task.WhenAny(initializeTask, processingTask).ConfigureAwait(false) == processingTask
564-
&& !initializeTask.IsCompleted)
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+
Task delayTask = Task.Delay(_options.InitializationTimeout, cancellationToken);
565+
Task completed = await Task.WhenAny(initializeTask, processingTask, delayTask).ConfigureAwait(false);
566+
567+
if (completed != initializeTask && !initializeTask.IsCompleted)
565568
{
569+
// Either the server process exited (processingTask) or the timeout fired
570+
// (delayTask) before initialization completed. Re-sweep pending requests
571+
// to signal the TCS in case ProcessMessagesCoreAsync's sweep missed it.
566572
_sessionHandler.FailPendingRequests();
573+
574+
if (!initializeTask.IsCompleted)
575+
{
576+
// The initialize task's async chain may be unreachable (intermediate
577+
// state machines collected by GC). Throw timeout directly.
578+
LogClientInitializationTimeout(_endpointName);
579+
throw new TimeoutException("Initialization timed out");
580+
}
567581
}
568582

569583
var initializeResponse = await initializeTask.ConfigureAwait(false);

0 commit comments

Comments
 (0)