From 7f05cd7b703827b48df407d7ba44183376cd5ab8 Mon Sep 17 00:00:00 2001 From: Luis Rodriguez <25299418+luisangelrod@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:36:05 -0400 Subject: [PATCH 1/2] Close stdio input before waiting for server exit --- .../Client/StdioClientSessionTransport.cs | 8 ++++ .../Transport/StdioClientTransportTests.cs | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs index caee6b383..87a7403f9 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs @@ -61,6 +61,14 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell // so create an exception with details about that. error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false); + // Closing the server's stdin is the portable graceful-shutdown signal for stdio transports. + // Do this before waiting so a well-behaved server can finish its own cleanup and exit. + try + { + _process.StandardInput.Close(); + } + catch { } + // Ensure all pending ErrorDataReceived events are drained before detaching // the handler. GetUnexpectedExitExceptionAsync does this when HasExited is // true, but there is a narrow window on Linux where the process has closed diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 60ce9cf5a..4c12aef6d 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; +using System.Diagnostics; using System.IO.Pipelines; using System.Runtime.InteropServices; using System.Text; @@ -13,6 +14,46 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime; + [Fact] + public async Task DisposeAsync_ClosesServerStandardInputBeforeWaitingForExit() + { + TimeSpan shutdownTimeout = TimeSpan.FromSeconds(4); + string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); + string testServerDll = Path.Combine(AppContext.BaseDirectory, "TestServer.dll"); + + StdioClientTransport transport = new(new() + { + Name = "TestServer", + Command = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch + { + (true, _) => "mono", + (_, true) => testServerExecutable, + _ => "dotnet", + }, + Arguments = (PlatformDetection.IsMonoRuntime, PlatformDetection.IsWindows) switch + { + (true, _) => [testServerExecutable], + (_, true) => [], + _ => [testServerDll], + }, + ShutdownTimeout = shutdownTimeout, + }, LoggerFactory); + + await using ITransport session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + Stopwatch stopwatch = Stopwatch.StartNew(); + await session.DisposeAsync(); + stopwatch.Stop(); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromTicks(shutdownTimeout.Ticks / 2), + $"Disposal took {stopwatch.Elapsed}, indicating it waited for the {shutdownTimeout} shutdown timeout."); + + var exception = await Assert.ThrowsAsync( + async () => await session.MessageReader.Completion); + var completionDetails = Assert.IsType(exception.Details); + Assert.Equal(0, completionDetails.ExitCode); + } + [Fact] public async Task ConnectAsync_DoesNotLogEnvironmentVariablesAtTrace() { From 620d49cf8c99f50ed440b18441af8a7df696e5ba Mon Sep 17 00:00:00 2001 From: Luis Rodriguez Date: Thu, 27 Aug 2026 08:19:54 -0400 Subject: [PATCH 2/2] Test graceful stdio shutdown without timing assumptions --- .../Transport/StdioClientTransportTests.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 4c12aef6d..73fd65d09 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -2,7 +2,6 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Tests.Utils; -using System.Diagnostics; using System.IO.Pipelines; using System.Runtime.InteropServices; using System.Text; @@ -15,7 +14,7 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime; [Fact] - public async Task DisposeAsync_ClosesServerStandardInputBeforeWaitingForExit() + public async Task DisposeAsync_ClosesServerStandardInputForGracefulExit() { TimeSpan shutdownTimeout = TimeSpan.FromSeconds(4); string testServerExecutable = Path.Combine(AppContext.BaseDirectory, "TestServer.exe"); @@ -41,16 +40,13 @@ public async Task DisposeAsync_ClosesServerStandardInputBeforeWaitingForExit() await using ITransport session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - Stopwatch stopwatch = Stopwatch.StartNew(); await session.DisposeAsync(); - stopwatch.Stop(); - - Assert.True(stopwatch.Elapsed < TimeSpan.FromTicks(shutdownTimeout.Ticks / 2), - $"Disposal took {stopwatch.Elapsed}, indicating it waited for the {shutdownTimeout} shutdown timeout."); var exception = await Assert.ThrowsAsync( async () => await session.MessageReader.Completion); var completionDetails = Assert.IsType(exception.Details); + // A zero exit code proves the server observed stdin EOF and exited on its own + // instead of being terminated after ShutdownTimeout. Assert.Equal(0, completionDetails.ExitCode); }