// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions.CommandLine; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.IPC; using Microsoft.Testing.Platform.IPC.Models; using Microsoft.Testing.Platform.IPC.Serializers; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.ServerMode; using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.Tools; namespace Microsoft.Testing.Platform; internal sealed class DotnetTestConnection : IPushOnlyProtocol, IDisposable { private readonly CommandLineHandler _commandLineHandler; private readonly IEnvironment _environment; private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; private readonly ITestApplicationCancellationTokenSource _cancellationTokenSource; private readonly ILogger _logger; private IClient? _transportClient; private DotnetTestTransportKind _transportKind; private NamedPipeClient? _serverControlPipeClient; private Task? _serverControlListenerTask; private CancellationTokenSource? _serverControlListenerCts; private string? _serverControlPipeName; private int _cancelRequested; public static string InstanceId { get; } = Guid.NewGuid().ToString("N"); string IPushOnlyProtocol.InstanceId => InstanceId; public DotnetTestConnection(CommandLineHandler commandLineHandler, IEnvironment environment, ITestApplicationModuleInfo testApplicationModuleInfo, ITestApplicationCancellationTokenSource cancellationTokenSource) : this(commandLineHandler, environment, testApplicationModuleInfo, cancellationTokenSource, new NopLogger()) { } /// /// Initializes a new instance of the class with a logger for low-noise /// diagnostics of the control-pipe connect/listen/cancel/exit paths. /// public DotnetTestConnection(CommandLineHandler commandLineHandler, IEnvironment environment, ITestApplicationModuleInfo testApplicationModuleInfo, ITestApplicationCancellationTokenSource cancellationTokenSource, ILogger logger) { _commandLineHandler = commandLineHandler; _environment = environment; _testApplicationModuleInfo = testApplicationModuleInfo; _cancellationTokenSource = cancellationTokenSource; _logger = logger; } public bool IsServerMode => _transportClient?.IsConnected == true; public Task GetDataConsumerAsync() => Task.FromResult((IPushOnlyProtocolConsumer)new DotnetTestDataConsumer(this, _environment)); public async Task AfterCommonServiceSetupAsync() { if (_commandLineHandler.HasDotnetTestServerOption() && _commandLineHandler.TryGetDotnetTestTransport(out DotnetTestTransportKind transport)) { // The execution id is used to identify the test execution // We are storing it as an env var so that it can be read by the test host, test host controller and the test host orchestrator // If it already exists, we don't overwrite it if (RoslynString.IsNullOrEmpty(_environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID))) { _environment.SetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID, Guid.NewGuid().ToString("N")); } _transportKind = transport; _transportClient = transport switch { DotnetTestTransportKind.Http => CreateHttpClient(), DotnetTestTransportKind.NamedPipe when !OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi() => CreateNamedPipeClient(), DotnetTestTransportKind.NamedPipe => throw new PlatformNotSupportedException("The dotnet test named-pipe transport is not supported on WebAssembly."), _ => throw ApplicationStateGuard.Unreachable(), }; ((NamedPipeBase)_transportClient).RegisterAllSerializers(); await _transportClient.ConnectAsync(_cancellationTokenSource.CancellationToken).ConfigureAwait(false); } } [UnsupportedOSPlatform("browser")] private NamedPipeClient CreateNamedPipeClient() { ApplicationStateGuard.Ensure(_commandLineHandler.TryGetOptionArgumentList(PlatformCommandLineProvider.DotNetTestPipeOptionKey, out string[]? arguments)); return new NamedPipeClient(arguments[0], _environment); } private DotnetTestHttpClient CreateHttpClient() { ApplicationStateGuard.Ensure(_commandLineHandler.TryGetOptionArgumentList(PlatformCommandLineProvider.DotNetTestHttpEndpointOptionKey, out string[]? endpointArguments)); ApplicationStateGuard.Ensure(_commandLineHandler.TryGetOptionArgumentList(PlatformCommandLineProvider.DotNetTestHttpTokenOptionKey, out string[]? tokenArguments)); return new DotnetTestHttpClient(new Uri(endpointArguments[0], UriKind.Absolute), tokenArguments[0]); } public async Task HelpInvokedAsync() { IClient transportClient = _transportClient ?? throw new InvalidOperationException("The dotnet test transport client is not connected."); List commandLineHelpOptions = []; foreach (ICommandLineOptionsProvider commandLineOptionProvider in _commandLineHandler.CommandLineOptionsProviders) { if (commandLineOptionProvider is IToolCommandLineOptionsProvider) { continue; } foreach (CommandLineOption commandLineOption in commandLineOptionProvider.GetCommandLineOptions()) { commandLineHelpOptions.Add(new CommandLineOptionMessage( commandLineOption.Name, commandLineOption.Description, commandLineOption.IsHidden, commandLineOption.IsBuiltIn, commandLineOptionProvider.Uid, commandLineOption.Arity.Min, commandLineOption.Arity.Max)); } } await transportClient.RequestReplyAsync(new CommandLineOptionMessages(_testApplicationModuleInfo.GetCurrentTestApplicationFullPath(), [.. commandLineHelpOptions.OrderBy(option => option.Name)]), _cancellationTokenSource.CancellationToken).ConfigureAwait(false); } public bool IsIDE { get; private set; } // True once the handshake negotiated protocol version 1.2.0 or later, which is when the SDK is // able to receive AzureDevOpsLogMessage forwards. The host gates forwarding on this so an older // SDK (1.0.0/1.1.0) never receives an unknown message id. public bool IsLogForwardingSupported { get; private set; } // True once the handshake negotiated protocol version 1.3.0 or later, which is when the SDK is able to // receive generic DisplayMessage forwards (warning/error host diagnostics). The host gates forwarding on this // so an older SDK (<= 1.2.0) never receives an unknown message id. public bool IsDisplayMessageForwardingSupported { get; private set; } // True once the SDK advertised a reverse "server control" pipe name in its handshake reply. When set, the // test host opens a NamedPipeClient to that pipe and parks a long-poll so the SDK can push a // ServerControlMessage (e.g. CancelSession) at any time. The feature is gated on the presence of the handshake // property (a capability), not on the negotiated version string, so an older SDK simply never enables it. public bool IsServerControlChannelSupported { get; private set; } public bool IsRequiredArtifactPostProcessingSupported { get; private set; } public async Task IsCompatibleProtocolAsync(string hostType, IReadOnlyDictionary? additionalHandshakeProperties = null) { IClient transportClient = _transportClient ?? throw new InvalidOperationException("The dotnet test transport client is not connected."); string supportedProtocolVersions = ProtocolConstants.SupportedVersions; Dictionary properties = new() { { HandshakeMessagePropertyNames.PID, _environment.ProcessId.ToString(CultureInfo.InvariantCulture) }, { HandshakeMessagePropertyNames.Architecture, RuntimeInformation.ProcessArchitecture.ToString() }, { HandshakeMessagePropertyNames.Framework, RuntimeInformation.FrameworkDescription }, { HandshakeMessagePropertyNames.OS, RuntimeInformation.OSDescription }, { HandshakeMessagePropertyNames.SupportedProtocolVersions, supportedProtocolVersions }, { HandshakeMessagePropertyNames.HostType, hostType }, { HandshakeMessagePropertyNames.ModulePath, _testApplicationModuleInfo?.GetCurrentTestApplicationFullPath() ?? string.Empty }, { HandshakeMessagePropertyNames.ExecutionId, _environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID) ?? string.Empty }, { HandshakeMessagePropertyNames.InstanceId, InstanceId }, { HandshakeMessagePropertyNames.ExecutionMode, GetExecutionMode() }, }; if (hostType is HandshakeMessageHostTypes.TestHost or HandshakeMessageHostTypes.ServerTestHost) { properties.Add(HandshakeMessagePropertyNames.AttemptNumber, GetAttemptNumber()); } if (additionalHandshakeProperties is not null) { foreach (KeyValuePair property in additionalHandshakeProperties) { properties[property.Key] = property.Value; } } HandshakeMessage handshakeMessage = new(properties); HandshakeMessage response = await transportClient.RequestReplyAsync(handshakeMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); IsIDE = response.Properties?.TryGetValue(HandshakeMessagePropertyNames.IsIDE, out string? isIDEValue) == true && bool.TryParse(isIDEValue, out bool isIDE) && isIDE; IsRequiredArtifactPostProcessingSupported = response.Properties?.TryGetValue(HandshakeMessagePropertyNames.RequiredPostProcessingSupported, out string? requiredPostProcessingValue) is true && bool.TryParse(requiredPostProcessingValue, out bool requiredPostProcessingSupported) && requiredPostProcessingSupported; if (_transportKind == DotnetTestTransportKind.NamedPipe && response.Properties?.TryGetValue(HandshakeMessagePropertyNames.ServerControlPipeName, out string? serverControlPipeName) is true && !RoslynString.IsNullOrEmpty(serverControlPipeName)) { _serverControlPipeName = serverControlPipeName; IsServerControlChannelSupported = true; } if (response.Properties?.TryGetValue(HandshakeMessagePropertyNames.SupportedProtocolVersions, out string? protocolVersion) is true) { bool isCompatible = IsVersionCompatible(protocolVersion, supportedProtocolVersions); bool versionParsed = Version.TryParse(protocolVersion, out Version? negotiatedVersion); IsLogForwardingSupported = isCompatible && versionParsed && negotiatedVersion >= new Version(1, 2, 0); IsDisplayMessageForwardingSupported = isCompatible && versionParsed && negotiatedVersion >= new Version(1, 3, 0); return isCompatible; } return false; } private string GetExecutionMode() => _commandLineHandler.ParseResult.HasTool ? HandshakeMessageExecutionModes.Tool : _commandLineHandler.IsHelpInvoked() ? HandshakeMessageExecutionModes.Help : _commandLineHandler.IsOptionSet(PlatformCommandLineProvider.DiscoverTestsOptionKey) ? HandshakeMessageExecutionModes.Discover : HandshakeMessageExecutionModes.Run; private string GetAttemptNumber() { string? value = _environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER); return RoslynString.IsNullOrEmpty(value) ? "1" : int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int attemptNumber) && attemptNumber >= 1 ? attemptNumber.ToString(CultureInfo.InvariantCulture) : throw new InvalidOperationException($"Environment variable '{EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_ATTEMPTNUMBER}' must contain a positive integer."); } public static bool IsVersionCompatible(string protocolVersion, string supportedProtocolVersions) => supportedProtocolVersions.Split(';').Contains(protocolVersion); public async Task SendMessageAsync(IRequest message) { IClient transportClient = _transportClient ?? throw new InvalidOperationException("The dotnet test transport client is not connected."); switch (message) { case DiscoveredTestMessages discoveredTestMessages: await transportClient.RequestReplyAsync(discoveredTestMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case TestResultMessages testResultMessages: await transportClient.RequestReplyAsync(testResultMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case FileArtifactMessages fileArtifactMessages: await transportClient.RequestReplyAsync(fileArtifactMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case TestSessionEvent testSessionEvent: await transportClient.RequestReplyAsync(testSessionEvent, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case AzureDevOpsLogMessage azureDevOpsLogMessage: await transportClient.RequestReplyAsync(azureDevOpsLogMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case DisplayMessage displayMessage: await transportClient.RequestReplyAsync(displayMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; } } /// /// Opens the reverse "server control" pipe the SDK advertised during the handshake and parks a long-poll /// on it. The SDK completes that request with a /// whenever it wants to signal the test host (today only /// ). On cancel - or when the control pipe drops, which means /// the host went away - is invoked exactly once so the caller /// can stop the run cooperatively (preferring a graceful stop so trx/artifacts are still produced). /// /// /// The reaction to a server-initiated cancel. It receives the test application cancellation token. /// /// /// Both the connect and the long-poll run entirely on a background task, so test start is never blocked on /// this auxiliary channel. This is best-effort: if the control pipe cannot be established the test run /// continues unaffected (the feature simply stays off). Callers should only invoke this after a successful /// handshake and when is . It is invoked /// once per connection on the awaited run path (before the test run starts), so the control-channel fields /// below are written here and only read later during exit/dispose - the single-threaded lifecycle makes the /// plain fields safe without extra synchronization. /// public Task StartServerControlChannelAsync(Func onCancelSessionRequestedAsync) { if (_transportKind != DotnetTestTransportKind.NamedPipe || OperatingSystem.IsBrowser() || OperatingSystem.IsWasi() || !IsServerControlChannelSupported || RoslynString.IsNullOrEmpty(_serverControlPipeName) || _serverControlPipeClient is not null) { return Task.CompletedTask; } _serverControlListenerCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.CancellationToken); // exitProcessOnConnectionLoss: false - a dropped control pipe must not kill the test host; the listener // turns it into a cooperative cancel instead. var controlClient = new NamedPipeClient(_serverControlPipeName, _environment, exitProcessOnConnectionLoss: false); controlClient.RegisterAllSerializers(); _serverControlPipeClient = controlClient; // Connect AND listen on a background task: we deliberately do not await the connect on the run path so a // slow/absent control server can never delay (or fail) test execution start. #pragma warning disable CA1416 // The runtime guards above exclude browser before the named-pipe operation is scheduled. _serverControlListenerTask = Task.Run( () => ConnectAndListenForServerControlAsync(controlClient, onCancelSessionRequestedAsync, _serverControlListenerCts.Token)); #pragma warning restore CA1416 return Task.CompletedTask; } [UnsupportedOSPlatform("browser")] private async Task ConnectAndListenForServerControlAsync(NamedPipeClient controlClient, Func onCancelSessionRequestedAsync, CancellationToken cancellationToken) { try { // Bound the connect so a misbehaving SDK that advertises a pipe it never listens on cannot leave this // task parked forever holding resources. using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); connectCts.CancelAfter(TimeSpan.FromSeconds(30)); await controlClient.ConnectAsync(connectCts.Token).ConfigureAwait(false); } catch (Exception ex) { // Best-effort: failing to establish the control channel degrades to "no server-initiated cancel" // rather than affecting the test run. await TryLogAsync(LogLevel.Debug, $"Failed to connect to the server control pipe '{_serverControlPipeName}'; the server-initiated cancel feature will stay disabled for this run: {ex}").ConfigureAwait(false); return; } await TryLogAsync(LogLevel.Debug, $"Connected to the server control pipe '{_serverControlPipeName}'.").ConfigureAwait(false); await ListenForServerControlAsync(controlClient, onCancelSessionRequestedAsync, cancellationToken).ConfigureAwait(false); } [UnsupportedOSPlatform("browser")] private async Task ListenForServerControlAsync(NamedPipeClient controlClient, Func onCancelSessionRequestedAsync, CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { ServerControlMessage message = await controlClient.RequestReplyAsync( WaitForServerControlRequest.CachedInstance, cancellationToken).ConfigureAwait(false); if (message.Kind == ServerControlKinds.CancelSession) { await RequestCancelOnceAsync(onCancelSessionRequestedAsync).ConfigureAwait(false); return; } // Unknown control kind (forward-compat): ignore it and keep parking for the next signal. } } catch (OperationCanceledException) { // We are shutting down (dispose or app cancellation) - nothing to do. Expected shutdown, so this stays // at Trace to avoid noise. await TryLogAsync(LogLevel.Trace, $"Server control pipe '{_serverControlPipeName}' listener stopped: shutdown requested.").ConfigureAwait(false); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { // The control pipe dropped while the session was still live => the host went away. Treat it as a // cooperative cancel so we still try to wind down and report whatever completed. NOTE: this makes it a // protocol requirement that the SDK keep the control pipe open until the data session ends - an early // close for any reason is interpreted here as a cancel. await RequestCancelOnceAsync(onCancelSessionRequestedAsync).ConfigureAwait(false); await TryLogAsync(LogLevel.Debug, $"Server control pipe '{_serverControlPipeName}' listener failed while the session was live; ensuring cooperative cancellation: {ex}").ConfigureAwait(false); } catch (Exception ex) { // Cancellation raced with a pipe error (e.g. the stream was disposed during teardown); ignore. This is // an expected shutdown race, so it stays at Trace. await TryLogAsync(LogLevel.Trace, $"Server control pipe '{_serverControlPipeName}' listener observed a race between shutdown and a pipe error during teardown: {ex}").ConfigureAwait(false); } } private async Task RequestCancelOnceAsync(Func onCancelSessionRequestedAsync) { if (Interlocked.Exchange(ref _cancelRequested, 1) != 0) { return; } await onCancelSessionRequestedAsync(_cancellationTokenSource.CancellationToken).ConfigureAwait(false); } public async Task OnExitAsync() { #if NET if (_serverControlListenerCts is { } cts) { await cts.CancelAsync().ConfigureAwait(false); } #else #pragma warning disable VSTHRD103 // CancellationTokenSource.CancelAsync is not available on this target framework. _serverControlListenerCts?.Cancel(); #pragma warning restore VSTHRD103 #endif // Cancelling the token is enough to abort an in-flight named-pipe read on modern .NET, but on .NET // Framework cancellation of an already-parked overlapped read is not reliable - disposing the stream is // what forces it to unblock. We cancelled first (above) so the listener treats the resulting failure as // shutdown rather than "host gone => cancel". if (!OperatingSystem.IsBrowser()) { _serverControlPipeClient?.Dispose(); } if (_serverControlListenerTask is { } listenerTask) { // Bounded wait so a stuck listener can never hang the exit path on any target framework. Task completed = await Task.WhenAny(listenerTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false); if (completed != listenerTask) { await TryLogAsync(LogLevel.Debug, $"Server control pipe '{_serverControlPipeName}' listener did not finish within the 5s bounded wait during exit; continuing without waiting further.").ConfigureAwait(false); } } } public void Dispose() { _serverControlListenerCts?.Cancel(); // Disposing the pipe client forces a parked read to abort even where token cancellation alone would not. if (!OperatingSystem.IsBrowser()) { _serverControlPipeClient?.Dispose(); } if (_serverControlListenerTask is { } listenerTask) { try { // Bounded wait: the cancel + dispose above unblock the parked read quickly. Never hang exit. listenerTask.Wait(TimeSpan.FromSeconds(5)); } catch (Exception) { // Best-effort shutdown. Logging providers have already been disposed by this phase; diagnostics // belong in OnExitAsync, which runs while logging is still available. } } _serverControlListenerCts?.Dispose(); _transportClient?.Dispose(); } private async Task TryLogAsync(LogLevel logLevel, string message) { var loggingTask = Task.Run(async () => { try { await _logger.LogAsync(logLevel, message, null, LoggingExtensions.Formatter).ConfigureAwait(false); } catch (Exception) { // Control-channel diagnostics must never alter connection, cancellation, or shutdown behavior. } }); // Isolate synchronous provider work and bound providers that return a task which never completes. await Task.WhenAny(loggingTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); } }