File: RemoteExecutor.cs
Web Access
Project: src\test\Microsoft.ML.TestFramework\Microsoft.ML.TestFramework.csproj (Microsoft.ML.TestFramework)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Xunit;
using Xunit.Sdk;
 
namespace Microsoft.ML.TestFramework
{
    /// <summary>
    /// Base class used for all tests that need to spawn a remote process.
    /// Most of the code has been taken from RemoteExecutorTestBase class in the corefx repo.
    /// </summary>
    public static class RemoteExecutor
    {
        /// <summary>The name of the test console app.</summary>
        public static readonly string TestConsoleApp = Path.GetFullPath(@"RemoteExecutorConsoleApp.dll");
#if NETFRAMEWORK
        public static readonly string HostRunner = Path.GetFullPath(@"RemoteExecutorConsoleApp.exe");
        private static readonly string _extraParameter = "";
#else
        public static readonly string HostRunner = Process.GetCurrentProcess().MainModule.FileName;
        private static readonly string _extraParameter = TestConsoleApp;
#endif
        /// <summary>A timeout (milliseconds) after which a wait on a remote operation should be considered a failure.</summary>
        public const int FailWaitTimeoutMilliseconds = 60 * 1000;
 
        /// <summary>The exit code returned when the test process exits successfully.</summary>
        public const int SuccessExitCode = 42;
 
        /// <summary>Invokes the method from this assembly in another process using the specified arguments.</summary>
        /// <param name="method">The method to invoke.</param>
        /// <param name="arg1">The first argument to pass to the method.</param>
        /// <param name="arg2">The second argument to pass to the method.</param>
        /// <param name="options">Options to use for the invocation.</param>
        public static void RemoteInvoke(
            Func<string, string, int> method,
            string arg1, string arg2,
            RemoteInvokeOptions options = null)
        {
            RemoteInvoke(GetMethodInfo(method), new[] { arg1, arg2 }, options);
        }
 
        /// <summary>Invokes the method from this assembly in another process using the specified arguments.</summary>
        /// <param name="method">The method to invoke.</param>
        /// <param name="arg1">The first argument to pass to the method.</param>
        /// <param name="arg2">The second argument to pass to the method.</param>
        /// <param name="arg3">The third argument to pass to the method.</param>
        /// <param name="options">Options to use for the invocation.</param>
        public static void RemoteInvoke(
            Func<string, string, string, int> method,
            string arg1, string arg2, string arg3,
            RemoteInvokeOptions options = null)
        {
            RemoteInvoke(GetMethodInfo(method), new[] { arg1, arg2, arg3 }, options);
        }
 
        /// <summary>Invokes the method from this assembly in another process using the specified arguments.</summary>
        /// <param name="method">The method to invoke.</param>
        /// <param name="arg1">The first argument to pass to the method.</param>
        /// <param name="arg2">The second argument to pass to the method.</param>
        /// <param name="arg3">The third argument to pass to the method.</param>
        /// <param name="arg4">The fourth argument to pass to the method.</param>
        /// <param name="options">Options to use for the invocation.</param>
        public static void RemoteInvoke(
            Func<string, string, string, string, int> method,
            string arg1, string arg2, string arg3, string arg4,
            RemoteInvokeOptions options = null)
        {
            RemoteInvoke(GetMethodInfo(method), new[] { arg1, arg2, arg3, arg4 }, options);
        }
 
        /// <summary>Invokes the method from this assembly in another process using the specified arguments.</summary>
        /// <param name="method">The method to invoke.</param>
        /// <param name="args">The arguments to pass to the method.</param>
        /// <param name="options">Options to use for the invocation.</param>
        /// <param name="pasteArguments">true if this function should paste the arguments (e.g. surrounding with quotes); false if that responsibility is left up to the caller.</param>
        private static void RemoteInvoke(MethodInfo method, string[] args, RemoteInvokeOptions options, bool pasteArguments = true)
        {
            options = options ?? new RemoteInvokeOptions();
 
            // Verify the specified method returns an int (the exit code) or nothing,
            // and that if it accepts any arguments, they're all strings.
            Assert.True(method.ReturnType == typeof(void) || method.ReturnType == typeof(int) || method.ReturnType == typeof(Task<int>));
            Assert.All(method.GetParameters(), pi => Assert.Equal(typeof(string), pi.ParameterType));
 
            // And make sure it's in this assembly.  This isn't critical, but it helps with deployment to know
            // that the method to invoke is available because we're already running in this assembly.
            Type t = method.DeclaringType;
            Assembly a = t.GetTypeInfo().Assembly;
 
            // Start the other process and return a wrapper for it to handle its lifetime and exit checking.
            ProcessStartInfo psi = options.StartInfo;
            psi.UseShellExecute = false;
 
            // If we need the host (if it exists), use it, otherwise target the console app directly.
            string metadataArgs = PasteArguments.Paste(new string[] { a.FullName, t.FullName, method.Name, options.ExceptionFile }, pasteFirstArgumentUsingArgV0Rules: false);
            string passedArgs = pasteArguments ? PasteArguments.Paste(args, pasteFirstArgumentUsingArgV0Rules: false) : string.Join(" ", args);
            string testConsoleAppArgs = _extraParameter + " " + metadataArgs + " " + passedArgs;
 
            psi.FileName = HostRunner;
            psi.Arguments = testConsoleAppArgs;
 
            // Return the handle to the process, which may or not be started
            CheckProcess(Process.Start(psi), options);
        }
 
        private static void CheckProcess(Process process, RemoteInvokeOptions options)
        {
            if (process != null)
            {
                // A bit unorthodox to do throwing operations in a Dispose, but by doing it here we avoid
                // needing to do this in every derived test and keep each test much simpler.
                try
                {
                    Assert.True(process.WaitForExit(options.TimeOut),
                        $"Timed out after {options.TimeOut}ms waiting for remote process {process.Id}");
 
                    if (File.Exists(options.ExceptionFile))
                    {
                        throw new RemoteExecutionException(File.ReadAllText(options.ExceptionFile));
                    }
 
                    if (options.CheckExitCode)
                    {
                        int expected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? options.ExpectedExitCode : unchecked((sbyte)options.ExpectedExitCode);
                        int actual = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? process.ExitCode : unchecked((sbyte)process.ExitCode);
 
                        Assert.True(expected == actual, $"Exit code was {process.ExitCode} but it should have been {options.ExpectedExitCode}");
                    }
                }
                finally
                {
                    if (File.Exists(options.ExceptionFile))
                    {
                        File.Delete(options.ExceptionFile);
                    }
 
                    // Cleanup
                    try { process.Kill(); }
                    catch { } // ignore all cleanup errors
 
                    process.Dispose();
                    process = null;
                }
            }
        }
 
        private sealed class RemoteExecutionException : XunitException
        {
            internal RemoteExecutionException(string stackTrace) : base($"Remote process failed with an unhandled exception. {stackTrace}") { }
        }
 
        private static MethodInfo GetMethodInfo(Delegate d)
        {
            // RemoteInvoke doesn't support marshaling state on classes associated with
            // the delegate supplied (often a display class of a lambda).  If such fields
            // are used, odd errors result, e.g. NullReferenceExceptions during the remote
            // execution.  Try to ward off the common cases by proactively failing early
            // if it looks like such fields are needed.
            if (d.Target != null)
            {
                // The only fields on the type should be compiler-defined (any fields of the compiler's own
                // making generally include '<' and '>', as those are invalid in C# source).  Note that this logic
                // may need to be revised in the future as the compiler changes, as this relies on the specifics of
                // actually how the compiler handles lifted fields for lambdas.
                Type targetType = d.Target.GetType();
                Assert.All(
                    targetType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
                    fi => Assert.True(fi.Name.IndexOf('<') != -1, $"Field marshaling is not supported by {nameof(RemoteInvoke)}: {fi.Name}"));
            }
 
            return d.GetMethodInfo();
        }
    }
 
    /// <summary>Options used with RemoteInvoke.</summary>
    public sealed class RemoteInvokeOptions
    {
        public RemoteInvokeOptions(Dictionary<string, string> environmentVariables = null)
        {
            if (environmentVariables != null)
            {
                foreach (var item in environmentVariables)
                {
                    StartInfo.EnvironmentVariables.Add(item.Key, item.Value);
                }
            }
        }
 
        public ProcessStartInfo StartInfo { get; set; } = new ProcessStartInfo();
        public bool CheckExitCode { get; set; } = true;
        public int TimeOut { get; set; } = RemoteExecutor.FailWaitTimeoutMilliseconds;
        public int ExpectedExitCode { get; set; } = RemoteExecutor.SuccessExitCode;
        public string ExceptionFile { get; } = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
    }
}