Skip to content

Commit 69a22a2

Browse files
committed
Fix process crash when testing Stderr
1 parent 8970b0a commit 69a22a2

2 files changed

Lines changed: 29 additions & 7 deletions

File tree

src/ModelContextProtocol.Core/Client/StdioClientTransport.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,17 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
151151
stderrRollingLog.Enqueue(data);
152152
}
153153

154-
_options.StandardErrorLines?.Invoke(data);
154+
try
155+
{
156+
_options.StandardErrorLines?.Invoke(data);
157+
}
158+
catch (Exception ex)
159+
{
160+
// Prevent exceptions in the user callback from propagating
161+
// to the background thread that dispatches ErrorDataReceived,
162+
// which would crash the process.
163+
LogStderrCallbackFailed(logger, endpointName, ex);
164+
}
155165

156166
LogReadStderr(logger, endpointName, data);
157167
}
@@ -230,12 +240,10 @@ internal static void DisposeProcess(
230240

231241
// Ensure all redirected stderr/stdout events have been dispatched
232242
// before disposing. Only the no-arg WaitForExit() guarantees this;
233-
// WaitForExit(int) (as used by KillTree) does not.
234-
// This should not hang: either the process already exited on its own
235-
// (no child processes holding handles), or KillTree killed the entire
236-
// process tree. If it does take too long, the test infrastructure's
237-
// own timeout will catch it.
238-
if (!processRunning && HasExited(process))
243+
// WaitForExit(int) (as used by KillTree) does not. We must call
244+
// this whether the process exited on its own or was killed above,
245+
// otherwise ErrorDataReceived handlers may fire after Dispose().
246+
if (HasExited(process))
239247
{
240248
process.WaitForExit();
241249
}
@@ -299,6 +307,9 @@ private static string EscapeArgumentString(string argument) =>
299307
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
300308
private static partial void LogReadStderr(ILogger logger, string endpointName, string data);
301309

310+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} StandardErrorLines callback failed.")]
311+
private static partial void LogStderrCallbackFailed(ILogger logger, string endpointName, Exception exception);
312+
302313
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")]
303314
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);
304315

tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
5959
Assert.Contains(id, sb.ToString());
6060
}
6161

62+
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
63+
public async Task CreateAsync_StdErrCallbackThrows_DoesNotCrashProcess()
64+
{
65+
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
66+
new(new() { Command = "cmd", Arguments = ["/c", "echo fail >&2 & exit /b 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory) :
67+
new(new() { Command = "sh", Arguments = ["-c", "echo fail >&2; exit 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory);
68+
69+
// Should throw IOException for the failed server, not crash the host process.
70+
await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
71+
}
72+
6273
[Theory]
6374
[InlineData(null)]
6475
[InlineData("argument with spaces")]

0 commit comments

Comments
 (0)