Fix process crash when testing Stderr (#1449)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
70442e9351
commit
2e7209c35a
13 changed files with 254 additions and 148 deletions
|
|
@ -10,15 +10,17 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
private readonly StdioClientTransportOptions _options;
|
private readonly StdioClientTransportOptions _options;
|
||||||
private readonly Process _process;
|
private readonly Process _process;
|
||||||
private readonly Queue<string> _stderrRollingLog;
|
private readonly Queue<string> _stderrRollingLog;
|
||||||
|
private readonly DataReceivedEventHandler _errorHandler;
|
||||||
private int _cleanedUp = 0;
|
private int _cleanedUp = 0;
|
||||||
private readonly int? _processId;
|
private readonly int? _processId;
|
||||||
|
|
||||||
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, ILoggerFactory? loggerFactory) :
|
public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue<string> stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
|
||||||
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
|
base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_process = process;
|
_process = process;
|
||||||
_stderrRollingLog = stderrRollingLog;
|
_stderrRollingLog = stderrRollingLog;
|
||||||
|
_errorHandler = errorHandler;
|
||||||
try { _processId = process.Id; } catch { }
|
try { _processId = process.Id; } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +35,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
{
|
{
|
||||||
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
|
// We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
|
||||||
// for the I/O error, we should throw an exception for that instead.
|
// for the I/O error, we should throw an exception for that instead.
|
||||||
if (await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false) is Exception processExitException)
|
if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
|
||||||
{
|
{
|
||||||
throw processExitException;
|
throw processExitException;
|
||||||
}
|
}
|
||||||
|
|
@ -45,15 +47,32 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Only clean up once.
|
// Only run the full stdio cleanup once (handler detach, process kill, etc.).
|
||||||
|
// If another call is already handling cleanup, cancel the shutdown token
|
||||||
|
// to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
|
||||||
|
// call SetDisconnected with full StdioClientCompletionDetails.
|
||||||
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
|
if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
|
||||||
{
|
{
|
||||||
|
CancelShutdown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
|
// We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
|
||||||
// so create an exception with details about that.
|
// so create an exception with details about that.
|
||||||
error ??= await GetUnexpectedExitExceptionAsync(cancellationToken).ConfigureAwait(false);
|
error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
|
||||||
|
// so HasExited returns false and the drain is skipped. An unconditional
|
||||||
|
// wait here covers that gap. When the drain already happened above, the
|
||||||
|
// call returns immediately.
|
||||||
|
await WaitForProcessExitAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Detach the stderr handler so no further ErrorDataReceived events
|
||||||
|
// are dispatched during or after process disposal.
|
||||||
|
_process.ErrorDataReceived -= _errorHandler;
|
||||||
|
|
||||||
// Terminate the server process (or confirm it already exited), then build
|
// Terminate the server process (or confirm it already exited), then build
|
||||||
// and publish strongly-typed completion details while the process handle
|
// and publish strongly-typed completion details while the process handle
|
||||||
|
|
@ -78,7 +97,7 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
|
await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync(CancellationToken cancellationToken)
|
private async ValueTask<Exception?> GetUnexpectedExitExceptionAsync()
|
||||||
{
|
{
|
||||||
if (!StdioClientTransport.HasExited(_process))
|
if (!StdioClientTransport.HasExited(_process))
|
||||||
{
|
{
|
||||||
|
|
@ -86,18 +105,8 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.Assert(StdioClientTransport.HasExited(_process));
|
Debug.Assert(StdioClientTransport.HasExited(_process));
|
||||||
try
|
|
||||||
{
|
await WaitForProcessExitAsync().ConfigureAwait(false);
|
||||||
// The process has exited, but we still need to ensure stderr has been flushed.
|
|
||||||
// WaitForExitAsync only waits for exit; it does not guarantee that all
|
|
||||||
// ErrorDataReceived events have been dispatched. The synchronous WaitForExit()
|
|
||||||
// (no arguments) does ensure that, so call it after WaitForExitAsync completes.
|
|
||||||
#if NET
|
|
||||||
await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
#endif
|
|
||||||
_process.WaitForExit();
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
string errorMessage = "MCP server process exited unexpectedly";
|
string errorMessage = "MCP server process exited unexpectedly";
|
||||||
|
|
||||||
|
|
@ -122,6 +131,35 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
|
||||||
return new IOException(errorMessage);
|
return new IOException(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for the process to exit within <see cref="StdioClientTransportOptions.ShutdownTimeout"/>
|
||||||
|
/// and flushes pending <see cref="Process.ErrorDataReceived"/> events.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On .NET, <c>Process.WaitForExitAsync</c> also waits for asynchronous output readers
|
||||||
|
/// to complete, ensuring all events have been dispatched. On .NET Framework,
|
||||||
|
/// <see cref="Process.WaitForExit(int)"/> does not guarantee this; the parameterless
|
||||||
|
/// <see cref="Process.WaitForExit()"/> overload is needed to flush the event queue.
|
||||||
|
/// This method is idempotent—calling it after the process has already been waited on
|
||||||
|
/// returns immediately.
|
||||||
|
/// </remarks>
|
||||||
|
private async ValueTask WaitForProcessExitAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#if NET
|
||||||
|
using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
|
||||||
|
await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
#else
|
||||||
|
if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
|
||||||
|
{
|
||||||
|
_process.WaitForExit();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
|
private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
|
||||||
{
|
{
|
||||||
StdioClientCompletionDetails details = new()
|
StdioClientCompletionDetails details = new()
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
|
|
||||||
Process? process = null;
|
Process? process = null;
|
||||||
bool processStarted = false;
|
bool processStarted = false;
|
||||||
|
DataReceivedEventHandler? errorHandler = null;
|
||||||
|
|
||||||
string command = _options.Command;
|
string command = _options.Command;
|
||||||
IList<string>? arguments = _options.Arguments;
|
IList<string>? arguments = _options.Arguments;
|
||||||
|
|
@ -136,7 +137,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
// few lines in a rolling log for use in exceptions.
|
// few lines in a rolling log for use in exceptions.
|
||||||
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
|
const int MaxStderrLength = 10; // keep the last 10 lines of stderr
|
||||||
Queue<string> stderrRollingLog = new(MaxStderrLength);
|
Queue<string> stderrRollingLog = new(MaxStderrLength);
|
||||||
process.ErrorDataReceived += (sender, args) =>
|
errorHandler = (sender, args) =>
|
||||||
{
|
{
|
||||||
string? data = args.Data;
|
string? data = args.Data;
|
||||||
if (data is not null)
|
if (data is not null)
|
||||||
|
|
@ -151,11 +152,22 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
stderrRollingLog.Enqueue(data);
|
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);
|
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 need both stdin and stdout to use a no-BOM UTF-8 encoding. On .NET Core,
|
||||||
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
// we can use ProcessStartInfo.StandardOutputEncoding/StandardInputEncoding, but
|
||||||
|
|
@ -193,7 +205,7 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
|
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, _loggerFactory);
|
return new StdioClientSessionTransport(_options, process, endpointName, stderrRollingLog, errorHandler, _loggerFactory);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -201,6 +213,11 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (process is not null && errorHandler is not null)
|
||||||
|
{
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
|
}
|
||||||
|
|
||||||
DisposeProcess(process, processStarted, _options.ShutdownTimeout);
|
DisposeProcess(process, processStarted, _options.ShutdownTimeout);
|
||||||
}
|
}
|
||||||
catch (Exception ex2)
|
catch (Exception ex2)
|
||||||
|
|
@ -228,18 +245,6 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
process.KillTree(shutdownTimeout);
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoke the callback while the process handle is still valid,
|
// Invoke the callback while the process handle is still valid,
|
||||||
// e.g. to read ExitCode before Dispose() invalidates it.
|
// e.g. to read ExitCode before Dispose() invalidates it.
|
||||||
beforeDispose?.Invoke();
|
beforeDispose?.Invoke();
|
||||||
|
|
@ -299,6 +304,9 @@ public sealed partial class StdioClientTransport : IClientTransport
|
||||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received stderr log: '{Data}'.")]
|
||||||
private static partial void LogReadStderr(ILogger logger, string endpointName, string 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}.")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} started server process with PID {ProcessId}.")]
|
||||||
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);
|
private static partial void LogTransportProcessStarted(ILogger logger, string endpointName, int processId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
private readonly TextReader _serverOutput;
|
private readonly TextReader _serverOutput;
|
||||||
private readonly Stream _serverInputStream;
|
private readonly Stream _serverInputStream;
|
||||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
private CancellationTokenSource? _shutdownCts = new();
|
private readonly CancellationTokenSource _shutdownCts = new();
|
||||||
private Task? _readTask;
|
private Task? _readTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -97,8 +97,16 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public override ValueTask DisposeAsync() =>
|
public override async ValueTask DisposeAsync()
|
||||||
CleanupAsync(cancellationToken: CancellationToken.None);
|
{
|
||||||
|
await CleanupAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Ensure the channel is always completed after disposal, even if CleanupAsync
|
||||||
|
// returned early because another caller (e.g. ReadMessagesAsync) was already
|
||||||
|
// running cleanup. SetDisconnected is idempotent—if the channel was already
|
||||||
|
// completed by the other cleanup path, this is a no-op.
|
||||||
|
SetDisconnected();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
private async Task ReadMessagesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
@ -168,15 +176,20 @@ internal class StreamClientSessionTransport : TransportBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels the shutdown token to signal that the transport is shutting down,
|
||||||
|
/// without performing any other cleanup.
|
||||||
|
/// </summary>
|
||||||
|
protected void CancelShutdown()
|
||||||
|
{
|
||||||
|
_shutdownCts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
protected virtual async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
LogTransportShuttingDown(Name);
|
LogTransportShuttingDown(Name);
|
||||||
|
|
||||||
if (Interlocked.Exchange(ref _shutdownCts, null) is { } shutdownCts)
|
await _shutdownCts.CancelAsync().ConfigureAwait(false);
|
||||||
{
|
|
||||||
await shutdownCts.CancelAsync().ConfigureAwait(false);
|
|
||||||
shutdownCts.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Interlocked.Exchange(ref _readTask, null) is Task readTask)
|
if (Interlocked.Exchange(ref _readTask, null) is Task readTask)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -82,24 +82,29 @@ public class ClientConformanceTests
|
||||||
|
|
||||||
var process = new Process { StartInfo = startInfo };
|
var process = new Process { StartInfo = startInfo };
|
||||||
|
|
||||||
process.OutputDataReceived += (sender, e) =>
|
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||||
|
// throwing on a background thread if events arrive after the test completes.
|
||||||
|
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
_output.WriteLine(e.Data);
|
try { _output.WriteLine(e.Data); } catch { }
|
||||||
outputBuilder.AppendLine(e.Data);
|
outputBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.ErrorDataReceived += (sender, e) =>
|
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
_output.WriteLine(e.Data);
|
try { _output.WriteLine(e.Data); } catch { }
|
||||||
errorBuilder.AppendLine(e.Data);
|
errorBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
process.OutputDataReceived += outputHandler;
|
||||||
|
process.ErrorDataReceived += errorHandler;
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
@ -112,6 +117,8 @@ public class ClientConformanceTests
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
process.Kill(entireProcessTree: true);
|
process.Kill(entireProcessTree: true);
|
||||||
|
process.OutputDataReceived -= outputHandler;
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
return (
|
return (
|
||||||
Success: false,
|
Success: false,
|
||||||
Output: outputBuilder.ToString(),
|
Output: outputBuilder.ToString(),
|
||||||
|
|
@ -119,6 +126,9 @@ public class ClientConformanceTests
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.OutputDataReceived -= outputHandler;
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
|
|
||||||
var output = outputBuilder.ToString();
|
var output = outputBuilder.ToString();
|
||||||
var error = errorBuilder.ToString();
|
var error = errorBuilder.ToString();
|
||||||
var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);
|
var success = process.ExitCode == 0 || HasOnlyWarnings(output, error);
|
||||||
|
|
|
||||||
|
|
@ -136,24 +136,29 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
||||||
|
|
||||||
var process = new Process { StartInfo = startInfo };
|
var process = new Process { StartInfo = startInfo };
|
||||||
|
|
||||||
process.OutputDataReceived += (sender, e) =>
|
// Protect callbacks with try/catch to prevent ITestOutputHelper from
|
||||||
|
// throwing on a background thread if events arrive after the test completes.
|
||||||
|
DataReceivedEventHandler outputHandler = (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
output.WriteLine(e.Data);
|
try { output.WriteLine(e.Data); } catch { }
|
||||||
outputBuilder.AppendLine(e.Data);
|
outputBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.ErrorDataReceived += (sender, e) =>
|
DataReceivedEventHandler errorHandler = (sender, e) =>
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
output.WriteLine(e.Data);
|
try { output.WriteLine(e.Data); } catch { }
|
||||||
errorBuilder.AppendLine(e.Data);
|
errorBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
process.OutputDataReceived += outputHandler;
|
||||||
|
process.ErrorDataReceived += errorHandler;
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
@ -166,6 +171,8 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
process.Kill(entireProcessTree: true);
|
process.Kill(entireProcessTree: true);
|
||||||
|
process.OutputDataReceived -= outputHandler;
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
return (
|
return (
|
||||||
Success: false,
|
Success: false,
|
||||||
Output: outputBuilder.ToString(),
|
Output: outputBuilder.ToString(),
|
||||||
|
|
@ -173,6 +180,9 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.OutputDataReceived -= outputHandler;
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Success: process.ExitCode == 0,
|
Success: process.ExitCode == 0,
|
||||||
Output: outputBuilder.ToString(),
|
Output: outputBuilder.ToString(),
|
||||||
|
|
|
||||||
|
|
@ -105,85 +105,96 @@ var clientTransport = new HttpClientTransport(new()
|
||||||
OAuth = oauthOptions,
|
OAuth = oauthOptions,
|
||||||
}, loggerFactory: consoleLoggerFactory);
|
}, loggerFactory: consoleLoggerFactory);
|
||||||
|
|
||||||
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
try
|
||||||
|
|
||||||
bool success = true;
|
|
||||||
|
|
||||||
switch (scenario)
|
|
||||||
{
|
{
|
||||||
case "tools_call":
|
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
||||||
{
|
|
||||||
var tools = await mcpClient.ListToolsAsync();
|
|
||||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
|
||||||
|
|
||||||
// Call the "add_numbers" tool
|
bool success = true;
|
||||||
var toolName = "add_numbers";
|
|
||||||
Console.WriteLine($"Calling tool: {toolName}");
|
|
||||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
{ "a", 5 },
|
|
||||||
{ "b", 10 }
|
|
||||||
});
|
|
||||||
success &= !(result.IsError == true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "elicitation-sep1034-client-defaults":
|
|
||||||
{
|
|
||||||
var tools = await mcpClient.ListToolsAsync();
|
|
||||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
|
||||||
var toolName = "test_client_elicitation_defaults";
|
|
||||||
Console.WriteLine($"Calling tool: {toolName}");
|
|
||||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
|
||||||
success &= !(result.IsError == true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "sse-retry":
|
|
||||||
{
|
|
||||||
var tools = await mcpClient.ListToolsAsync();
|
|
||||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
|
||||||
var toolName = "test_reconnection";
|
|
||||||
Console.WriteLine($"Calling tool: {toolName}");
|
|
||||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
|
||||||
success &= !(result.IsError == true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "auth/scope-step-up":
|
|
||||||
{
|
|
||||||
// Just testing that we can authenticate and list tools
|
|
||||||
var tools = await mcpClient.ListToolsAsync();
|
|
||||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
|
||||||
|
|
||||||
// Call the "test_tool" tool
|
switch (scenario)
|
||||||
var toolName = "test-tool";
|
|
||||||
Console.WriteLine($"Calling tool: {toolName}");
|
|
||||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
{ "foo", "bar" },
|
|
||||||
});
|
|
||||||
success &= !(result.IsError == true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "auth/scope-retry-limit":
|
|
||||||
{
|
{
|
||||||
// Try to list tools - this triggers the auth flow that always fails with 403.
|
case "tools_call":
|
||||||
// The test validates the client doesn't retry indefinitely.
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await mcpClient.ListToolsAsync();
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
|
||||||
|
// Call the "add_numbers" tool
|
||||||
|
var toolName = "add_numbers";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ "a", 5 },
|
||||||
|
{ "b", 10 }
|
||||||
|
});
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
case "elicitation-sep1034-client-defaults":
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Expected auth failure: {ex.Message}");
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
var toolName = "test_client_elicitation_defaults";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
case "sse-retry":
|
||||||
|
{
|
||||||
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
var toolName = "test_reconnection";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "auth/scope-step-up":
|
||||||
|
{
|
||||||
|
// Just testing that we can authenticate and list tools
|
||||||
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
|
||||||
|
// Call the "test_tool" tool
|
||||||
|
var toolName = "test-tool";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ "foo", "bar" },
|
||||||
|
});
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "auth/scope-retry-limit":
|
||||||
|
{
|
||||||
|
// Try to list tools - this triggers the auth flow that always fails with 403.
|
||||||
|
// The test validates the client doesn't retry indefinitely.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mcpClient.ListToolsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Expected auth failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// No extra processing for other scenarios
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
// No extra processing for other scenarios
|
// Exit code 0 on success, 1 on failure
|
||||||
break;
|
return success ? 0 : 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Report the error to stderr and exit with a non-zero code rather than
|
||||||
|
// crashing the process with an unhandled exception. An unhandled exception
|
||||||
|
// generates a crash dump which can abort the parent test host.
|
||||||
|
Console.Error.WriteLine($"Conformance client failed: {ex}");
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit code 0 on success, 1 on failure
|
|
||||||
return success ? 0 : 1;
|
|
||||||
|
|
||||||
// Copied from ProtectedMcpClient sample
|
// Copied from ProtectedMcpClient sample
|
||||||
// Simulate a user opening the browser and logging in
|
// Simulate a user opening the browser and logging in
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@ namespace ModelContextProtocol.Tests.Configuration;
|
||||||
public class McpServerBuilderExtensionsTransportsTests
|
public class McpServerBuilderExtensionsTransportsTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WithStdioServerTransport_Sets_Transport()
|
public void WithStdioServerTransport_Registers_Transport()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddMcpServer().WithStdioServerTransport();
|
services.AddMcpServer().WithStdioServerTransport();
|
||||||
|
|
||||||
var transportServiceType = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
|
// Verify StdioServerTransport is registered for ITransport, but don't resolve it —
|
||||||
Assert.NotNull(transportServiceType);
|
// doing so opens Console.OpenStandardInput() which permanently blocks a thread pool
|
||||||
|
// thread on the test host's stdin. StdioServerTransport should only be used in a
|
||||||
var serviceProvider = services.BuildServiceProvider();
|
// dedicated child process, not in-process.
|
||||||
Assert.IsType<StdioServerTransport>(serviceProvider.GetRequiredService<ITransport>());
|
var transportDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ITransport));
|
||||||
|
Assert.NotNull(transportDescriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -181,9 +181,10 @@ public class McpServerOptionsSetupTests
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.WithResources<SimpleResourceType>()
|
.WithResources<SimpleResourceType>()
|
||||||
.WithStdioServerTransport();
|
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||||
|
|
||||||
var options = services.BuildServiceProvider().GetRequiredService<IOptions<McpServerOptions>>().Value;
|
await using var sp = services.BuildServiceProvider();
|
||||||
|
var options = sp.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||||
|
|
||||||
// The options should preserve the user's manually set capabilities
|
// The options should preserve the user's manually set capabilities
|
||||||
Assert.NotNull(options.Capabilities?.Resources);
|
Assert.NotNull(options.Capabilities?.Resources);
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,9 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.WithResources<LiveResources>()
|
.WithResources<LiveResources>()
|
||||||
.WithStdioServerTransport();
|
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||||
|
|
||||||
var serviceProvider = services.BuildServiceProvider();
|
await using var serviceProvider = services.BuildServiceProvider();
|
||||||
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||||
|
|
||||||
// Verify capabilities are preserved
|
// Verify capabilities are preserved
|
||||||
|
|
@ -122,15 +122,15 @@ public class McpServerResourceCapabilityIssueReproTests : ClientServerTestBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
|
public async Task ResourcesCapability_IsCreated_WhenOnlyResourcesAreProvided()
|
||||||
{
|
{
|
||||||
// Test that ResourcesCapability is created even without handlers or manual setting
|
// Test that ResourcesCapability is created even without handlers or manual setting
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
var builder = services.AddMcpServer()
|
var builder = services.AddMcpServer()
|
||||||
.WithResources<LiveResources>()
|
.WithResources<LiveResources>()
|
||||||
.WithStdioServerTransport();
|
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||||
|
|
||||||
var serviceProvider = services.BuildServiceProvider();
|
await using var serviceProvider = services.BuildServiceProvider();
|
||||||
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
var mcpOptions = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||||
|
|
||||||
// Resources are registered
|
// Resources are registered
|
||||||
|
|
|
||||||
|
|
@ -15,29 +15,29 @@ public class McpServerLoggingLevelTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanCreateServerWithLoggingLevelHandler()
|
public async Task CanCreateServerWithLoggingLevelHandler()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||||
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
provider.GetRequiredService<McpServer>();
|
provider.GetRequiredService<McpServer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddingLoggingLevelHandlerSetsLoggingCapability()
|
public async Task AddingLoggingLevelHandlerSetsLoggingCapability()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||||
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
.WithSetLoggingLevelHandler(async (ctx, ct) => new EmptyResult());
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
var server = provider.GetRequiredService<McpServer>();
|
var server = provider.GetRequiredService<McpServer>();
|
||||||
|
|
||||||
|
|
@ -46,12 +46,12 @@ public class McpServerLoggingLevelTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
|
public async Task ServerWithoutCallingLoggingLevelHandlerDoesNotSetLoggingCapability()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport();
|
.WithStreamServerTransport(Stream.Null, Stream.Null);
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
var server = provider.GetRequiredService<McpServer>();
|
var server = provider.GetRequiredService<McpServer>();
|
||||||
Assert.Null(server.ServerOptions.Capabilities?.Logging);
|
Assert.Null(server.ServerOptions.Capabilities?.Logging);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,12 @@ public partial class McpServerResourceTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanCreateServerWithResource()
|
public async Task CanCreateServerWithResource()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||||
.WithListResourcesHandler(async (ctx, ct) =>
|
.WithListResourcesHandler(async (ctx, ct) =>
|
||||||
{
|
{
|
||||||
return new ListResourcesResult
|
return new ListResourcesResult
|
||||||
|
|
@ -58,19 +58,19 @@ public partial class McpServerResourceTests
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
provider.GetRequiredService<McpServer>();
|
provider.GetRequiredService<McpServer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CanCreateServerWithResourceTemplates()
|
public async Task CanCreateServerWithResourceTemplates()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||||
.WithListResourceTemplatesHandler(async (ctx, ct) =>
|
.WithListResourceTemplatesHandler(async (ctx, ct) =>
|
||||||
{
|
{
|
||||||
return new ListResourceTemplatesResult
|
return new ListResourceTemplatesResult
|
||||||
|
|
@ -94,17 +94,17 @@ public partial class McpServerResourceTests
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
provider.GetRequiredService<McpServer>();
|
provider.GetRequiredService<McpServer>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreatingReadHandlerWithNoListHandlerSucceeds()
|
public async Task CreatingReadHandlerWithNoListHandlerSucceeds()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithStdioServerTransport()
|
.WithStreamServerTransport(Stream.Null, Stream.Null)
|
||||||
.WithReadResourceHandler(async (ctx, ct) =>
|
.WithReadResourceHandler(async (ctx, ct) =>
|
||||||
{
|
{
|
||||||
return new ReadResourceResult
|
return new ReadResourceResult
|
||||||
|
|
@ -117,7 +117,7 @@ public partial class McpServerResourceTests
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
var sp = services.BuildServiceProvider();
|
await using var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
sp.GetRequiredService<McpServer>();
|
sp.GetRequiredService<McpServer>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,17 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
|
||||||
Assert.Contains(id, sb.ToString());
|
Assert.Contains(id, sb.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
|
||||||
|
public async Task CreateAsync_StdErrCallbackThrows_DoesNotCrashProcess()
|
||||||
|
{
|
||||||
|
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
|
||||||
|
new(new() { Command = "cmd", Arguments = ["/c", "echo fail >&2 & exit /b 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory) :
|
||||||
|
new(new() { Command = "sh", Arguments = ["-c", "echo fail >&2; exit 1"], StandardErrorLines = _ => throw new InvalidOperationException("boom") }, LoggerFactory);
|
||||||
|
|
||||||
|
// Should throw IOException for the failed server, not crash the host process.
|
||||||
|
await Assert.ThrowsAnyAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(null)]
|
[InlineData(null)]
|
||||||
[InlineData("argument with spaces")]
|
[InlineData("argument with spaces")]
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,11 @@ public class StdioServerTransportTests : LoggedTest
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
public async Task Constructor_Should_Initialize_With_Valid_Parameters()
|
||||||
{
|
{
|
||||||
// Act
|
// Use StreamServerTransport with Stream.Null rather than StdioServerTransport.
|
||||||
await using var transport = new StdioServerTransport(_serverOptions);
|
// StdioServerTransport opens Console.OpenStandardInput() which permanently
|
||||||
|
// blocks a thread pool thread on the test host's stdin. StdioServerTransport
|
||||||
|
// should only be instantiated in a dedicated child process.
|
||||||
|
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null, _serverOptions.ServerInfo?.Name);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(transport);
|
Assert.NotNull(transport);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue