Skip to content

Commit db2baaa

Browse files
committed
Detach error handlers before disposing process.
1 parent 82623d1 commit db2baaa

4 files changed

Lines changed: 37 additions & 23 deletions

File tree

src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
1010
private readonly StdioClientTransportOptions _options;
1111
private readonly Process _process;
1212
private readonly Queue<string> _stderrRollingLog;
13+
private readonly DataReceivedEventHandler _errorHandler;
1314
private int _cleanedUp = 0;
1415
private readonly int? _processId;
1516

16-
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
17+
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
1718
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
1819
{
1920
_options = options;
2021
_process = process;
2122
_stderrRollingLog = stderrRollingLog;
23+
_errorHandler = errorHandler;
2224
try { _processId = process.Id; } catch { }
2325
}
2426

@@ -55,6 +57,10 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
5557
// so create an exception with details about that.
5658
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
5759

60+
// Detach the stderr handler so no further ErrorDataReceived events
61+
// are dispatched during or after process disposal.
62+
_process.ErrorDataReceived -= _errorHandler;
63+
5864
// Terminate the server process (or confirm it already exited), then build
5965
// and publish strongly-typed completion details while the process handle
6066
// is still valid so we can read the exit code.
@@ -89,13 +95,11 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell
8995
try
9096
{
9197
// The process has exited, but we still need to ensure stderr has been flushed.
92-
// WaitForExitAsync only waits for exit; it does not guarantee that all
93-
// ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
94-
// (no arguments) does ensure that, so call it after WaitForExitAsync completes.
9598
#if NET
9699
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
100+
#else
101+
_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds);
97102
#endif
98-
_process.WaitForExit();
99103
}
100104
catch { }
101105

src/ModelContextProtocol.Core/Client/StdioClientTransport.cs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
5959

6060
Process? process = null;
6161
bool processStarted = false;
62+
DataReceivedEventHandler? errorHandler = null;
6263

6364
string command = _options.Command;
6465
IList<string>? arguments = _options.Arguments;
@@ -136,7 +137,7 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
136137
// few lines in a rolling log for use in exceptions.
137138
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
138139
Queue<string> stderrRollingLog = new(MaxStderrLength);
139-
process.ErrorDataReceived += (sender, args) =>
140+
process.ErrorDataReceived += errorHandler = (sender, args) =>
140141
{
141142
string? data = args.Data;
142143
if (data is not null)
@@ -203,14 +204,19 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
203204

204205
process.BeginErrorReadLine();
205206

206-
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
207+
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory);
207208
}
208209
catch (Exception ex)
209210
{
210211
LogTransportConnectFailed(logger, endpointName, ex);
211212

212213
try
213214
{
215+
if (process is not null && errorHandler is not null)
216+
{
217+
process.ErrorDataReceived -= errorHandler;
218+
}
219+
214220
DisposeProcess(process, processStarted, _options.ShutdownTimeout);
215221
}
216222
catch (Exception ex2)
@@ -238,18 +244,6 @@ internal static void DisposeProcess(
238244
process.KillTree(shutdownTimeout);
239245
}
240246

241-
// When the process exited on its own, call WaitForExit() to flush
242-
// any remaining ErrorDataReceived/OutputDataReceived events.
243-
// We skip this after KillTree because the parameterless WaitForExit()
244-
// blocks until all redirected output pipe handles are closed, which
245-
// hangs when grandchild processes (e.g. from cmd.exe /c npx) inherited
246-
// the handles. Late-firing events after KillTree are safe because the
247-
// ErrorDataReceived handler is protected with try/catch.
248-
if (!processRunning && HasExited(process))
249-
{
250-
process.WaitForExit();
251-
}
252-
253247
// Invoke the callback while the process handle is still valid,
254248
// e.g. to read ExitCode before Dispose() invalidates it.
255249
beforeDispose?.Invoke();

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public async Task RunConformanceTest(string scenario)
8484

8585
// Protect callbacks with try/catch to prevent ITestOutputHelper from
8686
// throwing on a background thread if events arrive after the test completes.
87-
process.OutputDataReceived += (sender, e) =>
87+
DataReceivedEventHandler outputHandler = (sender, e) =>
8888
{
8989
if (e.Data != null)
9090
{
@@ -93,7 +93,7 @@ public async Task RunConformanceTest(string scenario)
9393
}
9494
};
9595

96-
process.ErrorDataReceived += (sender, e) =>
96+
DataReceivedEventHandler errorHandler = (sender, e) =>
9797
{
9898
if (e.Data != null)
9999
{
@@ -102,6 +102,9 @@ public async Task RunConformanceTest(string scenario)
102102
}
103103
};
104104

105+
process.OutputDataReceived += outputHandler;
106+
process.ErrorDataReceived += errorHandler;
107+
105108
process.Start();
106109
process.BeginOutputReadLine();
107110
process.BeginErrorReadLine();
@@ -114,13 +117,18 @@ public async Task RunConformanceTest(string scenario)
114117
catch (OperationCanceledException)
115118
{
116119
process.Kill(entireProcessTree: true);
120+
process.OutputDataReceived -= outputHandler;
121+
process.ErrorDataReceived -= errorHandler;
117122
return (
118123
Success: false,
119124
Output: outputBuilder.ToString(),
120125
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
121126
);
122127
}
123128

129+
process.OutputDataReceived -= outputHandler;
130+
process.ErrorDataReceived -= errorHandler;
131+
124132
var output = outputBuilder.ToString();
125133
var error = errorBuilder.ToString();
126134
var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);

tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public async Task RunPendingConformanceTest_ServerSsePolling()
138138

139139
// Protect callbacks with try/catch to prevent ITestOutputHelper from
140140
// throwing on a background thread if events arrive after the test completes.
141-
process.OutputDataReceived += (sender, e) =>
141+
DataReceivedEventHandler outputHandler = (sender, e) =>
142142
{
143143
if (e.Data != null)
144144
{
@@ -147,7 +147,7 @@ public async Task RunPendingConformanceTest_ServerSsePolling()
147147
}
148148
};
149149

150-
process.ErrorDataReceived += (sender, e) =>
150+
DataReceivedEventHandler errorHandler = (sender, e) =>
151151
{
152152
if (e.Data != null)
153153
{
@@ -156,6 +156,9 @@ public async Task RunPendingConformanceTest_ServerSsePolling()
156156
}
157157
};
158158

159+
process.OutputDataReceived += outputHandler;
160+
process.ErrorDataReceived += errorHandler;
161+
159162
process.Start();
160163
process.BeginOutputReadLine();
161164
process.BeginErrorReadLine();
@@ -168,13 +171,18 @@ public async Task RunPendingConformanceTest_ServerSsePolling()
168171
catch (OperationCanceledException)
169172
{
170173
process.Kill(entireProcessTree: true);
174+
process.OutputDataReceived -= outputHandler;
175+
process.ErrorDataReceived -= errorHandler;
171176
return (
172177
Success: false,
173178
Output: outputBuilder.ToString(),
174179
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
175180
);
176181
}
177182

183+
process.OutputDataReceived -= outputHandler;
184+
process.ErrorDataReceived -= errorHandler;
185+
178186
return (
179187
Success: process.ExitCode == 0,
180188
Output: outputBuilder.ToString(),

0 commit comments

Comments
 (0)