// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using Microsoft.DotNet.Cli.Commands.Test.IPC;
using Microsoft.DotNet.Cli.Commands.Test.IPC.Models;
using Microsoft.DotNet.Cli.Commands.Test.IPC.Serializers;
using Microsoft.DotNet.Cli.Commands.Test.Terminal;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.ProjectTools;
namespace Microsoft.DotNet.Cli.Commands.Test;
internal sealed class TestApplication(
TestModule module,
BuildOptions buildOptions,
TestOptions testOptions,
TestResultsDirectoryResolver resultsDirectoryResolver,
TerminalTestReporter output,
Action<CommandLineOptionMessages> onHelpRequested,
ArtifactPostProcessingManager? artifactPostProcessingManager = null,
ArtifactPostProcessingInvocation? artifactPostProcessingInvocation = null,
TestRunPolicy? testRunPolicy = null) : IDisposable
{
private static readonly Version ProtocolVersion_1_1 = new(1, 1, 0);
private static readonly TimeSpan DefaultArtifactPostProcessingTimeout = TimeSpan.FromMinutes(15);
/// <summary>
/// Largest timeout <see cref="Task.WaitAsync(TimeSpan)"/> accepts, in seconds. Anything above
/// <c>Timer.MaxSupportedTimeout</c> (0xFFFFFFFE milliseconds) throws instead of waiting.
/// </summary>
private const int MaximumArtifactPostProcessingTimeoutSeconds = (int)(0xFFFFFFFE / 1000);
private static readonly TimeSpan ArtifactPostProcessingTimeout = GetArtifactPostProcessingTimeout();
private const int LiveOutputTailLineCount = 200;
private readonly Lock _requestLock = new();
private readonly Lock _controlRequestLock = new();
private readonly Lock _pipeConnectionsLock = new();
private readonly BuildOptions _buildOptions = buildOptions;
private readonly TestResultsDirectoryResolver _resultsDirectoryResolver = resultsDirectoryResolver;
private readonly Action<CommandLineOptionMessages> _onHelpRequested = onHelpRequested;
private readonly TestApplicationHandler _handler = new(
output,
module,
testOptions,
artifactPostProcessingManager,
artifactPostProcessingInvocation,
testRunPolicy);
private readonly ArtifactPostProcessingInvocation? _artifactPostProcessingInvocation = artifactPostProcessingInvocation;
private readonly TestRunPolicy? _testRunPolicy = testRunPolicy;
private readonly CancellationTokenSource _pipeCancellationTokenSource = new();
private readonly string _pipeName = NamedPipeServer.GetPipeName(Guid.NewGuid().ToString("N"));
private readonly string _controlPipeName = NamedPipeServer.GetPipeName(Guid.NewGuid().ToString("N"));
private readonly List<NamedPipeServer> _testAppPipeConnections = [];
private readonly Dictionary<NamedPipeServer, HandshakeMessage> _handshakes = new();
private readonly List<TaskCompletionSource<IResponse>> _pendingControlRequests = [];
private int _hasRun;
private int _protocolNegotiated;
private int _sessionCancellationRequested;
private Version? _negotiatedProtocolVersion;
private ProcessOutputCollector? _standardOutputCollector;
private ProcessOutputCollector? _standardErrorCollector;
public TestModule Module { get; } = module;
public TestOptions TestOptions { get; } = testOptions;
public bool HasFailureDuringDispose { get; private set; }
internal bool IsProtocol_1_1_OrHigher =>
_negotiatedProtocolVersion is { } negotiatedProtocolVersion &&
negotiatedProtocolVersion.CompareTo(ProtocolVersion_1_1) >= 0;
public async Task<int> RunAsync(CtrlCCancellationManager ctrlC)
{
if (Interlocked.Exchange(ref _hasRun, 1) != 0)
{
throw new InvalidOperationException(CliCommandStrings.RunAsyncCalledMoreThanOnce);
}
var processStartInfo = CreateProcessStartInfo();
var cancellationToken = _pipeCancellationTokenSource.Token;
var testAppPipeConnectionLoop = Task.Run(async () => await WaitConnectionAsync(cancellationToken));
var controlPipeConnectionLoop = Task.Run(async () => await WaitControlConnectionAsync(cancellationToken));
Process? process = null;
bool testApplicationStarted = false;
try
{
Logger.LogTrace($"Starting test process with command '{processStartInfo.FileName}' and arguments '{processStartInfo.Arguments}'.");
process = Process.Start(processStartInfo)!;
_testRunPolicy?.OnTestApplicationStarted();
testApplicationStarted = true;
// Register with the Ctrl+C manager so a force-exit (second Ctrl+C) kills this process
// tree even if the child's own cooperative cancellation hangs.
ctrlC.Register(process);
// Reading from process stdout/stderr is done on separate threads to avoid blocking IO on the threadpool.
// Note: even with 'process.StandardOutput.ReadToEndAsync()' or 'process.BeginOutputReadLine()', we ended up with
// many TP threads just doing synchronous IO, slowing down the progress of the test run.
// We want to read requests coming through the pipe and sending responses back to the test app as fast as possible.
// The collector is thread-safe for the timeout case.
// In the timeout case, we leave stdOutTask and stdErrTask running, just we stop observing them.
var stdOutBuilder = new ProcessOutputCollector(LiveOutputTailLineCount, _handler.WriteMessage);
var stdErrBuilder = new ProcessOutputCollector(LiveOutputTailLineCount, _handler.WriteMessage);
Volatile.Write(ref _standardOutputCollector, stdOutBuilder);
Volatile.Write(ref _standardErrorCollector, stdErrBuilder);
var stdOutTask = Task.Factory.StartNew(() =>
{
var stdOut = process.StandardOutput;
string? currentLine;
while ((currentLine = stdOut.ReadLine()) is not null)
{
stdOutBuilder.AddLine(currentLine, GetLiveOutputStreamingState());
}
}, TaskCreationOptions.LongRunning);
var stdErrTask = Task.Factory.StartNew(() =>
{
var stdErr = process.StandardError;
string? currentLine;
while ((currentLine = stdErr.ReadLine()) is not null)
{
stdErrBuilder.AddLine(currentLine, GetLiveOutputStreamingState());
}
}, TaskCreationOptions.LongRunning);
// WaitForExitAsync only waits for process exit (and doesn't wait for output) for our usage here.
// If we use BeginOutputReadLine/BeginErrorReadLine, it will also wait for output which can deadlock.
bool artifactPostProcessingTimedOut = false;
if (_artifactPostProcessingInvocation is null)
{
Task processExitTask = process.WaitForExitAsync();
if (_testRunPolicy is { } testRunPolicy)
{
Task firstCompletedTask = await Task.WhenAny(processExitTask, testRunPolicy.Cancellation);
if (firstCompletedTask != processExitTask)
{
RequestSessionCancellation();
try
{
await processExitTask.WaitAsync(testRunPolicy.CancellationGracePeriod);
}
catch (TimeoutException)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException ex)
{
// The process exited between the HasExited check and Kill.
Logger.LogTrace($"Test process exited before it could be killed after cancellation:\n{ex}");
}
await processExitTask;
}
}
else
{
await processExitTask;
}
}
else
{
await processExitTask;
}
}
else
{
try
{
await process.WaitForExitAsync().WaitAsync(ArtifactPostProcessingTimeout);
}
catch (TimeoutException)
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
await process.WaitForExitAsync();
}
artifactPostProcessingTimedOut = true;
}
}
_testRunPolicy?.OnTestApplicationExited();
testApplicationStarted = false;
// At this point, process already exited. Allow for 5 seconds to consume stdout/stderr.
// We might not be able to consume all the output if the test app has exited but left a child process alive.
try
{
await Task.WhenAll(stdOutTask, stdErrTask).WaitAsync(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
}
if (artifactPostProcessingTimedOut)
{
throw new TimeoutException();
}
var exitCode = process.ExitCode;
string outputToReport = stdOutBuilder.GetOutputToReport();
string errorToReport = stdErrBuilder.GetOutputToReport();
// Output that was streamed live is not reported again - it is already on the terminal, and
// replaying it printed everything twice (https://github.com/dotnet/sdk/issues/55549). The trace
// file has no console sink and is collected on its own, though, so it would silently lose that
// output. Record it here, where the full capture is still available, for the streams the
// handler is not going to log. Guarded so nothing is materialized unless tracing is enabled,
// which it is not by default.
if (Logger.TraceEnabled)
{
LogStreamedProcessOutput("Output Data", outputToReport, stdOutBuilder);
LogStreamedProcessOutput("Error Data", errorToReport, stdErrBuilder);
}
_handler.OnTestProcessExited(exitCode, outputToReport, errorToReport);
// This condition is to prevent considering the test app as successful when we didn't receive test session end.
// We don't produce the exception if the exit code is already non-zero to avoid surfacing this exception when there is already a known failure.
// For example, if hangdump timeout is reached, the process will be killed and we will have mismatching count.
// Or if there is a crash (e.g, Environment.FailFast), etc.
// So this is only a safe guard to avoid passing the test run if Environment.Exit(0) is called in one of the tests for example.
if (exitCode == 0 && _handler.HasMismatchingTestSessionEventCount())
{
throw new InvalidOperationException(CliCommandStrings.MissingTestSessionEnd);
}
return exitCode;
}
finally
{
if (testApplicationStarted)
{
_testRunPolicy?.OnTestApplicationExited();
}
if (process is not null)
{
ctrlC.Unregister(process);
process.Dispose();
}
Volatile.Write(ref _standardOutputCollector, null);
Volatile.Write(ref _standardErrorCollector, null);
_pipeCancellationTokenSource.Cancel();
await Task.WhenAll(testAppPipeConnectionLoop, controlPipeConnectionLoop);
}
}
internal ProcessStartInfo CreateProcessStartInfo()
{
var processStartInfo = new ProcessStartInfo
{
// We should get correct RunProperties right away.
// For the case of dotnet test --test-modules path/to/dll, the TestModulesFilterHandler is responsible
// for providing the dotnet muxer as RunCommand, and `exec "path/to/dll"` as RunArguments.
FileName = Module.RunProperties.Command,
Arguments = GetArguments(),
RedirectStandardOutput = true,
RedirectStandardError = true,
// False is already the default on .NET Core, but prefer to be explicit.
UseShellExecute = false,
};
if (!string.IsNullOrEmpty(Module.RunProperties.WorkingDirectory))
{
processStartInfo.WorkingDirectory = Module.RunProperties.WorkingDirectory;
}
if (Module.LaunchSettings is ProjectLaunchProfile)
{
foreach (var entry in Module.LaunchSettings.EnvironmentVariables)
{
processStartInfo.Environment[entry.Key] = entry.Value;
}
if (_artifactPostProcessingInvocation is null &&
!_buildOptions.NoLaunchProfileArguments &&
!string.IsNullOrEmpty(Module.LaunchSettings.CommandLineArgs))
{
processStartInfo.Arguments = $"{processStartInfo.Arguments} {Module.LaunchSettings.CommandLineArgs}";
}
}
// Command-line variables (including changes made by opted-in MSBuild targets)
// override variables specified in the launch profile.
foreach (var (name, value) in Module.EnvironmentVariables)
{
processStartInfo.Environment[name] = value;
}
if (Module.DotnetRootArchVariableName is not null)
{
processStartInfo.Environment[Module.DotnetRootArchVariableName] = Path.GetDirectoryName(new Muxer().MuxerPath);
}
if (TestOptions.CollectTestMap)
{
processStartInfo.Environment[TestOptions.AffectedTestsModeEnvironmentVariable] = TestOptions.CollectTestMapMode;
}
else if (TestOptions.AffectedTests)
{
processStartInfo.Environment[TestOptions.AffectedTestsModeEnvironmentVariable] = TestOptions.RunAffectedTestsMode;
}
else
{
processStartInfo.Environment.Remove(TestOptions.AffectedTestsModeEnvironmentVariable);
}
processStartInfo.Environment["DOTNET_CLI_TEST_COMMAND_WORKING_DIRECTORY"] = Directory.GetCurrentDirectory();
return processStartInfo;
}
private string GetArguments()
{
// Keep RunArguments first.
// In the case of UseAppHost=false, RunArguments is set to `exec $(TargetPath)`:
// https://github.com/dotnet/sdk/blob/333388c31d811701e3b6be74b5434359151424dc/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Sdk.targets#L1411
// So, we keep that first always.
// RunArguments is intentionally not escaped. It can contain multiple arguments and spaces there shouldn't cause the whole
// value to be wrapped in double quotes. This matches dotnet run behavior.
// In short, it's expected to already be escaped properly.
StringBuilder builder = new(
_artifactPostProcessingInvocation is null
? Module.RunProperties.Arguments
: GetArtifactPostProcessingLaunchArguments(Module));
if (_artifactPostProcessingInvocation is not null)
{
return BuildArtifactPostProcessingArguments(
builder,
_buildOptions.PathOptions,
_artifactPostProcessingInvocation.ManifestPath,
_pipeName);
}
if (TestOptions.IsHelp)
{
builder.Append($" {CliConstants.HelpOptionKey}");
}
if (TestOptions.IsDiscovery)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ListTestsOptionName}");
}
if (TestOptions.CollectTestMap && !TestOptions.CollectTestMapForwarded)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.CollectTestMapOptionName}");
}
if (TestOptions.AffectedTests && !TestOptions.AffectedTestsForwarded)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.AffectedTestsOptionName}");
}
if (_resultsDirectoryResolver.Resolve(Module) is { } resultsDirectoryPath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ResultsDirectoryOptionName} {ArgumentEscaper.EscapeSingleArg(resultsDirectoryPath)}");
}
if (_buildOptions.PathOptions.ConfigFilePath is { } configFilePath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ConfigFileOptionName} {ArgumentEscaper.EscapeSingleArg(configFilePath)}");
}
if (_buildOptions.PathOptions.DiagnosticOutputDirectoryPath is { } diagnosticOutputDirectoryPath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.DiagnosticOutputDirectoryOptionName} {ArgumentEscaper.EscapeSingleArg(diagnosticOutputDirectoryPath)}");
}
foreach (var arg in _buildOptions.TestApplicationArguments)
{
builder.Append($" {ArgumentEscaper.EscapeSingleArg(arg)}");
}
builder.Append($" {CliConstants.ServerOptionKey} {CliConstants.ServerOptionValue} {CliConstants.DotNetTestPipeOptionKey} {ArgumentEscaper.EscapeSingleArg(_pipeName)}");
return builder.ToString();
}
internal static string GetArtifactPostProcessingLaunchArguments(TestModule module)
=> string.Equals(
Path.GetFileNameWithoutExtension(module.RunProperties.Command),
"dotnet",
StringComparison.OrdinalIgnoreCase)
? $"exec {ArgumentEscaper.EscapeSingleArg(module.TargetPath)}"
: string.Empty;
/// <summary>
/// Appends the arguments that put a relaunched test application into merge-host mode. None of the
/// options describing the test run itself are forwarded — the host discovers nothing and runs no
/// tests — but the options deciding which extensions load, and where they write diagnostics, must
/// match the run that produced the artifacts.
/// </summary>
internal static string BuildArtifactPostProcessingArguments(
StringBuilder builder,
PathOptions pathOptions,
string manifestPath,
string pipeName)
{
builder.Append($" {CliConstants.ArtifactPostProcessingToolName}");
builder.Append($" {CliConstants.ArtifactPostProcessingManifestOptionKey} {ArgumentEscaper.EscapeSingleArg(manifestPath)}");
// A merge host resolves and enables its extensions exactly like a test host does, so a
// configuration file that governs which extensions load has to reach it too. Without this
// the merge runs with a different extension set than the run that produced the artifacts,
// and a post-processor disabled by configuration silently comes back for the merge.
if (pathOptions.ConfigFilePath is { } configFilePath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.ConfigFileOptionName} {ArgumentEscaper.EscapeSingleArg(configFilePath)}");
}
if (pathOptions.DiagnosticOutputDirectoryPath is { } diagnosticOutputDirectoryPath)
{
builder.Append($" {TestCommandDefinition.MicrosoftTestingPlatform.DiagnosticOutputDirectoryOptionName} {ArgumentEscaper.EscapeSingleArg(diagnosticOutputDirectoryPath)}");
}
// The results directory is deliberately not forwarded: the merged output location travels in
// the manifest instead, so the SDK keeps control of it even when it has to be derived.
builder.Append($" {CliConstants.ServerOptionKey} {CliConstants.ServerOptionValue} {CliConstants.DotNetTestPipeOptionKey} {ArgumentEscaper.EscapeSingleArg(pipeName)}");
return builder.ToString();
}
/// <summary>
/// Resolves how long a relaunched merge host may run before it is killed. The bound exists so a
/// hung merge host cannot hold an already-finished test run open indefinitely, but no single
/// value fits every repository — merging the coverage of a large solution legitimately takes far
/// longer than merging two TRX reports. The override also accepts '0' to remove the bound, which
/// is what makes it possible to attach a debugger to a merge host.
/// </summary>
private static TimeSpan GetArtifactPostProcessingTimeout()
=> ParseArtifactPostProcessingTimeout(
Environment.GetEnvironmentVariable(CliConstants.TestArtifactPostProcessingTimeoutEnvVar));
internal static TimeSpan ParseArtifactPostProcessingTimeout(string? configuredTimeout)
{
// Parsed as long rather than int so that a value chosen to mean 'a very long time' lands in
// the 'effectively never' branch below instead of overflowing the parse and silently falling
// back to the default. Anything above long.MaxValue seconds is not a duration anyone means,
// so it keeps the default along with the other unusable values.
if (!long.TryParse(configuredTimeout, NumberStyles.Integer, CultureInfo.InvariantCulture, out long seconds) || seconds < 0)
{
return DefaultArtifactPostProcessingTimeout;
}
// Task.WaitAsync rejects a timeout above Timer.MaxSupportedTimeout (~49.7 days) with an
// ArgumentOutOfRangeException, which would escape the TimeoutException-only catch around the
// wait and fail every merge. A caller asking for a timeout that long means "effectively
// never", so give them that instead of a broken run.
return seconds == 0 || seconds > MaximumArtifactPostProcessingTimeoutSeconds
? Timeout.InfiniteTimeSpan
: TimeSpan.FromSeconds(seconds);
}
private async Task WaitConnectionAsync(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
var pipeConnection = new NamedPipeServer(_pipeName, OnRequest, NamedPipeServerStream.MaxAllowedServerInstances, token, skipUnknownMessages: true);
pipeConnection.RegisterAllSerializers();
await pipeConnection.WaitConnectionAsync(token);
AddPipeConnection(pipeConnection);
}
}
catch (OperationCanceledException ex)
{
// We are exiting
Logger.LogTrace($"WaitConnectionAsync() throws OperationCanceledException with {(ex.CancellationToken == token ? "internal token" : "external token")}");
}
catch (Exception ex)
{
var exAsString = ex.ToString();
Logger.LogTrace(exAsString);
Environment.FailFast(exAsString);
}
}
private async Task WaitControlConnectionAsync(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
NamedPipeServer? pipeConnection = new(
_controlPipeName,
OnControlRequest,
NamedPipeServerStream.MaxAllowedServerInstances,
token,
skipUnknownMessages: false);
try
{
pipeConnection.RegisterAllSerializers();
await pipeConnection.WaitConnectionAsync(token);
AddPipeConnection(pipeConnection);
pipeConnection = null;
}
finally
{
pipeConnection?.Dispose();
}
}
}
catch (OperationCanceledException ex) when (ex.CancellationToken == token)
{
Logger.LogTrace("WaitControlConnectionAsync() was cancelled.");
}
catch (Exception ex)
{
// The reverse channel is an optional capability. A failure here must not replace
// the test application's result; policy cancellation can still use the kill fallback.
Logger.LogTrace($"The server-control pipe stopped accepting connections:\n{ex}");
}
}
private Task<IResponse> OnControlRequest(NamedPipeServer _, IRequest request)
{
if (request is not WaitForServerControlRequest)
{
throw new NotSupportedException(string.Format(CliCommandStrings.CmdUnsupportedMessageRequestTypeException, request.GetType()));
}
lock (_controlRequestLock)
{
if (Volatile.Read(ref _sessionCancellationRequested) != 0)
{
return Task.FromResult<IResponse>(new ServerControlMessage(ServerControlKinds.CancelSession));
}
var completion = new TaskCompletionSource<IResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingControlRequests.Add(completion);
_pipeCancellationTokenSource.Token.Register(
() => completion.TrySetCanceled(_pipeCancellationTokenSource.Token));
return completion.Task;
}
}
private void AddPipeConnection(NamedPipeServer pipeConnection)
{
lock (_pipeConnectionsLock)
{
_testAppPipeConnections.Add(pipeConnection);
}
}
private void RequestSessionCancellation()
{
if (Interlocked.Exchange(ref _sessionCancellationRequested, 1) != 0)
{
return;
}
lock (_controlRequestLock)
{
var message = new ServerControlMessage(ServerControlKinds.CancelSession);
foreach (TaskCompletionSource<IResponse> request in _pendingControlRequests)
{
request.TrySetResult(message);
}
}
}
private Task<IResponse> OnRequest(NamedPipeServer server, IRequest request)
{
// We need to lock as we might be called concurrently when test app child processes all communicate with us.
// For example, in a case of a sharding extension, we could get test result messages concurrently.
// To be the most safe, we lock the whole OnRequest.
lock (_requestLock)
{
try
{
switch (request)
{
case HandshakeMessage handshakeMessage:
if (!_handshakes.TryAdd(server, handshakeMessage))
{
throw new InvalidOperationException(CliCommandStrings.DotnetTestDuplicateHandshakeOnConnection);
}
string negotiatedVersion = GetSupportedProtocolVersion(handshakeMessage);
// If the handler rejects the handshake (unsupported version, missing required
// properties, mismatching info, ...) respond with an empty negotiated version so
// Microsoft.Testing.Platform stops sending further messages on this connection.
bool handshakeAccepted = OnHandshakeMessage(handshakeMessage, negotiatedVersion.Length > 0);
SetNegotiatedProtocolVersion(handshakeAccepted ? negotiatedVersion : string.Empty);
return Task.FromResult((IResponse)CreateHandshakeMessage(handshakeAccepted ? negotiatedVersion : string.Empty));
case CommandLineOptionMessages commandLineOptionMessages:
OnCommandLineOptionMessages(commandLineOptionMessages);
break;
case DiscoveredTestMessages discoveredTestMessages:
OnDiscoveredTestMessages(discoveredTestMessages);
break;
case TestResultMessages testResultMessages:
OnTestResultMessages(testResultMessages);
break;
case FileArtifactMessages fileArtifactMessages:
OnFileArtifactMessages(fileArtifactMessages);
break;
case TestInProgressMessages testInProgressMessages:
OnTestInProgressMessages(testInProgressMessages);
break;
case TestSessionEvent sessionEvent:
OnSessionEvent(sessionEvent);
break;
case AzureDevOpsLogMessage azureDevOpsLogMessage:
OnAzureDevOpsLogMessage(azureDevOpsLogMessage);
break;
case DisplayMessage displayMessage:
OnDisplayMessage(displayMessage);
break;
// If we don't recognize the message, log and skip it
case UnknownMessage unknownMessage:
Logger.LogTrace($"Request '{request.GetType()}' with Serializer ID = {unknownMessage.SerializerId} is unsupported.");
return Task.FromResult((IResponse)VoidResponse.CachedInstance);
default:
// If it doesn't match any of the above, throw an exception
throw new NotSupportedException(string.Format(CliCommandStrings.CmdUnsupportedMessageRequestTypeException, request.GetType()));
}
}
catch (Exception ex)
{
// BE CAREFUL:
// When handling some of the messages, we may throw an exception in unexpected state.
// (e.g, OnSessionEvent may throw if we receive TestSessionEnd without TestSessionStart).
// (or if we receive help-related messages when not in help mode)
// In that case, we FailFast.
// The lack of FailFast *might* have unintended consequences, such as breaking the internal loop of pipe server.
// In that case, maybe MTP app will continue waiting for response, but we don't send the response and are waiting for
// MTP app process exit (which doesn't happen).
// So, we explicitly FailFast here.
string exAsString = ex.ToString();
Logger.LogTrace(exAsString);
Environment.FailFast(exAsString);
}
return Task.FromResult((IResponse)VoidResponse.CachedInstance);
}
}
internal static string GetSupportedProtocolVersion(HandshakeMessage handshakeMessage)
{
if (!handshakeMessage.Properties.TryGetValue(HandshakeMessagePropertyNames.SupportedProtocolVersions, out string? protocolVersions) ||
string.IsNullOrWhiteSpace(protocolVersions))
{
// The handshake didn't advertise any supported protocol versions. Return empty so the
// handler can surface a dedicated "missing protocol versions" failure to the user via
// 'HandshakeFailure' (rather than throwing here, which would route to 'FailFast').
return string.Empty;
}
List<(Version Version, string Text)> sdkSupportedVersions = [];
foreach (string supportedVersion in ProtocolConstants.SupportedVersions.Split(';'))
{
string trimmedSupportedVersion = supportedVersion.Trim();
if (Version.TryParse(trimmedSupportedVersion, out Version? parsedSupportedVersion))
{
sdkSupportedVersions.Add((parsedSupportedVersion, trimmedSupportedVersion));
}
}
Version? highestCommonVersion = null;
string highestCommonVersionText = string.Empty;
foreach (string advertisedVersion in protocolVersions.Split(';'))
{
if (!Version.TryParse(advertisedVersion.Trim(), out Version? parsedAdvertisedVersion))
{
continue;
}
foreach ((Version sdkSupportedVersion, string sdkSupportedVersionText) in sdkSupportedVersions)
{
if (parsedAdvertisedVersion.Equals(sdkSupportedVersion) &&
(highestCommonVersion is null || sdkSupportedVersion.CompareTo(highestCommonVersion) > 0))
{
highestCommonVersion = sdkSupportedVersion;
highestCommonVersionText = sdkSupportedVersionText;
}
}
}
return highestCommonVersionText;
}
private HandshakeMessage CreateHandshakeMessage(string version)
{
var properties = new Dictionary<byte, string>(capacity: 6)
{
{ HandshakeMessagePropertyNames.PID, Environment.ProcessId.ToString(CultureInfo.InvariantCulture) },
{ HandshakeMessagePropertyNames.Architecture, RuntimeInformation.ProcessArchitecture.ToString() },
{ HandshakeMessagePropertyNames.Framework, RuntimeInformation.FrameworkDescription },
{ HandshakeMessagePropertyNames.OS, RuntimeInformation.OSDescription },
{ HandshakeMessagePropertyNames.SupportedProtocolVersions, version }
};
if (version.Length > 0)
{
properties.Add(HandshakeMessagePropertyNames.ServerControlPipeName, _controlPipeName);
}
return new HandshakeMessage(properties);
}
private void SetNegotiatedProtocolVersion(string negotiatedVersion)
{
if (Version.TryParse(negotiatedVersion, out Version? parsedNegotiatedVersion) &&
(_negotiatedProtocolVersion is null || parsedNegotiatedVersion.CompareTo(_negotiatedProtocolVersion) > 0))
{
_negotiatedProtocolVersion = parsedNegotiatedVersion;
}
Volatile.Write(ref _protocolNegotiated, 1);
FlushBufferedOutputIfLiveStreamingEnabled();
}
private bool? GetLiveOutputStreamingState() =>
Volatile.Read(ref _protocolNegotiated) == 0 ? null : IsProtocol_1_1_OrHigher;
/// <summary>
/// Records process output that is not being reported to the user because it already reached the
/// terminal as live output, so that an enabled trace file still contains it.
/// </summary>
private static void LogStreamedProcessOutput(string label, string reportedOutput, ProcessOutputCollector collector)
{
if (reportedOutput.Length > 0)
{
// The output is being reported, so the handler traces it as usual.
return;
}
string capturedOutput = collector.GetCapturedOutput();
if (capturedOutput.Length > 0)
{
Logger.LogTrace($"{label} (already streamed live): {capturedOutput}");
}
}
private void FlushBufferedOutputIfLiveStreamingEnabled()
{
bool? liveOutputStreamingState = GetLiveOutputStreamingState();
Volatile.Read(ref _standardOutputCollector)?.FlushBufferedOutputIfLiveStreamingEnabled(liveOutputStreamingState);
Volatile.Read(ref _standardErrorCollector)?.FlushBufferedOutputIfLiveStreamingEnabled(liveOutputStreamingState);
}
public bool OnHandshakeMessage(HandshakeMessage handshakeMessage, bool gotSupportedVersion)
=> _handler.OnHandshakeReceived(handshakeMessage, gotSupportedVersion);
private void OnCommandLineOptionMessages(CommandLineOptionMessages commandLineOptionMessages)
{
if (!TestOptions.IsHelp)
{
throw new InvalidOperationException(CliCommandStrings.UnexpectedHelpMessage);
}
_onHelpRequested(commandLineOptionMessages);
}
private void OnDiscoveredTestMessages(DiscoveredTestMessages discoveredTestMessages)
=> _handler.OnDiscoveredTestsReceived(discoveredTestMessages);
private void OnTestResultMessages(TestResultMessages testResultMessage)
=> _handler.OnTestResultsReceived(testResultMessage);
private void OnFileArtifactMessages(FileArtifactMessages fileArtifactMessages)
=> _handler.OnFileArtifactsReceived(fileArtifactMessages);
private void OnTestInProgressMessages(TestInProgressMessages testInProgressMessages)
=> _handler.OnTestInProgressReceived(testInProgressMessages);
private void OnSessionEvent(TestSessionEvent sessionEvent)
=> _handler.OnSessionEventReceived(sessionEvent);
private void OnAzureDevOpsLogMessage(AzureDevOpsLogMessage azureDevOpsLogMessage)
=> _handler.OnAzureDevOpsLogReceived(azureDevOpsLogMessage);
private void OnDisplayMessage(DisplayMessage displayMessage)
=> _handler.OnDisplayMessageReceived(displayMessage);
internal sealed class ProcessOutputCollector(int liveOutputTailLineCount, Action<string> writeOutput)
{
// Serializes "decide what to write, then write it" so live output reaches the terminal in the
// order the test process produced it. Without it the protocol-negotiation flush and a reader
// thread can interleave and put a later line ahead of the buffered lines that precede it, and
// live output is the only copy the user gets. This is deliberately separate from _lock so a
// capture read never waits on terminal IO - RunAsync reads the capture on a timeout path that
// exists precisely because a reader may be stuck. Always taken before _lock, never after.
private readonly object _writeLock = new();
private readonly object _lock = new();
private readonly Queue<string> _lines = [];
private bool _liveStreamingEnabled;
public void AddLine(string line, bool? liveOutputStreamingState)
{
lock (_writeLock)
{
string outputToWrite;
lock (_lock)
{
_lines.Enqueue(line);
if (_liveStreamingEnabled)
{
// The caller sampled the streaming state before taking this lock, so it can be
// stale: negotiation may have enabled streaming in between. Once streaming is on
// it never turns back off, and the capture is reported as already shown, so this
// line has to be written even when the stale sample says otherwise - skipping it
// would drop it from the terminal entirely.
outputToWrite = line + Environment.NewLine;
}
else
{
if (liveOutputStreamingState != true)
{
return;
}
_liveStreamingEnabled = true;
outputToWrite = JoinLinesWithTrailingNewLine(_lines);
}
TrimToBoundedTail();
}
writeOutput(outputToWrite);
}
}
public void FlushBufferedOutputIfLiveStreamingEnabled(bool? liveOutputStreamingState)
{
lock (_writeLock)
{
string outputToWrite;
lock (_lock)
{
if (liveOutputStreamingState != true || _liveStreamingEnabled)
{
return;
}
_liveStreamingEnabled = true;
outputToWrite = JoinLinesWithTrailingNewLine(_lines);
TrimToBoundedTail();
}
if (outputToWrite.Length > 0)
{
writeOutput(outputToWrite);
}
}
}
/// <summary>
/// Returns the captured tail of the stream for the caller to report, or an empty string when
/// that content already reached the terminal as live output.
/// </summary>
/// <remarks>
/// Live streaming engages only once for a stream, and everything buffered up to that point is
/// flushed in the same step, so from then on the whole capture - including lines later evicted
/// by <see cref="TrimToBoundedTail"/> - has been shown. Reporting it again would print it twice
/// (https://github.com/dotnet/sdk/issues/55549). Until streaming engages the capture is the only
/// copy there is, so it is returned in full: older Microsoft.Testing.Platform versions that
/// don't negotiate protocol 1.1.0, and processes that fail before the handshake, depend on it.
/// </remarks>
public string GetOutputToReport()
{
lock (_lock)
{
return _liveStreamingEnabled ? string.Empty : string.Join(Environment.NewLine, _lines);
}
}
/// <summary>
/// Returns the captured tail of the stream whether or not it was already streamed live. Only for
/// diagnostics that record the output without showing it to the user - anything rendered on the
/// terminal must go through <see cref="GetOutputToReport"/> instead, or it will be printed twice.
/// </summary>
public string GetCapturedOutput()
{
lock (_lock)
{
return string.Join(Environment.NewLine, _lines);
}
}
private void TrimToBoundedTail()
{
while (_lines.Count > liveOutputTailLineCount)
{
_lines.Dequeue();
}
}
private static string JoinLinesWithTrailingNewLine(IEnumerable<string> lines)
{
StringBuilder builder = new();
foreach (string line in lines)
{
builder.AppendLine(line);
}
return builder.ToString();
}
}
public override string ToString()
{
StringBuilder builder = new();
if (!string.IsNullOrEmpty(Module.RunProperties.Command))
{
builder.Append($"{ProjectProperties.RunCommand}: {Module.RunProperties.Command}");
}
if (!string.IsNullOrEmpty(Module.RunProperties.Arguments))
{
builder.Append($"{ProjectProperties.RunArguments}: {Module.RunProperties.Arguments}");
}
if (!string.IsNullOrEmpty(Module.RunProperties.WorkingDirectory))
{
builder.Append($"{ProjectProperties.RunWorkingDirectory}: {Module.RunProperties.WorkingDirectory}");
}
if (!string.IsNullOrEmpty(Module.ProjectFullPath))
{
builder.Append($"{ProjectProperties.ProjectFullPath}: {Module.ProjectFullPath}");
}
if (!string.IsNullOrEmpty(Module.TargetFramework))
{
builder.Append($"{ProjectProperties.TargetFramework} : {Module.TargetFramework}");
}
return builder.ToString();
}
public void Dispose()
{
foreach (var namedPipeServer in _testAppPipeConnections)
{
try
{
namedPipeServer.Dispose();
}
catch (Exception ex)
{
StringBuilder messageBuilder;
if (_handshakes.TryGetValue(namedPipeServer, out var handshake))
{
messageBuilder = new StringBuilder(CliCommandStrings.DotnetTestPipeFailureHasHandshake);
messageBuilder.AppendLine();
foreach (var kvp in handshake.Properties)
{
messageBuilder.AppendLine($"{kvp.Key}: {kvp.Value}");
}
}
else
{
messageBuilder = new StringBuilder(CliCommandStrings.DotnetTestPipeFailureWithoutHandshake);
messageBuilder.AppendLine();
}
messageBuilder.AppendLine($"RunCommand: {Module.RunProperties.Command}");
messageBuilder.AppendLine($"RunArguments: {Module.RunProperties.Arguments}");
messageBuilder.AppendLine(ex.ToString());
HasFailureDuringDispose = true;
Reporter.Error.WriteLine(messageBuilder.ToString());
}
}
_pipeCancellationTokenSource.Dispose();
}
}