diff --git a/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/Program.cs b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/Program.cs new file mode 100644 index 00000000000..6cb9ee24c3a --- /dev/null +++ b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/Program.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using System.Threading.Tasks; +using System.Xml.Linq; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +// @TODO medium-to-longer term, we should try to get rid of the special-unicorn-single-file runner in favor of making the real runner work for single file. +// https://github.com/dotnet/runtime/issues/70432 +public class SingleFileTestRunner : XunitTestFramework +{ + private SingleFileTestRunner(IMessageSink messageSink) + : base(messageSink) { } + + public static int Main(string[] args) + { + int? maybeExitCode = Microsoft.DotNet.RemoteExecutor.Program.TryExecute(args); + if (maybeExitCode.HasValue) + { + return maybeExitCode.Value; + } + + var asm = typeof(SingleFileTestRunner).Assembly; + Console.WriteLine("Running assembly:" + asm.FullName); + + var diagnosticSink = new ConsoleDiagnosticMessageSink(); + var testsFinished = new TaskCompletionSource(); + var testSink = new TestMessageSink(); + var summarySink = new DelegatingExecutionSummarySink(testSink, + () => false, + (completed, summary) => Console.WriteLine($"Tests run: {summary.Total}, Errors: {summary.Errors}, Failures: {summary.Failed}, Skipped: {summary.Skipped}. Time: {TimeSpan.FromSeconds((double)summary.Time).TotalSeconds}s")); + var resultsXmlAssembly = new XElement("assembly"); + var resultsSink = new DelegatingXmlCreationSink(summarySink, resultsXmlAssembly); + + testSink.Execution.TestSkippedEvent += args => { Console.WriteLine($"[SKIP] {args.Message.Test.DisplayName}"); }; + testSink.Execution.TestFailedEvent += args => { Console.WriteLine($"[FAIL] {args.Message.Test.DisplayName}{Environment.NewLine}{Xunit.ExceptionUtility.CombineMessages(args.Message)}{Environment.NewLine}{Xunit.ExceptionUtility.CombineStackTraces(args.Message)}"); }; + + testSink.Execution.TestAssemblyFinishedEvent += args => + { + Console.WriteLine($"Finished {args.Message.TestAssembly.Assembly}{Environment.NewLine}"); + testsFinished.SetResult(); + }; + + var assemblyConfig = new TestAssemblyConfiguration() + { + // Turn off pre-enumeration of theories, since there is no theory selection UI in this runner + PreEnumerateTheories = false, + }; + + var xunitTestFx = new SingleFileTestRunner(diagnosticSink); + var asmInfo = Reflector.Wrap(asm); + var asmName = asm.GetName(); + + var discoverySink = new TestDiscoverySink(); + var discoverer = xunitTestFx.CreateDiscoverer(asmInfo); + discoverer.Find(false, discoverySink, TestFrameworkOptions.ForDiscovery(assemblyConfig)); + discoverySink.Finished.WaitOne(); + + string xmlResultFileName = null; + XunitFilters filters = new XunitFilters(); + // Quick hack wo much validation to get args that are passed (notrait, xml) + Dictionary> noTraits = new Dictionary>(); + for (int i = 0; i < args.Length; i++) + { + if (args[i].Equals("-notrait", StringComparison.OrdinalIgnoreCase)) + { + var traitKeyValue=args[i + 1].Split("=", StringSplitOptions.TrimEntries); + if (!noTraits.TryGetValue(traitKeyValue[0], out List values)) + { + noTraits.Add(traitKeyValue[0], values = new List()); + } + values.Add(traitKeyValue[1]); + } + if (args[i].Equals("-xml", StringComparison.OrdinalIgnoreCase)) + { + xmlResultFileName=args[i + 1].Trim(); + } + } + + foreach (KeyValuePair> kvp in noTraits) + { + filters.ExcludedTraits.Add(kvp.Key, kvp.Value); + } + + var filteredTestCases = discoverySink.TestCases.Where(filters.Filter).ToList(); + var executor = xunitTestFx.CreateExecutor(asmName); + executor.RunTests(filteredTestCases, resultsSink, TestFrameworkOptions.ForExecution(assemblyConfig)); + + resultsSink.Finished.WaitOne(); + + // Helix need to see results file in the drive to detect if the test has failed or not + if(xmlResultFileName != null) + { + resultsXmlAssembly.Save(xmlResultFileName); + } + + var failed = resultsSink.ExecutionSummary.Failed > 0 || resultsSink.ExecutionSummary.Errors > 0; + return failed ? 1 : 0; + } +} + +internal class ConsoleDiagnosticMessageSink : IMessageSink +{ + public bool OnMessage(IMessageSinkMessage message) + { + if (message is IDiagnosticMessage diagnosticMessage) + { + return true; + } + return false; + } +} diff --git a/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/SingleFileTests.csproj b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/SingleFileTests.csproj new file mode 100644 index 00000000000..c0cbb851e17 --- /dev/null +++ b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/SingleFileTests.csproj @@ -0,0 +1,45 @@ + + + + Exe + net7.0 + true + + + + + + + + + + + + + + + + + + + true + true + + + + + + + + + + $(NoWarn);IL1005;IL3002 + partial + true + true + + + true + + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/rd.xml b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/rd.xml new file mode 100644 index 00000000000..e4966287fa6 --- /dev/null +++ b/src/Microsoft.DotNet.RemoteExecutor/SingleFileTests/rd.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.DotNet.RemoteExecutor/src/Microsoft.DotNet.RemoteExecutor.csproj b/src/Microsoft.DotNet.RemoteExecutor/src/Microsoft.DotNet.RemoteExecutor.csproj index 9f2d2c9c205..c47979ed247 100644 --- a/src/Microsoft.DotNet.RemoteExecutor/src/Microsoft.DotNet.RemoteExecutor.csproj +++ b/src/Microsoft.DotNet.RemoteExecutor/src/Microsoft.DotNet.RemoteExecutor.csproj @@ -10,6 +10,9 @@ true $(TargetsForTfmSpecificContentInPackage);PackBuildOutputs true + $(NoWarn);IL3000 + true + true diff --git a/src/Microsoft.DotNet.RemoteExecutor/src/Program.cs b/src/Microsoft.DotNet.RemoteExecutor/src/Program.cs index 1a621fcb36b..668db1372e9 100644 --- a/src/Microsoft.DotNet.RemoteExecutor/src/Program.cs +++ b/src/Microsoft.DotNet.RemoteExecutor/src/Program.cs @@ -13,10 +13,40 @@ namespace Microsoft.DotNet.RemoteExecutor /// /// Provides an entry point in a new process that will load a specified method and invoke it. /// - internal static class Program + public static class Program { private static int Main(string[] args) { + int? maybeExitCode = TryExecute(args); + if (maybeExitCode.HasValue) + { + return maybeExitCode.Value; + } + + // we should not get here + Console.Error.WriteLine("Remote executor EXE started, but missing magic environmental variable: " + RemoteExecutor.REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE); + return -1; + } + + /// + /// Checks if the command line arguments are for the remote executor. If so, attempts to parse and execute the remote function. + /// + /// + /// This entry point is intended be called by single-file test hosts. It allows one applicaiton to both hosts the tests + /// and host the remote executor. + /// This method may exit the process before returning. + /// + /// null the arguments are not for the remote executor, otherwise the exit code for the process as a result of running the remote executor + public static int? TryExecute(string[] args) + { + if (Environment.GetEnvironmentVariable(RemoteExecutor.REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE) is null) + { + return null; + } + + // Allow the remote executor to also start more remote executors. + Environment.SetEnvironmentVariable(RemoteExecutor.REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE, null); + // The program expects to be passed the target assembly name to load, the type // from that assembly to find, and the method from that assembly to invoke. // Any additional arguments are passed as strings to the method. diff --git a/src/Microsoft.DotNet.RemoteExecutor/src/RemoteExecutor.cs b/src/Microsoft.DotNet.RemoteExecutor/src/RemoteExecutor.cs index 0d80e2ebd3a..5307cffa172 100644 --- a/src/Microsoft.DotNet.RemoteExecutor/src/RemoteExecutor.cs +++ b/src/Microsoft.DotNet.RemoteExecutor/src/RemoteExecutor.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading.Tasks; using Xunit; @@ -16,6 +17,8 @@ namespace Microsoft.DotNet.RemoteExecutor { public static partial class RemoteExecutor { + internal const string REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE = "DOTNET_REMOTE_EXECUTOR"; + /// /// A timeout (milliseconds) after which a wait on a remote operation should be considered a failure. /// @@ -54,37 +57,45 @@ static RemoteExecutor() Path = typeof(RemoteExecutor).Assembly.Location; - if (IsNetCore()) + if (string.IsNullOrEmpty(Path)) + { + // Single file case. Assume that our entry EXE has will detect the special argument and vector into the remote executor. + HostRunner = Process.GetCurrentProcess().MainModule.FileName; + } + else { - HostRunner = processFileName; + if (IsNetCore()) + { + HostRunner = processFileName; - string hostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + string hostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; - // Partially addressing https://github.com/dotnet/arcade/issues/6371 - // We expect to run tests with dotnet. However in certain scenarios we may have a different apphost (e.g. Visual Studio testhost). - // Attempt to find and use dotnet. - if (!IOPath.GetFileName(HostRunner).Equals(hostName, StringComparison.OrdinalIgnoreCase)) - { - string runtimePath = IOPath.GetDirectoryName(typeof(object).Assembly.Location); - - // In case we are running the app via a runtime, dotnet.exe is located 3 folders above the runtime. Example: - // runtime -> C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.6\ - // dotnet.exe -> C:\Program Files\dotnet\shared\dotnet.exe - // This should also work on Unix and locally built runtime/testhost. - string directory = GetDirectoryName(GetDirectoryName(GetDirectoryName(runtimePath))); - if (directory != string.Empty) + // Partially addressing https://github.com/dotnet/arcade/issues/6371 + // We expect to run tests with dotnet. However in certain scenarios we may have a different apphost (e.g. Visual Studio testhost). + // Attempt to find and use dotnet. + if (!IOPath.GetFileName(HostRunner).Equals(hostName, StringComparison.OrdinalIgnoreCase)) { - string dotnetExe = IOPath.Combine(directory, hostName); - if (File.Exists(dotnetExe)) + string runtimePath = IOPath.GetDirectoryName(typeof(object).Assembly.Location); + + // In case we are running the app via a runtime, dotnet.exe is located 3 folders above the runtime. Example: + // runtime -> C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.6\ + // dotnet.exe -> C:\Program Files\dotnet\shared\dotnet.exe + // This should also work on Unix and locally built runtime/testhost. + string directory = GetDirectoryName(GetDirectoryName(GetDirectoryName(runtimePath))); + if (directory != string.Empty) { - HostRunner = dotnetExe; + string dotnetExe = IOPath.Combine(directory, hostName); + if (File.Exists(dotnetExe)) + { + HostRunner = dotnetExe; + } } } } - } - else if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase)) - { - HostRunner = Path; + else if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase)) + { + HostRunner = Path; + } } HostRunnerName = IOPath.GetFileName(HostRunner); @@ -95,6 +106,8 @@ static RemoteExecutor() private static bool IsNetCore() => Environment.Version.Major >= 5 || RuntimeInformation.FrameworkDescription.StartsWith(".NET Core", StringComparison.OrdinalIgnoreCase); + private static bool IsSingleFile() => string.IsNullOrEmpty(typeof(RemoteExecutor).Assembly.Location); + /// Returns true if the RemoteExecutor works on the current platform, otherwise false. public static bool IsSupported { get; } = !RuntimeInformation.IsOSPlatform(OSPlatform.Create("IOS")) && @@ -103,8 +116,6 @@ private static bool IsNetCore() => !RuntimeInformation.IsOSPlatform(OSPlatform.Create("MACCATALYST")) && !RuntimeInformation.IsOSPlatform(OSPlatform.Create("WATCHOS")) && !RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER")) && - // The current RemoteExecutor design is not compatible with single file - !string.IsNullOrEmpty(typeof(RemoteExecutor).Assembly.Location) && Environment.GetEnvironmentVariable("DOTNET_REMOTEEXECUTOR_SUPPORTED") != "0"; /// Invokes the method from this assembly in another process using the specified arguments. @@ -405,6 +416,13 @@ private static RemoteInvokeHandle Invoke(MethodInfo method, string[] args, throw new PlatformNotSupportedException("RemoteExecutor is not supported on this platform."); } + // If we were started as the remote executor but did not actually enter the remote executor entrypoint, + // throw to prevent infinitely spawning processes. + if (Environment.GetEnvironmentVariable(REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE) is not null) + { + throw new InvalidOperationException("Magic environmental variable to start the remote executor is set! Did your single-file host forget to call Microsoft.DotNet.RemoteExecutor.Program.TryExecute() ?"); + } + // 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) @@ -421,6 +439,7 @@ private static RemoteInvokeHandle Invoke(MethodInfo method, string[] args, // Start the other process and return a wrapper for it to handle its lifetime and exit checking. ProcessStartInfo psi = options.StartInfo; psi.UseShellExecute = false; + psi.Environment.Add(REMOTE_EXECUTOR_ENVIRONMENTAL_VARIABLE, "1"); if (!options.EnableProfiling) { @@ -462,19 +481,20 @@ private static RemoteInvokeHandle Invoke(MethodInfo method, string[] args, private static string GetConsoleAppArgs(RemoteInvokeOptions options, out IEnumerable toDispose) { bool isNetCore = IsNetCore(); - if (options.RuntimeConfigurationOptions?.Any() == true&& !isNetCore) + bool isSingleFile = IsSingleFile(); + if (options.RuntimeConfigurationOptions?.Any() == true && (!isNetCore || isSingleFile)) { throw new InvalidOperationException("RuntimeConfigurationOptions are only supported on .NET Core"); } - if (!isNetCore) + if (!isNetCore || isSingleFile) { toDispose = null; return string.Empty; } string args = "exec"; - + string runtimeConfigPath = GetRuntimeConfigPath(options, out toDispose); if (runtimeConfigPath != null) { diff --git a/src/Microsoft.DotNet.RemoteExecutor/tests/RemoteExecutorTests.cs b/src/Microsoft.DotNet.RemoteExecutor/tests/RemoteExecutorTests.cs index 3e7e0e4f650..7f77f30d0b1 100644 --- a/src/Microsoft.DotNet.RemoteExecutor/tests/RemoteExecutorTests.cs +++ b/src/Microsoft.DotNet.RemoteExecutor/tests/RemoteExecutorTests.cs @@ -108,6 +108,7 @@ public static void AsyncAction_FatalError_AV() ); } + // NativeAOT-TODO: NativeAOT translates the AV into a NullReferenceException rather than just crashing the process. [Fact] public static void AsyncAction_FatalError_Runtime() { @@ -133,6 +134,7 @@ public static unsafe void FatalError_AV() ); } + // NativeAOT-TODO: NativeAOT translates the AV into a NullReferenceException rather than just crashing the process. [Fact] public static void FatalError_Runtime() {