-
Notifications
You must be signed in to change notification settings - Fork 667
Fix process crash when testing Stderr #1449
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
69a22a2
ee120f6
2539d5c
82623d1
db2baaa
c1d3675
8f6e91f
fa6e7b3
ccfd685
5cae6fb
0647e71
a698b02
8ad61e5
bc10e41
289e6b6
6db87b9
567b4f9
2c41608
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = | |
|
|
||
| Process? process = null; | ||
| bool processStarted = false; | ||
| DataReceivedEventHandler? errorHandler = null; | ||
|
|
||
| string command = _options.Command; | ||
| IList<string>? arguments = _options.Arguments; | ||
|
|
@@ -136,7 +137,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = | |
| // few lines in a rolling log for use in exceptions. | ||
| const int MaxStderrLength = 10; // keep the last 10 lines of stderr | ||
| Queue<string> stderrRollingLog = new(MaxStderrLength); | ||
| process.ErrorDataReceived += (sender, args) => | ||
| errorHandler = (sender, args) => | ||
| { | ||
| string? data = args.Data; | ||
| if (data is not null) | ||
|
|
@@ -151,11 +152,22 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = | |
| stderrRollingLog.Enqueue(data); | ||
| } | ||
|
|
||
| _options.StandardErrorLines?.Invoke(data); | ||
| try | ||
| { | ||
| _options.StandardErrorLines?.Invoke(data); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| // Prevent exceptions in the user callback from propagating | ||
| // to the background thread that dispatches ErrorDataReceived, | ||
| // which would crash the process. | ||
| LogStderrCallbackFailed(logger, endpointName, ex); | ||
| } | ||
|
|
||
| LogReadStderr(logger, endpointName, data); | ||
| } | ||
| }; | ||
| process.ErrorDataReceived += errorHandler; | ||
|
|
||
| // We need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core, | ||
| // we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but | ||
|
|
@@ -191,16 +203,26 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken = | |
|
|
||
| LogTransportProcessStarted(logger, endpointName, process.Id); | ||
|
|
||
| process.BeginErrorReadLine(); | ||
| // Suppress ExecutionContext flow so the Process's internal async | ||
| // stderr reader thread doesn't capture the caller's context. | ||
| using (ExecutionContext.SuppressFlow()) | ||
| { | ||
| process.BeginErrorReadLine(); | ||
| } | ||
|
|
||
| return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory); | ||
| return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| LogTransportConnectFailed(logger, endpointName, ex); | ||
|
|
||
| try | ||
| { | ||
| if (process is not null && errorHandler is not null) | ||
| { | ||
| process.ErrorDataReceived -= errorHandler; | ||
| } | ||
|
|
||
| DisposeProcess(process, processStarted, _options.ShutdownTimeout); | ||
| } | ||
| catch (Exception ex2) | ||
|
|
@@ -228,18 +250,6 @@ internal static void DisposeProcess( | |
| process.KillTree(shutdownTimeout); | ||
| } | ||
|
|
||
| // Ensure all redirected stderr/stdout events have been dispatched | ||
| // before disposing. Only the no-arg WaitForExit() guarantees this; | ||
| // WaitForExit(int) (as used by KillTree) does not. | ||
| // This should not hang: either the process already exited on its own | ||
| // (no child processes holding handles), or KillTree killed the entire | ||
| // process tree. If it does take too long, the test infrastructure's | ||
| // own timeout will catch it. | ||
| if (!processRunning && HasExited(process)) | ||
| { | ||
| process.WaitForExit(); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't we now possibly miss some error messages being reported over stderr from the child process?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The In the The reason for removing it from
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
WaitForExit(timeout) doesn't wait for EOF of the streams though, right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right — I checked the .NET runtime source for On .NET Framework there's no |
||
|
|
||
| // Invoke the callback while the process handle is still valid, | ||
| // e.g. to read ExitCode before Dispose() invalidates it. | ||
| beforeDispose?.Invoke(); | ||
|
|
@@ -299,6 +309,9 @@ private static string EscapeArgumentString(string argument) => | |
| [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")] | ||
| private static partial void LogReadStderr(ILogger logger, string endpointName, string data); | ||
|
|
||
| [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} StandardErrorLines callback failed.")] | ||
| private static partial void LogStderrCallbackFailed(ILogger logger, string endpointName, Exception exception); | ||
|
|
||
| [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")] | ||
| private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| #if NET | ||
| using System.Diagnostics; | ||
| using System.Runtime.CompilerServices; | ||
| using System.Runtime.ExceptionServices; | ||
|
|
||
| namespace ModelContextProtocol.Tests.Utils; | ||
|
|
||
| /// <summary> | ||
| /// Module initializer that hooks global exception events to diagnose async void crashes | ||
| /// and other unobserved exceptions during test execution. This is intended to capture | ||
| /// evidence for intermittent xUnit test runner hangs where an async void method's exception | ||
| /// may go unobserved, preventing a TaskCompletionSource from being signaled. | ||
| /// </summary> | ||
| internal static class DiagnosticExceptionTracing | ||
| { | ||
| private static int s_initialized; | ||
| private static int s_unhandledExceptionCount; | ||
| private static int s_unobservedTaskExceptionCount; | ||
|
|
||
| [ModuleInitializer] | ||
| internal static void Initialize() | ||
| { | ||
| if (Interlocked.Exchange(ref s_initialized, 1) != 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // This fires when an async void method throws and the exception propagates to the | ||
| // thread pool. If the xUnit runTest async void function throws before its try block, | ||
| // we'll see it here. Note: this typically terminates the process, so seeing output | ||
| // from this handler is strong evidence of the suspected bug. | ||
| AppDomain.CurrentDomain.UnhandledException += (sender, e) => | ||
| { | ||
| int count = Interlocked.Increment(ref s_unhandledExceptionCount); | ||
| string terminating = e.IsTerminating ? "TERMINATING" : "non-terminating"; | ||
| Console.Error.WriteLine($"[DiagnosticExceptionTracing] UnhandledException #{count} ({terminating}): {e.ExceptionObject}"); | ||
| Console.Error.Flush(); | ||
|
|
||
| // Also write to Trace in case stderr is not captured | ||
| Trace.WriteLine($"[DiagnosticExceptionTracing] UnhandledException #{count} ({terminating}): {e.ExceptionObject}"); | ||
| }; | ||
|
|
||
| // This fires when a Task's exception is never observed (no await, no Wait, no Result). | ||
| // Could indicate fire-and-forget tasks with silent failures. | ||
| TaskScheduler.UnobservedTaskException += (sender, e) => | ||
| { | ||
| int count = Interlocked.Increment(ref s_unobservedTaskExceptionCount); | ||
| Console.Error.WriteLine($"[DiagnosticExceptionTracing] UnobservedTaskException #{count}: {e.Exception}"); | ||
| Console.Error.Flush(); | ||
| }; | ||
|
|
||
| Console.Error.WriteLine("[DiagnosticExceptionTracing] Exception tracing hooks installed"); | ||
| Console.Error.Flush(); | ||
| } | ||
| } | ||
| #endif |
Uh oh!
There was an error while loading. Please reload this page.