diff --git a/samples/Playground/Playground.csproj b/samples/Playground/Playground.csproj index 57236d10af..dbf0ad2fc3 100644 --- a/samples/Playground/Playground.csproj +++ b/samples/Playground/Playground.csproj @@ -12,6 +12,16 @@ + + + + diff --git a/samples/Playground/Program.cs b/samples/Playground/Program.cs index 1a5cf88f8a..6e138d7392 100644 --- a/samples/Playground/Program.cs +++ b/samples/Playground/Program.cs @@ -18,9 +18,37 @@ public static async Task Main(string[] args) ITestApplicationBuilder testApplicationBuilder = await TestApplication.CreateBuilderAsync(args); testApplicationBuilder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); - // Enable Trx - // testApplicationBuilder.AddTrxReportProvider(); - using ITestApplication testApplication = await testApplicationBuilder.BuildAsync(); - return await testApplication.RunAsync(); + // Enable Trx + // testApplicationBuilder.AddTrxReportProvider(); + + // Enable Telemetry + // testApplicationBuilder.AddAppInsightsTelemetryProvider(); + using ITestApplication testApplication = await testApplicationBuilder.BuildAsync(); + return await testApplication.RunAsync(); + } + else + { + Environment.SetEnvironmentVariable("TESTSERVERMODE", "0"); + using TestingPlatformClient client = await TestingPlatformClientFactory.StartAsServerAndConnectAsync(Environment.ProcessPath!, enableDiagnostic: true); + + await client.InitializeAsync(); + List testNodeUpdates = new(); + ResponseListener discoveryResponse = await client.DiscoverTestsAsync(Guid.NewGuid(), node => + { + testNodeUpdates.AddRange(node); + return Task.CompletedTask; + }); + await discoveryResponse.WaitCompletionAsync(); + + ResponseListener runRequest = await client.RunTestsAsync(Guid.NewGuid(), testNodeUpdates.Select(x => x.Node).ToArray(), node => + { + return Task.CompletedTask; + }); + await runRequest.WaitCompletionAsync(); + + await client.ExitAsync(); + + return 0; + } } } diff --git a/samples/Playground/ServerMode/TestingPlatformClientFactory.cs b/samples/Playground/ServerMode/TestingPlatformClientFactory.cs new file mode 100644 index 0000000000..07333c78b5 --- /dev/null +++ b/samples/Playground/ServerMode/TestingPlatformClientFactory.cs @@ -0,0 +1,502 @@ +// 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.Collections; +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; + +using Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100; + +namespace MSTest.Acceptance.IntegrationTests.Messages.V100; + +public partial /* for codegen regx */ class TestingPlatformClientFactory +{ + private static readonly string Root = RootFinder.Find(); + private static readonly Dictionary DefaultEnvironmentVariables = new() + { + { "DOTNET_ROOT", $"{Root}/.dotnet" }, + { "DOTNET_INSTALL_DIR", $"{Root}/.dotnet" }, + { "DOTNET_SKIP_FIRST_TIME_EXPERIENCE", "1" }, + { "DOTNET_MULTILEVEL_LOOKUP", "0" }, + }; + + public static async Task StartAsServerAndConnectToTheClientAsync(string testApp) + { + var environmentVariables = new Dictionary(DefaultEnvironmentVariables); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + // Skip all unwanted environment variables. + string? key = entry.Key.ToString(); + if (WellKnownEnvironmentVariables.ToSkipEnvironmentVariables.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + environmentVariables[key!] = entry.Value!.ToString()!; + } + + // We expect to not fail for unhandled exception in server mode for IDE needs. + environmentVariables.Add("TESTINGPLATFORM_EXIT_PROCESS_ON_UNHANDLED_EXCEPTION", "0"); + + // To attach to the server on startup + // environmentVariables.Add(EnvironmentVariableConstants.TESTINGPLATFORM_LAUNCH_ATTACH_DEBUGGER, "1"); + TcpListener tcpListener = new(IPAddress.Loopback, 0); + tcpListener.Start(); + StringBuilder builder = new(); + ProcessConfiguration processConfig = new(testApp) + { + OnStandardOutput = (_, output) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnStandardOutput:\n{output}"), + OnErrorOutput = (_, output) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnErrorOutput:\n{output}"), + OnExit = (processHandle, exitCode) => builder.AppendLine(CultureInfo.InvariantCulture, $"OnExit: exit code '{exitCode}'"), + + Arguments = $"--server --client-host localhost --client-port {((IPEndPoint)tcpListener.LocalEndpoint).Port}", + // Arguments = $"--server --client-host localhost --client-port {((IPEndPoint)tcpListener.LocalEndpoint).Port} --diagnostic --diagnostic-verbosity trace", + EnvironmentVariables = environmentVariables, + }; + + IProcessHandle processHandler = ProcessFactory.Start(processConfig, cleanDefaultEnvironmentVariableIfCustomAreProvided: false); + + TcpClient? tcpClient; + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(60)); + try + { + tcpClient = await tcpListener.AcceptTcpClientAsync(cancellationTokenSource.Token); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationTokenSource.Token) + { + throw new OperationCanceledException($"Timeout on connection for command line '{processConfig.FileName} {processConfig.Arguments}'\n{builder}", ex, cancellationTokenSource.Token); + } + + return new TestingPlatformClient(new(tcpClient.GetStream()), tcpClient, processHandler); + } + + public static async Task StartAsServerAndConnectAsync(string testApp, bool enableDiagnostic = false) + { + var environmentVariables = new Dictionary(DefaultEnvironmentVariables); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + // Skip all unwanted environment variables. + string? key = entry.Key.ToString(); + if (WellKnownEnvironmentVariables.ToSkipEnvironmentVariables.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + environmentVariables[key!] = entry.Value!.ToString()!; + } + + // We expect to not fail for unhandled exception in server mode for IDE needs. + environmentVariables.Add("TESTINGPLATFORM_EXIT_PROCESS_ON_UNHANDLED_EXCEPTION", "0"); + + // To attach to the server on startup + // environmentVariables.Add(EnvironmentVariableConstants.TESTINGPLATFORM_LAUNCH_ATTACH_DEBUGGER, "1"); + TaskCompletionSource portFound = new(); + ProcessConfiguration processConfig = new(testApp) + { + OnStandardOutput = + (_, output) => + { + Match m = ParsePort().Match(output); + if (m.Success && int.TryParse(m.Groups[1].Value, out int port)) + { + portFound.SetResult(port); + } + + // Do not remove pls + // NotepadWindow.WriteLine($"[OnStandardOutput] {output}"); + }, + + // Do not remove pls + // OnErrorOutput = (_, output) => NotepadWindow.WriteLine($"[OnErrorOutput] {output}"), + OnErrorOutput = (_, output) => + { + if (!portFound.Task.IsCompleted) + { + try + { + portFound.SetException(new InvalidOperationException(output)); + } + catch (InvalidOperationException) + { + // possible race + } + } + }, + OnExit = (processHandle, exitCode) => + { + if (exitCode != 0) + { + if (portFound.Task.Exception is null && !portFound.Task.IsCompleted) + { + portFound.SetException(new InvalidOperationException($"Port not found during parsing and process exited with code '{exitCode}'")); + } + } + }, + + // OnExit = (_, exitCode) => NotepadWindow.WriteLine($"[OnExit] Process exit code '{exitCode}'"), + Arguments = "--server --diagnostic --diagnostic-verbosity trace", + // Arguments = "--server", + EnvironmentVariables = environmentVariables, + }; + + IProcessHandle processHandler = ProcessFactory.Start(processConfig, cleanDefaultEnvironmentVariableIfCustomAreProvided: false); + await portFound.Task; + + var tcpClient = new TcpClient(); + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(90)); +#pragma warning disable VSTHRD103 // Call async methods when in an async method + await tcpClient.ConnectAsync(new IPEndPoint(IPAddress.Loopback, portFound.Task.Result), cancellationTokenSource.Token); +#pragma warning restore VSTHRD103 // Call async methods when in an async method + return new TestingPlatformClient(new(tcpClient.GetStream()), tcpClient, processHandler, enableDiagnostic); + } + + [GeneratedRegex(@"Starting server. Listening on port '(\d+)'")] + private static partial Regex ParsePort(); +} + +public sealed class ProcessConfiguration +{ + public ProcessConfiguration(string fileName) + { + FileName = fileName; + } + + public string FileName { get; } + + public string? Arguments { get; init; } + + public string? WorkingDirectory { get; init; } + + public IDictionary? EnvironmentVariables { get; init; } + + public Action? OnErrorOutput { get; init; } + + public Action? OnStandardOutput { get; init; } + + public Action? OnExit { get; init; } +} + +public interface IProcessHandle +{ + int Id { get; } + + string ProcessName { get; } + + int ExitCode { get; } + + TextWriter StandardInput { get; } + + TextReader StandardOutput { get; } + + void Dispose(); + + void Kill(); + + Task StopAsync(); + + Task WaitForExitAsync(); + + void WaitForExit(); + + Task WriteInputAsync(string input); +} + +public static class ProcessFactory +{ + public static IProcessHandle Start(ProcessConfiguration config, bool cleanDefaultEnvironmentVariableIfCustomAreProvided = false) + { + string fullPath = config.FileName; // Path.GetFullPath(startInfo.FileName); + string workingDirectory = config.WorkingDirectory + .OrDefault(Path.GetDirectoryName(config.FileName).OrDefault(Directory.GetCurrentDirectory())); + + ProcessStartInfo processStartInfo = new() + { + FileName = fullPath, + Arguments = config.Arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + }; + + if (config.EnvironmentVariables is not null) + { + if (cleanDefaultEnvironmentVariableIfCustomAreProvided) + { + processStartInfo.Environment.Clear(); + processStartInfo.EnvironmentVariables.Clear(); + } + + foreach (KeyValuePair kvp in config.EnvironmentVariables) + { + if (kvp.Value is null) + { + continue; + } + + processStartInfo.EnvironmentVariables[kvp.Key] = kvp.Value; + } + } + + Process process = new() + { + StartInfo = processStartInfo, + EnableRaisingEvents = true, + }; + + // ToolName and Pid are not populated until we start the process, + // and once we stop the process we cannot retrieve the info anymore + // so we start the process, try to grab the needed info and set it. + // And then we give the call reference to ProcessHandle, but not to ProcessHandleInfo + // so they can easily get the info, but cannot change it. + ProcessHandleInfo processHandleInfo = new(); + ProcessHandle processHandle = new(process, processHandleInfo); + + process.Exited += (s, e) => config.OnExit?.Invoke(processHandle, process.ExitCode); + + if (config.OnStandardOutput != null) + { + process.OutputDataReceived += (s, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + config.OnStandardOutput(processHandle, e.Data); + } + }; + } + + if (config.OnErrorOutput != null) + { + process.ErrorDataReceived += (s, e) => + { + if (!string.IsNullOrWhiteSpace(e.Data)) + { + config.OnErrorOutput(processHandle, e.Data); + } + }; + } + + if (!process.Start()) + { + throw new InvalidOperationException("Process failed to start"); + } + + try + { + processHandleInfo.ProcessName = process.ProcessName; + } + catch (InvalidOperationException) + { + // The associated process has exited. + // https://learn.microsoft.com/dotnet/api/system.diagnostics.process.processname?view=net-7.0 + } + + processHandleInfo.Id = process.Id; + + if (config.OnStandardOutput != null) + { + process.BeginOutputReadLine(); + } + + if (config.OnErrorOutput != null) + { + process.BeginErrorReadLine(); + } + + return processHandle; + } +} + +public sealed class ProcessHandleInfo +{ + public string? ProcessName { get; internal set; } + + public int Id { get; internal set; } +} + +public sealed class ProcessHandle : IProcessHandle, IDisposable +{ + private readonly ProcessHandleInfo _processHandleInfo; + private readonly Process _process; + private bool _disposed; + private int _exitCode; + + internal ProcessHandle(Process process, ProcessHandleInfo processHandleInfo) + { + _processHandleInfo = processHandleInfo; + _process = process; + } + + public string ProcessName => _processHandleInfo.ProcessName ?? ""; + + public int Id => _processHandleInfo.Id; + + public TextWriter StandardInput => _process.StandardInput; + + public TextReader StandardOutput => _process.StandardOutput; + + public int ExitCode => _process.ExitCode; + + public async Task WaitForExitAsync() + { + if (_disposed) + { + return _exitCode; + } +#if NETCOREAPP + await _process.WaitForExitAsync(); +#else + _process.WaitForExit(); +#endif + return await Task.FromResult(_process.ExitCode); + } + + public void WaitForExit() => _process.WaitForExit(); + + public async Task StopAsync() + { + if (_disposed) + { + return _exitCode; + } + + KillSafe(_process); + return await WaitForExitAsync(); + } + + public void Kill() + { + if (_disposed) + { + return; + } + + KillSafe(_process); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_process) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + KillSafe(_process); + _process.WaitForExit(); + _exitCode = _process.ExitCode; + _process.Dispose(); + } + + public async Task WriteInputAsync(string input) + { + await _process.StandardInput.WriteLineAsync(input); + await _process.StandardInput.FlushAsync(); + } + + private static void KillSafe(Process process) + { + try + { +#if NETCOREAPP + process.Kill(true); +#else + process.Kill(); +#endif + } + catch (InvalidOperationException) + { + } + catch (NotSupportedException) + { + } + } +} + +public static class StringExtensions +{ + // Double checking that is is not null on purpose. + public static string OrDefault(this string? value, string defaultValue) => string.IsNullOrEmpty(defaultValue) + ? throw new ArgumentNullException(nameof(defaultValue)) + : !string.IsNullOrWhiteSpace(value) + ? value! + : defaultValue; +} + +public static class WellKnownEnvironmentVariables +{ + public static readonly string[] ToSkipEnvironmentVariables = + [ + // Skip dotnet root, we redefine it below. + "DOTNET_ROOT", + + // Skip all environment variables related to minidump functionality. + // https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/xplat-minidump-generation.md + "DOTNET_DbgEnableMiniDump", + "DOTNET_DbgMiniDumpName", + "DOTNET_CreateDumpDiagnostics", + "DOTNET_CreateDumpVerboseDiagnostics", + "DOTNET_CreateDumpLogToFile", + "DOTNET_EnableCrashReport", + "DOTNET_EnableCrashReportOnly", + + // Old syntax for the minidump functionality. + "COMPlus_DbgEnableMiniDump", + "COMPlus_DbgEnableElfDumpOnMacOS", + "COMPlus_DbgMiniDumpName", + "COMPlus_DbgMiniDumpType", + + // Hot reload mode + "TESTINGPLATFORM_HOTRELOAD_ENABLED", + + // Telemetry + // By default arcade set this environment variable + "DOTNET_CLI_TELEMETRY_OPTOUT", + "TESTINGPLATFORM_TELEMETRY_OPTOUT", + "DOTNET_NOLOGO", + "TESTINGPLATFORM_NOBANNER", + + // Diagnostics + "TESTINGPLATFORM_DIAGNOSTIC", + + // Isolate from the skip banner in case of parent, children tests + "TESTINGPLATFORM_CONSOLEOUTPUTDEVICE_SKIP_BANNER" + ]; +} + +public static class RootFinder +{ + public static string Find() + { + string path = AppContext.BaseDirectory; + string dir = path; + while (Directory.GetDirectoryRoot(dir) != dir) + { + if (Directory.Exists(Path.Combine(dir, ".git"))) + { + return dir; + } + else + { + dir = Directory.GetParent(dir)!.ToString(); + } + } + + throw new InvalidOperationException($"Could not find solution root, .git not found in {path} or any parent directory."); + } +} diff --git a/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs b/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs new file mode 100644 index 0000000000..5cab289e33 --- /dev/null +++ b/samples/Playground/ServerMode/v1.0.0/TestingPlatformClient.cs @@ -0,0 +1,264 @@ +// 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.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; + +using MSTest.Acceptance.IntegrationTests.Messages.V100; + +using StreamJsonRpc; + +using AttachDebuggerInfo = MSTest.Acceptance.IntegrationTests.Messages.V100.AttachDebuggerInfo; + +namespace Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100; + +public sealed class TestingPlatformClient : IDisposable +{ + private readonly TcpClient _tcpClient = new(); + private readonly IProcessHandle _processHandler; + private readonly TargetHandler _targetHandler = new(); + private readonly StringBuilder _disconnectionReason = new(); + + public TestingPlatformClient(JsonRpc jsonRpc, TcpClient tcpClient, IProcessHandle processHandler, bool enableDiagnostic = false) + { + JsonRpcClient = jsonRpc; + _tcpClient = tcpClient; + _processHandler = processHandler; + JsonRpcClient.AddLocalRpcTarget( + _targetHandler, + new JsonRpcTargetOptions + { + MethodNameTransform = CommonMethodNameTransforms.CamelCase, + }); + + if (enableDiagnostic) + { + JsonRpcClient.TraceSource.Switch.Level = SourceLevels.All; + JsonRpcClient.TraceSource.Listeners.Add(new ConsoleRpcListener()); + } + + JsonRpcClient.Disconnected += JsonRpcClient_Disconnected; + JsonRpcClient.StartListening(); + } + + private void JsonRpcClient_Disconnected(object? sender, JsonRpcDisconnectedEventArgs e) + { + _disconnectionReason.AppendLine("Disconnected reason:"); + _disconnectionReason.AppendLine(e.Reason.ToString()); + _disconnectionReason.AppendLine(e.Description); + _disconnectionReason.AppendLine(e.Exception?.ToString()); + } + + public int ExitCode => _processHandler.ExitCode; + + public async Task WaitServerProcessExitAsync() + { + await _processHandler.WaitForExitAsync(); + return _processHandler.ExitCode; + } + + public JsonRpc JsonRpcClient { get; } + + private async Task CheckedInvokeAsync(Func func) + { + try + { + await func(); + } + catch (Exception ex) + { + if (_disconnectionReason.Length > 0) + { + throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex); + } + + throw; + } + } + + private async Task CheckedInvokeAsync(Func> func, bool @checked = true) + { + try + { + return await func(); + } + catch (Exception ex) + { + if (@checked) + { + if (_disconnectionReason.Length > 0) + { + throw new InvalidOperationException($"{ex.Message}\n{_disconnectionReason}", ex); + } + + throw; + } + } + + return default!; + } + + public void RegisterLogListener(LogsCollector listener) + => _targetHandler.RegisterLogListener(listener); + + public void RegisterTelemetryListener(TelemetryCollector listener) + => _targetHandler.RegisterTelemetryListener(listener); + + public async Task InitializeAsync() + { + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); + return await CheckedInvokeAsync(async () => await JsonRpcClient.InvokeWithParameterObjectAsync( + "initialize", + new InitializeRequest(Environment.ProcessId, new V100.ClientInfo("test-client"), + new MSTest.Acceptance.IntegrationTests.Messages.V100.ClientCapabilities(new MSTest.Acceptance.IntegrationTests.Messages.V100.ClientTestingCapabilities(DebuggerProvider: false))), cancellationToken: cancellationTokenSource.Token)); + } + + public async Task ExitAsync(bool gracefully = true) + { + if (gracefully) + { + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); + await CheckedInvokeAsync(async () => await JsonRpcClient.NotifyWithParameterObjectAsync("exit", new object())); + } + else + { + _tcpClient.Dispose(); + } + } + + public async Task DiscoverTestsAsync(Guid requestId, Func action, bool @checked = true) + => await CheckedInvokeAsync( + async () => + { + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); + var discoveryListener = new TestNodeUpdatesResponseListener(requestId, action); + _targetHandler.RegisterResponseListener(discoveryListener); + await JsonRpcClient.InvokeWithParameterObjectAsync("testing/discoverTests", new DiscoveryRequest(RunId: requestId), cancellationToken: cancellationTokenSource.Token); + return discoveryListener; + }, @checked); + + public async Task RunTestsAsync(Guid requestId, Func action) + => await CheckedInvokeAsync(async () => + { + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); + var runListener = new TestNodeUpdatesResponseListener(requestId, action); + _targetHandler.RegisterResponseListener(runListener); + await JsonRpcClient.InvokeWithParameterObjectAsync("testing/runTests", new RunRequest(RunId: requestId, TestCases: null), cancellationToken: cancellationTokenSource.Token); + return runListener; + }); + + public async Task RunTestsAsync(Guid requestId, TestNode[] filter, Func action) + => await CheckedInvokeAsync(async () => + { + using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(3)); + var runListener = new TestNodeUpdatesResponseListener(requestId, action); + _targetHandler.RegisterResponseListener(runListener); + await JsonRpcClient.InvokeWithParameterObjectAsync("testing/runTests", new RunRequest(TestCases: filter, RunId: requestId), cancellationToken: cancellationTokenSource.Token); + return runListener; + }); + + public void Dispose() + { + JsonRpcClient.Dispose(); + _tcpClient.Dispose(); + _processHandler.WaitForExit(); + _processHandler.Dispose(); + } + + public record Log(LogLevel LogLevel, string Message); + + private sealed class TargetHandler + { + private readonly ConcurrentDictionary _listeners + = new(); + + private readonly ConcurrentBag _logListeners + = new(); + + private readonly ConcurrentBag _telemetryPayloads + = new(); + + public void RegisterTelemetryListener(TelemetryCollector listener) + => _telemetryPayloads.Add(listener); + + public void RegisterLogListener(LogsCollector listener) + => _logListeners.Add(listener); + + public void RegisterResponseListener(ResponseListener responseListener) + => _ = _listeners.TryAdd(responseListener.RequestId, responseListener); + + [JsonRpcMethod("client/attachDebugger", UseSingleObjectParameterDeserialization = true)] + public static Task AttachDebuggerAsync(AttachDebuggerInfo attachDebuggerInfo) => throw new NotImplementedException(); + + [JsonRpcMethod("testing/testUpdates/tests")] + public async Task TestsUpdateAsync(Guid runId, TestNodeUpdate[]? changes) + { + if (_listeners.TryGetValue(runId, out ResponseListener? responseListener)) + { + if (changes is null) + { + responseListener.Complete(); + _listeners.TryRemove(runId, out _); + return; + } + else + { + await responseListener.OnMessageReceiveAsync(changes); + } + } + } + + [JsonRpcMethod("telemetry/update", UseSingleObjectParameterDeserialization = true)] + public Task TelemetryAsync(Microsoft.Testing.Platform.ServerMode.IntegrationTests.Messages.V100.TelemetryPayload telemetry) + { + foreach (TelemetryCollector listener in _telemetryPayloads) + { + listener.Add(telemetry); + } + + return Task.CompletedTask; + } + + [JsonRpcMethod("client/log")] + public Task LogAsync(LogLevel level, string message) + { + foreach (LogsCollector listener in _logListeners) + { + listener.Add(new(level, message)); + } + + return Task.CompletedTask; + } + } +} + +public abstract class ResponseListener +{ + private readonly TaskCompletionSource _allMessageReceived = new(); + + public Guid RequestId { get; set; } + + protected ResponseListener(Guid requestId) => RequestId = requestId; + + public abstract Task OnMessageReceiveAsync(object message); + + internal void Complete() => _allMessageReceived.SetResult(); + + public Task WaitCompletionAsync() => _allMessageReceived.Task; +} + +public sealed class TestNodeUpdatesResponseListener : ResponseListener +{ + private readonly Func _action; + + public TestNodeUpdatesResponseListener(Guid requestId, Func action) + : base(requestId) + { + _action = action; + } + + public override async Task OnMessageReceiveAsync(object message) + => await _action((TestNodeUpdate[])message); +} diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs index 94d17f2d49..0816a71e87 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs @@ -55,6 +55,7 @@ public async Task RunAsync() { await DisposeServiceProviderAsync(ServiceProvider, isProcessShutdown: true); await DisposeHelper.DisposeAsync(ServiceProvider.GetService()); + await DisposeHelper.DisposeAsync(ServiceProvider.GetTestApplicationCancellationTokenSource()); } return exitCode; @@ -165,6 +166,13 @@ protected static async Task DisposeServiceProviderAsync(ServiceProvider serviceP continue; } + // The ITestApplicationCancellationTokenSource contains the cancellation token and can be used by other services during the shutdown + // we will collect manually in the correct moment. + if (service is ITestApplicationCancellationTokenSource) + { + continue; + } + if (filter is not null && !filter(service)) { continue; @@ -173,7 +181,6 @@ protected static async Task DisposeServiceProviderAsync(ServiceProvider serviceP // We need to ensure that we won't dispose special services till the shutdown if (!isProcessShutdown && service is ITelemetryCollector or - ITestApplicationCancellationTokenSource or ITestApplicationLifecycleCallbacks) { continue; diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs index 481d371c3d..98e6b9b52a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/ServerTestHost.cs @@ -682,6 +682,9 @@ await SendMessageAsync( method: JsonRpcMethods.ClientLog, @params: new LogEventArgs(logMessage), _testApplicationCancellationTokenSource.CancellationToken, + + // We could receive some log messages after the exit, a real sample is if telemetry provider is too slow and we log a warning. + checkServerExit: true, rethrowException: false); break; }