Fix race condition in StdioClientTransport on .NET Framework #996
+19
−8
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Fix flaky ConnectAndPing_Stdio test on .NET Framework 4.7.2
This PR fixes a race condition in
StdioClientTransportthat causes test flakiness on .NET Framework 4.7.2.Problem
The
ConnectAndPing_Stdio(clientId: "everything")test was failing intermittently on .NET Framework 4.7.2 with a 60-second timeout during client initialization:The test passed consistently on .NET 8.0, 9.0, and 10.0, but failed only on .NET Framework 4.7.2.
Root Cause
On .NET Framework,
StdioClientTransport.ConnectAsynctemporarily modifies the globalConsole.InputEncodingto ensure UTF-8 encoding for stdin. This is necessary because .NET Framework'sProcessStartInfodoesn't have aStandardInputEncodingproperty.When multiple tests run in parallel (which they do by default in xUnit), they create a race condition:
At T8, Test B restores
Console.InputEncodingto UTF-8 instead of the original Default encoding. This leaves the console in an incorrect state. Subsequent process starts may get the wrong encoding, causing the spawned npx process to receive corrupted input, leading to communication failures and timeout errors.Solution
Added a static lock object (
s_consoleEncodingLock) that serializes access to theConsole.InputEncodingmodification on .NET Framework. This ensures atomic execution of:The lock is only compiled for .NET Framework builds (using
#if !NET) since modern .NET versions haveProcessStartInfo.StandardInputEncodingand don't need this workaround.Changes
s_consoleEncodingLockstatic field (lines 28-32) - only on .NET Frameworklockstatement (lines 170-182) - only on .NET Framework#elseblock (lines 168-169)Testing
Impact
This is a minimal, surgical fix that:
Original prompt
A test failed in CI. Please diagnose the flakiness and submit a PR to fix it.
failed ModelContextProtocol.Tests.ClientIntegrationTests.ConnectAndPing_Stdio(clientId: "everything") (1m 00s 092ms)
System.TimeoutException : Initialization timed out
---- System.OperationCanceledException : The operation was canceled.
from D:\a\csharp-sdk\csharp-sdk\artifacts\bin\ModelContextProtocol.Tests\Debug\net472\ModelContextProtocol.Tests.exe (net48|x64)
Xunit.MicrosoftTestingPlatform.XunitException: System.TimeoutException : Initialization timed out
---- System.OperationCanceledException : The operation was canceled.
at ModelContextProtocol.Client.McpClientImpl.d__27.MoveNext() in //src/ModelContextProtocol.Core/Client/McpClientImpl.cs:197
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at ModelContextProtocol.Client.McpClientImpl.d__27.MoveNext() in //src/ModelContextProtocol.Core/Client/McpClientImpl.cs:204
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.ConfiguredTaskAwaitable.ConfiguredTaskAwaiter.GetResult()
at ModelContextProtocol.Client.McpClient.d__6.MoveNext() in //src/ModelContextProtocol.Core/Client/McpClient.Methods.cs:42
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at ModelContextProtocol.Client.McpClient.d__6.MoveNext() in //src/ModelContextProtocol.Core/Client/McpClient.Methods.cs:47
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.TaskAwaiter
1.GetResult() at ModelContextProtocol.Tests.ClientIntegrationTests.<ConnectAndPing_Stdio>d__6.MoveNext() in /_/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs:36 --- End of stack trace from previous location where exception was thrown --- at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) --- End of stack trace from previous location where exception was thrown --- at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) ----- Inner Stack Trace ----- at System.Threading.CancellationToken.ThrowOperationCanceledException() at System.Threading.CancellationToken.ThrowIfCancellationRequested() at System.Threading.Tasks.TaskExtensions.<WaitAsync>d__3.MoveNext() in /_/src/Common/Polyfills/System/Threading/Tasks/TaskExtensions.cs:46 --- End of stack trace from previous location where exception was thrown --- at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task) at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) at System.Runtime.CompilerServices.ConfiguredTaskAwaitable.ConfiguredTaskAwaiter.GetResult() at System.Threading.Tasks.TaskExtensions.<WaitAsync>d__21.MoveNext() in //src/Common/Polyfills/System/Threading/Tasks/TaskExtensions.cs:20--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
at System.Runtime.CompilerServices.ConfiguredTaskAwaitable
1.ConfiguredTaskAwaiter.GetResult() at ModelContextProtocol.McpSessionHandler.<SendRequestAsync>d__33.MoveNext() in /_/src/ModelContextProtocol.Core/McpSessionHandler.cs:449 --- End of stack trace from previous location where exception was thrown --- at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task) at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task) at System.Runtime.CompilerServices.ConfiguredTaskAwaitable1.ConfiguredTaskAwaiter.GetResult()at ModelContextProtocol.McpSession.d__9`2.MoveNext() in //src/ModelContextProtocol.Core/McpSession.Methods.cs:71
--- End of stack trace from previous location where exception was thrown ---
at System.Runtime.ExceptionServices.E...
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.