File: VsTestConsoleProcessManager.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.VsTestConsole.TranslationLayer\Microsoft.TestPlatform.VsTestConsole.TranslationLayer.csproj (Microsoft.TestPlatform.VsTestConsole.TranslationLayer)
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;

using Microsoft.TestPlatform.VsTestConsole.TranslationLayer.Interfaces;
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Helpers;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers;
using Microsoft.VisualStudio.TestPlatform.Utilities.Helpers.Interfaces;
using Microsoft.VisualStudio.TestPlatform.VsTestConsole.TranslationLayer.Resources;

using Resources = Microsoft.VisualStudio.TestPlatform.VsTestConsole.TranslationLayer.Resources.Resources;

namespace Microsoft.TestPlatform.VsTestConsole.TranslationLayer;

/// <summary>
/// Vstest.console process manager
/// </summary>
internal sealed class VsTestConsoleProcessManager : IProcessManager, IDisposable
{
    /// <summary>
    /// Port number for communicating with Vstest CLI
    /// </summary>
    private const string PortArgument = "/port:{0}";

    /// <summary>
    /// Process Id of the Current Process which is launching Vstest CLI
    /// Helps Vstest CLI in auto-exit if current process dies without notifying it
    /// </summary>
    private const string ParentProcessidArgument = "/parentprocessid:{0}";

    /// <summary>
    /// Diagnostics argument for Vstest CLI
    /// Enables Diagnostic logging for Vstest CLI and TestHost - Optional
    /// </summary>
    private const string DiagArgument = "/diag:{0};tracelevel={1}";

    /// <summary>
    /// EndSession timeout
    /// </summary>
    private const int Endsessiontimeout = 1000;

    private readonly string _vstestConsolePath;
    private readonly object _syncObject = new();
    private readonly bool _isNetCoreRunner;
    private readonly string? _dotnetExePath;
    private readonly ManualResetEvent _processExitedEvent = new(false);
    private readonly StringBuilder _vstestConsoleError = new();
    private Process? _process;

    private bool _vstestConsoleStarted;
    private bool _vstestConsoleExited;
    private bool _isDisposed;

    internal IFileHelper FileHelper { get; set; }

    /// <inheritdoc/>
    public string? ProcessName { get; set; }

    /// <inheritdoc/>
    public int? ProcessId { get; set; }

    /// <inheritdoc/>
    public string? ErrorOutput => _vstestConsoleError.ToString();

    /// <inheritdoc/>
    public int? ExitCode { get; private set; } = -1;

    /// <inheritdoc/>
    public event EventHandler? ProcessExited;

    /// <summary>
    /// Creates an instance of VsTestConsoleProcessManager class.
    /// </summary>
    /// <param name="vstestConsolePath">The full path to vstest.console</param>
    public VsTestConsoleProcessManager(string vstestConsolePath)
    {
        FileHelper = new FileHelper();
        if (!FileHelper.Exists(vstestConsolePath))
        {
            EqtTrace.Error("Invalid File Path: {0}", vstestConsolePath);
            throw new Exception(string.Format(CultureInfo.CurrentCulture, Resources.InvalidFilePath, vstestConsolePath));
        }
        _vstestConsolePath = vstestConsolePath;
        _isNetCoreRunner = vstestConsolePath.EndsWith(".dll");
    }

    public VsTestConsoleProcessManager(string vstestConsolePath, string dotnetExePath) : this(vstestConsolePath)
    {
        _dotnetExePath = dotnetExePath;
    }

    /// <summary>
    /// Checks if the process has been initialized.
    /// </summary>
    /// <returns>True if process is successfully initialized</returns>
    public bool IsProcessInitialized()
    {
        lock (_syncObject)
        {
            return _vstestConsoleStarted && !_vstestConsoleExited && _process != null;
        }
    }

    /// <summary>
    /// Call vstest.console with the parameters previously specified
    /// </summary>
    public void StartProcess(ConsoleParameters consoleParameters)
    {
        var consoleRunnerPath = GetConsoleRunner();

        // The console runner path we retrieve might have been escaped so we need to remove the
        // extra double quotes before testing whether the file exists.
        if (!File.Exists(consoleRunnerPath.Trim('"')))
        {
            throw new FileNotFoundException(string.Format(CultureInfo.CurrentCulture, InternalResources.CannotFindConsoleRunner, consoleRunnerPath), consoleRunnerPath);
        }

        ProcessName = null;
        ProcessId = null;
        ExitCode = null;
        _vstestConsoleError.Clear();

        var arguments = string.Join(" ", BuildArguments(consoleParameters));
        var info = new ProcessStartInfo(consoleRunnerPath, arguments)
        {
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };

        EqtTrace.Verbose("VsTestCommandLineWrapper.StartProcess: Process Start Info {0} {1}", info.FileName, info.Arguments);

        if (!consoleParameters.InheritEnvironmentVariables)
        {
            EqtTrace.Verbose("VsTestCommandLineWrapper.StartProcess: Clearing all environment variables.");

            info.EnvironmentVariables.Clear();
        }

        if (consoleParameters.EnvironmentVariables != null)
        {
            foreach (var envVariable in consoleParameters.EnvironmentVariables)
            {
                if (envVariable.Key != null)
                {
                    // Not printing the value on purpose, env variables can contain secrets and we don't need to know the values
                    // most of the time.
                    // EqtTrace.Verbose("VsTestCommandLineWrapper.StartProcess: Setting environment variable: {0}", envVariable.Key);
                    info.EnvironmentVariables[envVariable.Key] = envVariable.Value?.ToString();
                }
            }
        }

        try
        {
            _process = Process.Start(info);
            ProcessId = _process!.Id;
            ProcessName = _process.ProcessName;
            EqtTrace.Info($"VsTestConsoleProcessManager.StartProcess: Started process id:{_process.Id}"); // Not normally needed, but if you run multiple instances of wrapper it helps to also add {Environment.StackTrace}
        }
        catch (Win32Exception ex)
        {
            throw new Exception(string.Format(CultureInfo.CurrentCulture, InternalResources.ProcessStartWin32Failure, consoleRunnerPath, arguments), ex);
        }

        lock (_syncObject)
        {
            _vstestConsoleExited = false;
            _vstestConsoleStarted = true;
        }

        _process!.EnableRaisingEvents = true;
        _process.Exited += Process_Exited;

        _process.OutputDataReceived += Process_OutputDataReceived;
        _process.ErrorDataReceived += Process_ErrorDataReceived;
        _process.BeginOutputReadLine();
        _process.BeginErrorReadLine();
        _processExitedEvent.Reset();
    }

    /// <summary>
    /// Shutdown the vstest.console process
    /// </summary>
    public void ShutdownProcess()
    {
        // Ideally process should die by itself
        EqtTrace.Info($"VsTestConsoleProcessManager.ShutDownProcess : Will terminate vstest.console process: {ProcessId}-{ProcessName}, waiting {Endsessiontimeout} milliseconds for it to exit.");
        if (!_processExitedEvent.WaitOne(Endsessiontimeout) && IsProcessInitialized())
        {
            EqtTrace.Info($"VsTestConsoleProcessManager.ShutDownProcess : Terminating vstest.console process: {ProcessId}-{ProcessName} after waiting for {Endsessiontimeout} milliseconds.");
            _vstestConsoleExited = true;
            if (_process is not null)
            {
                _process.OutputDataReceived -= Process_OutputDataReceived;
                _process.ErrorDataReceived -= Process_ErrorDataReceived;
                SafelyTerminateProcess();
                _process.Dispose();
                _process = null;
            }
            EqtTrace.Info($"VsTestConsoleProcessManager.ShutDownProcess : Terminated vstest.console process: {ProcessId}-{ProcessName}.");
        }
        else
        {
            EqtTrace.Verbose($"VsTestConsoleProcessManager.ShutDownProcess : Process: {ProcessId}-{ProcessName} already exited, doing nothing.");
        }
    }

    private void SafelyTerminateProcess()
    {
        try
        {
            if (_process != null && !_process.HasExited)
            {
                _process.Kill();
            }
        }
        catch (InvalidOperationException ex)
        {
            EqtTrace.Info("VsTestCommandLineWrapper: Error While Terminating Process {0} ", ex.Message);
        }
    }

    private void Process_Exited(object? sender, EventArgs e)
    {
        lock (_syncObject)
        {
            _processExitedEvent.Set();
            _vstestConsoleExited = true;
            ExitCode = ((Process)sender!).ExitCode;
            ProcessExited?.Invoke(sender, e);
        }
    }

    private void Process_ErrorDataReceived(object sender, DataReceivedEventArgs e)
    {
        if (e.Data != null)
        {
            EqtTrace.Error(e.Data);
            _vstestConsoleError.AppendLine(e.Data);
        }
    }

    private void Process_OutputDataReceived(object sender, DataReceivedEventArgs e)
    {
        if (e.Data != null)
        {
            EqtTrace.Verbose(e.Data);
        }
    }

    internal string[] BuildArguments(ConsoleParameters parameters)
    {
        var args = new List<string>
        {
            // Start Vstest.console with args: --parentProcessId|/parentprocessid:<ppid> --port|/port:<port>
            string.Format(CultureInfo.InvariantCulture, ParentProcessidArgument, parameters.ParentProcessId),
            string.Format(CultureInfo.InvariantCulture, PortArgument, parameters.PortNumber)
        };

        if (!parameters.LogFilePath.IsNullOrEmpty())
        {
            // Extra args: --diag|/diag:<PathToLogFile>;tracelevel=<tracelevel>
            args.Add(string.Format(CultureInfo.InvariantCulture, DiagArgument, parameters.LogFilePath, parameters.TraceLevel));
        }

        if (_isNetCoreRunner)
        {
            args.Insert(0, GetEscapeSequencedPath(_vstestConsolePath));
        }

        return args.ToArray();
    }

    private string GetConsoleRunner()
        => _isNetCoreRunner
            ? _dotnetExePath.IsNullOrEmpty()
                ? new DotnetHostHelper().GetDotnetPath()
                : _dotnetExePath
            : GetEscapeSequencedPath(_vstestConsolePath);

    private static string GetEscapeSequencedPath(string path)
        => path.IsNullOrEmpty() ? path : $"\"{path.Trim('"')}\"";

    public void Dispose()
    {
        if (!_isDisposed)
        {
            _processExitedEvent.Dispose();
            _process?.Dispose();
            _isDisposed = true;
        }
    }
}