File: Client\ProxyOperationManager.cs
Web Access
Project: src\src\vstest\src\Microsoft.TestPlatform.CrossPlatEngine\Microsoft.TestPlatform.CrossPlatEngine.csproj (Microsoft.TestPlatform.CrossPlatEngine)

// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;

using Microsoft.VisualStudio.TestPlatform.Common.Utilities;
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Interfaces;
using Microsoft.VisualStudio.TestPlatform.CoreUtilities.Extensions;
using Microsoft.VisualStudio.TestPlatform.CoreUtilities.Helpers;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Host;
using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging;
using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions;
using Microsoft.VisualStudio.TestPlatform.PlatformAbstractions.Interfaces;
using Microsoft.VisualStudio.TestPlatform.Utilities;

using CommunicationUtilitiesResources = Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Resources.Resources;
using CoreUtilitiesConstants = Microsoft.VisualStudio.TestPlatform.CoreUtilities.Constants;
using CrossPlatEngineResources = Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Resources.Resources;

namespace Microsoft.VisualStudio.TestPlatform.CrossPlatEngine.Client;

/// <summary>
/// Base class for any operations that the client needs to drive through the engine.
/// </summary>
[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Would cause a breaking change if users are inheriting this class and implement IDisposable")]
public class ProxyOperationManager
{
    internal const string DotnetTesthostFriendlyName = "DotnetTestHost";
    internal const string DefaultTesthostFriendlyName = "DefaultTestHost";

    private readonly string _versionCheckPropertyName = "IsVersionCheckRequired";
    private readonly string _makeRunsettingsCompatiblePropertyName = "MakeRunsettingsCompatible";
    private readonly ManualResetEventSlim _testHostExited = new(false);
    private readonly IProcessHelper _processHelper;
    private readonly IBaseProxy? _baseProxy;

    private bool _versionCheckRequired = true;
    private bool _makeRunsettingsCompatible;
    private bool _makeRunsettingsCompatibleSet;
    private bool _initialized;
    private bool _testHostLaunched;
    private int _testHostProcessId;
    private string? _testHostProcessStdError;

    /// <summary>
    /// Initializes a new instance of the <see cref="ProxyOperationManager"/> class.
    /// </summary>
    ///
    /// <param name="requestData">Request data instance.</param>
    /// <param name="requestSender">Request sender instance.</param>
    /// <param name="testHostManager">Test host manager instance.</param>
    /// <param name="testhostManagerFramework">Testhost manager framework</param>
    public ProxyOperationManager(
        IRequestData? requestData,
        ITestRequestSender requestSender,
        ITestRuntimeProvider testHostManager,
        Framework testhostManagerFramework)
        : this(
            requestData,
            requestSender,
            testHostManager,
            testhostManagerFramework,
            null)
    { }

    /// <summary>
    /// Initializes a new instance of the <see cref="ProxyOperationManager"/> class.
    /// </summary>
    ///
    /// <param name="requestData">Request data instance.</param>
    /// <param name="requestSender">Request sender instance.</param>
    /// <param name="testHostManager">Test host manager instance.</param>
    /// <param name="testhostManagerFramework">Testhost manager framework</param>
    /// <param name="baseProxy">The base proxy.</param>
    public ProxyOperationManager(
        IRequestData? requestData,
        ITestRequestSender requestSender,
        ITestRuntimeProvider testHostManager,
        Framework? testhostManagerFramework,
        IBaseProxy? baseProxy)
    {
        RequestData = requestData;
        RequestSender = requestSender;
        TestHostManager = testHostManager;
        _baseProxy = baseProxy;

        _initialized = false;
        _testHostLaunched = false;
        _testHostProcessId = -1;
        _processHelper = new ProcessHelper();
        CancellationTokenSource = new CancellationTokenSource();
        TestHostManagerFramework = testhostManagerFramework;
    }

    /// <summary>
    /// Gets or sets the request data.
    /// </summary>
    public IRequestData? RequestData { get; set; }

    /// <summary>
    /// Gets or sets the server for communication.
    /// </summary>
    public ITestRequestSender RequestSender { get; set; }

    /// <summary>
    /// Gets or sets the test host manager.
    /// </summary>
    public ITestRuntimeProvider TestHostManager { get; set; }

    /// <summary>
    /// Gets the proxy operation manager id for proxy test session manager internal organization.
    /// </summary>
    internal int Id { get; set; } = -1;

    /// <summary>
    /// Gets or sets a value indicating whether the current proxy operation manager is part of a
    /// test session.
    /// </summary>
    internal bool IsTestSessionEnabled { get; set; }

    /// <summary>
    /// Gets or sets the cancellation token source.
    /// </summary>
    public CancellationTokenSource CancellationTokenSource { get; set; }

    public Framework? TestHostManagerFramework { get; }

    #region IProxyOperationManager implementation.
    /// <summary>
    /// Initializes the proxy.
    /// </summary>
    ///
    /// <param name="skipDefaultAdapters">
    /// Flag indicating if we should skip the default adapters initialization.
    /// </param>
    public virtual void Initialize(bool skipDefaultAdapters)
    {
        // No-op.
    }

    /// <summary>
    /// Ensures that the engine is ready for test operations. Usually includes starting up the
    /// test host process.
    /// </summary>
    ///
    /// <param name="sources">List of test sources.</param>
    /// <param name="runSettings">Run settings to be used.</param>
    /// <param name="eventHandler">The events handler.</param>
    ///
    /// <returns>
    /// Returns true if the communication is established b/w runner and host, false otherwise.
    /// </returns>
    public virtual bool SetupChannel(
        IEnumerable<string> sources,
        string runSettings,
        ITestMessageEventHandler eventHandler)
    {
        // NOTE: Event handler is ignored here, but it is used in the overloaded method.
        return SetupChannel(sources, runSettings);
    }

    /// <summary>
    /// Ensures that the engine is ready for test operations. Usually includes starting up the
    /// test host process.
    /// </summary>
    ///
    /// <param name="sources">List of test sources.</param>
    /// <param name="runSettings">Run settings to be used.</param>
    ///
    /// <returns>
    /// Returns true if the communication is established b/w runner and host, false otherwise.
    /// </returns>
    public virtual bool SetupChannel(IEnumerable<string> sources, string? runSettings)
    {
        CancellationTokenSource.Token.ThrowTestPlatformExceptionIfCancellationRequested();

        if (_initialized)
        {
            return true;
        }

        // Check whether test sessions are supported if the current proxy operation manager is to
        // be part of one.
        if (IsTestSessionEnabled && !IsTesthostCompatibleWithTestSessions())
        {
            return false;
        }

        var connTimeout = EnvironmentHelper.GetConnectionTimeout();

        _testHostProcessStdError = string.Empty;
        TestHostConnectionInfo testHostConnectionInfo = TestHostManager.GetTestHostConnectionInfo();

        var portNumber = 0;
        if (testHostConnectionInfo.Role == ConnectionRole.Client)
        {
            portNumber = RequestSender.InitializeCommunication();
            testHostConnectionInfo.Endpoint += portNumber;
        }

        var processId = _processHelper.GetCurrentProcessId();
        var connectionInfo = new TestRunnerConnectionInfo()
        {
            Port = portNumber,
            ConnectionInfo = testHostConnectionInfo,
            RunnerProcessId = processId,
            LogFile = GetTimestampedLogFile(EqtTrace.LogFile),
            TraceLevel = (int)EqtTrace.TraceLevel
        };

        // Subscribe to test host events.
        TestHostManager.HostLaunched += TestHostManagerHostLaunched;
        TestHostManager.HostExited += TestHostManagerHostExited;

        // Get environment variables from run settings.
        var envVars = InferRunSettingsHelper.GetEnvironmentVariables(runSettings);

        // Get the test process start info.
        var testHostStartInfo = UpdateTestProcessStartInfo(
            TestHostManager.GetTestHostProcessStartInfo(
                sources,
                envVars,
                connectionInfo));
        try
        {
            // Launch the test host.
            _testHostLaunched = TestHostManager.LaunchTestHostAsync(
                testHostStartInfo,
                CancellationTokenSource.Token).Result;

            if (_testHostLaunched && testHostConnectionInfo.Role == ConnectionRole.Host)
            {
                // If test runtime is service host, try to poll for connection as client.
                RequestSender.InitializeCommunication();
            }
        }
        catch (Exception ex)
        {
            EqtTrace.Error("ProxyOperationManager: Failed to launch testhost :{0}", ex);

            CancellationTokenSource.Token.ThrowTestPlatformExceptionIfCancellationRequested();
            throw new TestPlatformException(string.Format(CultureInfo.CurrentCulture, CrossPlatEngineResources.FailedToLaunchTestHost, ex.ToString()));
        }

        // Warn the user that execution will wait for debugger attach.
        var hostDebugEnabled = Environment.GetEnvironmentVariable("VSTEST_HOST_DEBUG");
        var nativeHostDebugEnabled = Environment.GetEnvironmentVariable("VSTEST_HOST_NATIVE_DEBUG");

        if ((!StringUtils.IsNullOrEmpty(hostDebugEnabled)
             && hostDebugEnabled.Equals("1", StringComparison.Ordinal))
            || (new PlatformEnvironment().OperatingSystem.Equals(PlatformOperatingSystem.Windows)
                && !StringUtils.IsNullOrEmpty(nativeHostDebugEnabled)
                && nativeHostDebugEnabled.Equals("1", StringComparison.Ordinal)))
        {
            ConsoleOutput.Instance.WriteLine(
                CrossPlatEngineResources.HostDebuggerWarning,
                OutputLevel.Warning);

            ConsoleOutput.Instance.WriteLine(
                string.Format(
                    CultureInfo.InvariantCulture,
                    "Process Id: {0}, Name: {1}",
                    _testHostProcessId,
                    _processHelper.GetProcessName(_testHostProcessId)),
                OutputLevel.Information);

            // Increase connection timeout when debugging is enabled.
            connTimeout *= 5;
        }

        // If test host does not launch then throw exception, otherwise wait for connection.
        if (!_testHostLaunched
            || !RequestSender.WaitForRequestHandlerConnection(
                connTimeout * 1000,
                CancellationTokenSource.Token))
        {
            EqtTrace.Verbose($"Test host failed to start Test host launched:{_testHostLaunched} test host exited: {_testHostExited.IsSet}");
            // Throw a test platform exception with the appropriate message if user requested cancellation.
            CancellationTokenSource.Token.ThrowTestPlatformExceptionIfCancellationRequested();

            // Throw a test platform exception along with the error messages from the test if the test host exited unexpectedly
            // before communication was established.
            ThrowOnTestHostExited(sources, _testHostExited.IsSet);

            // Throw a test platform exception stating the connection to test could not be established even after waiting
            // for the configure timeout period.
            ThrowExceptionOnConnectionFailure(sources, connTimeout);
        }

        // Handling special case for dotnet core projects with older test hosts.
        // Older test hosts are not aware of protocol version check, hence we should not be
        // sending VersionCheck message to these test hosts.
        CompatIssueWithVersionCheckAndRunsettings();
        if (_versionCheckRequired)
        {
            RequestSender.CheckVersionWithTestHost();
        }

        _initialized = true;

        return true;
    }

    /// <summary>
    /// Closes the channel and terminates the test host process.
    /// </summary>
    public virtual void Close()
    {
        try
        {
            // Do not send message if the host did not launch.
            if (_testHostLaunched)
            {
                RequestSender.EndSession();

                // We want to give test host a chance to safely close.
                // The upper bound for wait should be 100ms.
                var timeout = int.TryParse(Environment.GetEnvironmentVariable("VSTEST_TESTHOST_SHUTDOWN_TIMEOUT"), out var time)
                    ? time
                    : 100;
                EqtTrace.Verbose("ProxyOperationManager.Close: waiting for test host to exit for {0} ms", timeout);
                if (!_testHostExited.Wait(timeout))
                {
                    EqtTrace.Warning("ProxyOperationManager: Timed out waiting for test host to exit. Will terminate process.");
                }

                // Closing the communication channel.
                RequestSender.Close();
            }
        }
        catch (Exception ex)
        {
            // Error in sending an end session is not necessarily a failure. Discovery and execution should be already
            // complete at this time.
            EqtTrace.Warning("ProxyOperationManager: Failed to end session: " + ex);
        }
        finally
        {
            _initialized = false;

            // This is calling external code, make sure we don't fail when it throws
            try
            {
                // Please clean up test host.
                TestHostManager.CleanTestHostAsync(CancellationToken.None).Wait();
            }
            catch (Exception ex)
            {
                EqtTrace.Error($"ProxyOperationManager: Cleaning testhost failed: {ex}");
            }

            TestHostManager.HostExited -= TestHostManagerHostExited;
            TestHostManager.HostLaunched -= TestHostManagerHostLaunched;
        }
    }

    #endregion

    /// <summary>
    /// This method is exposed to enable derived classes to modify
    /// <see cref="TestProcessStartInfo"/>. For example, data collectors need additional
    /// environment variables to be passed.
    /// </summary>
    ///
    /// <param name="testProcessStartInfo">The test process start info.</param>
    ///
    /// <returns>
    /// The <see cref="TestProcessStartInfo"/>.
    /// </returns>
    public virtual TestProcessStartInfo UpdateTestProcessStartInfo(TestProcessStartInfo testProcessStartInfo)
    {
        // TODO (copoiena): If called and testhost is already running, we should restart.
        if (_baseProxy == null)
        {
            // Update Telemetry Opt in status because by default in Test Host Telemetry is opted out
            var telemetryOptedIn = RequestData?.IsTelemetryOptedIn == true ? "true" : "false";
            testProcessStartInfo.Arguments += " --telemetryoptedin " + telemetryOptedIn;
            return testProcessStartInfo;
        }

        return _baseProxy.UpdateTestProcessStartInfo(testProcessStartInfo);
    }

    /// <summary>
    /// This function will remove the unknown run settings nodes from the run settings strings.
    /// This is necessary because older test hosts may throw exceptions when encountering
    /// unknown nodes.
    /// </summary>
    ///
    /// <param name="runsettingsXml">Run settings string.</param>
    /// <param name="logMessage">Message logger.</param>
    ///
    /// <returns>The run settings after removing non-required nodes.</returns>
    public string? RemoveNodesFromRunsettingsIfRequired(string? runsettingsXml, Action<TestMessageLevel, string> logMessage)
    {
        var updatedRunSettingsXml = runsettingsXml;
        if (!_makeRunsettingsCompatibleSet)
        {
            CompatIssueWithVersionCheckAndRunsettings();
        }

        if (_makeRunsettingsCompatible)
        {
            logMessage.Invoke(TestMessageLevel.Warning, CrossPlatEngineResources.OldTestHostIsGettingUsed);
            updatedRunSettingsXml = InferRunSettingsHelper.MakeRunsettingsCompatible(runsettingsXml);
        }

        // We can remove "TargetPlatform" because is not needed, process is already in a "specific" target platform after test host process start,
        // so the default architecture is always the correct one.
        // This allow us to support new architecture enumeration without the need to update old test sdk.
        updatedRunSettingsXml = InferRunSettingsHelper.RemoveTargetPlatformElement(updatedRunSettingsXml);

        return updatedRunSettingsXml;
    }

    internal virtual string ReadTesthostFriendlyName()
    {
        var friendlyNameAttribute = TestHostManager.GetType().GetCustomAttributes(
                typeof(FriendlyNameAttribute), true)
            .FirstOrDefault();

        return (friendlyNameAttribute is not null and FriendlyNameAttribute friendlyName)
            ? friendlyName.FriendlyName : string.Empty;
    }

    internal bool IsTesthostCompatibleWithTestSessions()
    {
        // These constants should be kept in line with the friendly names found in
        // DotnetTestHostManager.cs, respectively DefaultTestHostManager.cs.
        //
        // We agreed on checking the test session compatibility this way (i.e. by reading the
        // friendly name and making sure it's one of the testhosts we control) instead of a more
        // generic alternative that was initially proposed (i.e. by decorating each testhost
        // manager with a capability attribute that could tell us if the test session scenario
        // is supported for the testhost in discussion) because of the breaking risks associated
        // with the latter approach. Also, there is no formal specification for now of what it
        // means to support test sessions. Should extending session functionality to 3rd party
        // testhosts be something we want to address in the future, we should come up with such
        // a specification first.
        var friendlyName = ReadTesthostFriendlyName();
        if (!friendlyName.IsNullOrEmpty())
        {
            var isSessionSupported = friendlyName is (DotnetTesthostFriendlyName or DefaultTesthostFriendlyName);
            EqtTrace.Verbose($"ProxyOperationManager.IsTesthostCompatibleWithTestSessions: Testhost friendly name: {friendlyName}; Sessions support: {isSessionSupported};");

            return isSessionSupported;
        }

        return false;
    }

    [return: NotNullIfNotNull("logFile")]
    private static string? GetTimestampedLogFile(string? logFile)
    {
        return logFile.IsNullOrWhiteSpace()
            ? null
            : Path.ChangeExtension(
                logFile,
                string.Format(
                    CultureInfo.InvariantCulture,
                    "host.{0}_{1}{2}",
                    DateTime.Now.ToString("yy-MM-dd_HH-mm-ss_fffff", CultureInfo.InvariantCulture),
                    new PlatformEnvironment().GetCurrentManagedThreadId(),
                    Path.GetExtension(logFile))).AddDoubleQuote();
    }

    private void CompatIssueWithVersionCheckAndRunsettings()
    {
        var properties = TestHostManager.GetType().GetRuntimeProperties();

        // The field is actually defaulting to true, so this is just a complicated way to set or not set
        // this to true (modern testhosts should have it set to true). Bad thing about this is that we are checking
        // internal "undocumented" property. Good thing is that if you don't implement it you get the modern behavior.
        var versionCheckProperty = properties.FirstOrDefault(p => string.Equals(p.Name, _versionCheckPropertyName, StringComparison.OrdinalIgnoreCase));
        if (versionCheckProperty != null)
        {
            _versionCheckRequired = (bool)versionCheckProperty.GetValue(TestHostManager)!;
        }

        var makeRunsettingsCompatibleProperty = properties.FirstOrDefault(p => string.Equals(p.Name, _makeRunsettingsCompatiblePropertyName, StringComparison.OrdinalIgnoreCase));
        if (makeRunsettingsCompatibleProperty != null)
        {
            _makeRunsettingsCompatible = (bool)makeRunsettingsCompatibleProperty.GetValue(TestHostManager)!;
            _makeRunsettingsCompatibleSet = true;
        }
    }

    private void TestHostManagerHostLaunched(object? sender, HostProviderEventArgs? e)
    {
        EqtTrace.Verbose(e!.Data);
        _testHostProcessId = e.ProcessId;
    }

    private void TestHostManagerHostExited(object? sender, HostProviderEventArgs? e)
    {
        EqtTrace.Verbose("CrossPlatEngine.TestHostManagerHostExited: calling on client process exit callback.");
        _testHostProcessStdError = e!.Data;

        // This needs to be set before we call the OnClientProcess exit because the
        // OnClientProcess will short-circuit WaitForRequestHandlerConnection in SetupChannel
        // that then continues to throw an exception and checks if the test host process exited.
        // If not it reports timeout, if we don't set this before OnClientProcessExit we will
        // report timeout even though we exited the test host before even attempting the connect.
        _testHostExited.Set();
        RequestSender.OnClientProcessExit(_testHostProcessStdError);
    }

    private void ThrowOnTestHostExited(IEnumerable<string> sources, bool testHostExited)
    {
        if (testHostExited)
        {
            // We might consider passing standard output here in case standard error is not
            // available because some errors don't end up in the standard error output.
            throw new TestPlatformException(string.Format(CultureInfo.CurrentCulture, CrossPlatEngineResources.TestHostExitedWithError, string.Join("', '", sources), _testHostProcessStdError));
        }
    }

    private void ThrowExceptionOnConnectionFailure(IEnumerable<string> sources, int connTimeout)
    {
        // Failed to launch testhost process.
        var errorMsg = CrossPlatEngineResources.InitializationFailed;

        // Testhost launched but timeout occurred due to machine slowness.
        if (_testHostLaunched)
        {
            errorMsg = string.Format(
                CultureInfo.CurrentCulture,
                CommunicationUtilitiesResources.ConnectionTimeoutErrorMessage,
                CoreUtilitiesConstants.VstestConsoleProcessName,
                CoreUtilitiesConstants.TesthostProcessName,
                connTimeout,
                EnvironmentHelper.VstestConnectionTimeout);

            // Add process ID info if available so the user knows which process is stuck.
            if (_testHostProcessId > 0)
            {
                errorMsg += string.Format(
                    CultureInfo.CurrentCulture,
                    " The process with id {0} was still running when this message was reported.",
                    _testHostProcessId);
            }
        }

        // After testhost process launched failed with error.
        if (!StringUtils.IsNullOrWhiteSpace(_testHostProcessStdError))
        {
            // Testhost failed with error.
            errorMsg = string.Format(CultureInfo.CurrentCulture, CrossPlatEngineResources.TestHostExitedWithError, string.Join("', '", sources), _testHostProcessStdError);
        }

        throw new TestPlatformException(errorMsg);
    }
}