diff --git a/docs/Changelog-Platform.md b/docs/Changelog-Platform.md index 8ec27f4d0d..0d16519903 100644 --- a/docs/Changelog-Platform.md +++ b/docs/Changelog-Platform.md @@ -34,6 +34,7 @@ See full log [of v4.2.3...v4.3.0](https://github.com/microsoft/testfx/compare/v4 * Add --progress {auto|on|off} and deprecate --no-progress in MTP terminal reporter by @Evangelink in [#9145](https://github.com/microsoft/testfx/pull/9145) * Add silence-driven progress heartbeat for SimpleAnsi/NoAnsi output modes by @Evangelink in [#9147](https://github.com/microsoft/testfx/pull/9147) * Add Azure DevOps per-assembly log groups by @Evangelink in [#9177](https://github.com/microsoft/testfx/pull/9177) +* Handshake from the test host orchestrator in the dotnet test pipe protocol, advertising the orchestration feature (e.g. retry) by @Copilot in [#9215](https://github.com/microsoft/testfx/pull/9215) ### Fixed diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj index 1cd7b3e618..0a25d33170 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxCommandLine.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxCommandLine.cs index 6f6d58cf9e..f191e73eeb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxCommandLine.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxCommandLine.cs @@ -2,13 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.TrxReport.Resources; -using Microsoft.Testing.Platform.CommandLine; -using Microsoft.Testing.Platform.Extensions; -using Microsoft.Testing.Platform.Extensions.CommandLine; namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; -internal sealed class TrxReportGeneratorCommandLine : CommandLineOptionsProviderBase +internal sealed class TrxReportGeneratorCommandLine : ReportGeneratorCommandLineBase { public const string TrxReportOptionName = "report-trx"; public const string TrxReportFileNameOptionName = "report-trx-filename"; @@ -16,32 +13,18 @@ internal sealed class TrxReportGeneratorCommandLine : CommandLineOptionsProvider public TrxReportGeneratorCommandLine() : base( nameof(TrxReportGeneratorCommandLine), - ExtensionVersion.DefaultSemVer, ExtensionResources.TrxReportGeneratorDisplayName, ExtensionResources.TrxReportGeneratorDescription, - [ - new(TrxReportOptionName, ExtensionResources.TrxReportOptionDescription, ArgumentArity.Zero, false), - new(TrxReportFileNameOptionName, ExtensionResources.TrxReportFileNameOptionDescription, ArgumentArity.ExactlyOne, false) - ]) - { - } - - public override Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) - => commandOption.Name == TrxReportFileNameOptionName - ? ReportFileNameValidator.ValidateReportFileNameArgumentAsync( - arguments, - ".trx", - ExtensionResources.TrxReportFileNameMustNotBeEmpty, - ExtensionResources.TrxReportFileNameExtensionIsNotTrx, - ExtensionResources.TrxReportFileNameRelativePathMustStayUnderResultsDirectory) - : ValidationResult.ValidTask; - - public override Task ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions) - => ReportFileNameValidator.ValidateReportCommandLineOptionsAsync( - commandLineOptions, TrxReportOptionName, TrxReportFileNameOptionName, + ExtensionResources.TrxReportOptionDescription, + ExtensionResources.TrxReportFileNameOptionDescription, + ".trx", + ExtensionResources.TrxReportFileNameMustNotBeEmpty, + ExtensionResources.TrxReportFileNameExtensionIsNotTrx, + ExtensionResources.TrxReportFileNameRelativePathMustStayUnderResultsDirectory, ExtensionResources.TrxReportFileNameRequiresTrxReport, - ExtensionResources.TrxReportIsNotValidForDiscovery, - PlatformCommandLineProvider.DiscoverTestsOptionKey); + ExtensionResources.TrxReportIsNotValidForDiscovery) + { + } } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs index ba69cf06a0..543d6a8e9f 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/CommonTestHost.cs @@ -5,6 +5,7 @@ using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.Extensions.TestHost; using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.IPC; using Microsoft.Testing.Platform.Logging; using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.OutputDevice; @@ -111,15 +112,17 @@ public async Task RunAsync() protected virtual string HostType => this switch { - ConsoleTestHost => "TestHost", - TestHostControllersTestHost => "TestHostController", - ServerTestHost => "ServerTestHost", + ConsoleTestHost => HandshakeMessageHostTypes.TestHost, + TestHostControllersTestHost => HandshakeMessageHostTypes.TestHostController, + ServerTestHost => HandshakeMessageHostTypes.ServerTestHost, _ => throw new InvalidOperationException($"Unknown host type '{GetType().FullName}'"), }; private string GetHostType() { - // For now, we don't inherit TestHostOrchestratorHost from CommonHost one so we don't connect when we orchestrate + // TestHostOrchestratorHost does not inherit from CommonHost, so the orchestrator handshake is + // performed there directly (see TestHostOrchestratorHost.RunAsync) rather than going through + // this path. This method only covers the test host and test host controller roles. string hostType = HostType; return hostType; } diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Modes.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Modes.cs index 4dc15614b9..b668a58f0b 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Modes.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.Modes.cs @@ -54,7 +54,7 @@ private async Task BuildHostAsync(BuildContext context) // detect mismatches such as --help being injected via RunArguments during a // normal run. The handshake is needed for the SDK to know that the test host // is in help mode, so it can ignore any incoming CommandLineOptionMessages. - bool isProtocolCompatible = await pushOnlyProtocol.IsCompatibleProtocolAsync("TestHost").ConfigureAwait(false); + bool isProtocolCompatible = await pushOnlyProtocol.IsCompatibleProtocolAsync(HandshakeMessageHostTypes.TestHost).ConfigureAwait(false); if (!isProtocolCompatible) { CompleteBuilderActivity(context.BuilderActivity, nameof(InformativeCommandLineHost)); diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostOchestratorHost.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostOchestratorHost.cs index ee2c567892..a8e63451ba 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostOchestratorHost.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostOchestratorHost.cs @@ -3,7 +3,9 @@ using Microsoft.Testing.Platform.Extensions.TestHostOrchestrator; using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.IPC; using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.ServerMode; using Microsoft.Testing.Platform.Services; using Microsoft.Testing.Platform.Telemetry; using Microsoft.Testing.Platform.TestHostOrchestrator; @@ -26,6 +28,26 @@ public async Task RunAsync() ITestHostExecutionOrchestrator testHostOrchestrator = _testHostOrchestratorConfiguration.TestHostOrchestrators[0]; ITestApplicationCancellationTokenSource applicationCancellationToken = _serviceProvider.GetTestApplicationCancellationTokenSource(); + + // When connected to dotnet test through the pipe protocol, handshake from the orchestrator too + // (test hosts and test host controllers already do). This lets the SDK know that an orchestrator + // (e.g. retry) is participating in the run, identified by the OrchestratorFeature property. + IPushOnlyProtocol? pushOnlyProtocol = _serviceProvider.GetService(); + if (pushOnlyProtocol is { IsServerMode: true }) + { + Dictionary additionalHandshakeProperties = new() + { + [HandshakeMessagePropertyNames.OrchestratorFeature] = testHostOrchestrator.Uid, + }; + + bool isProtocolCompatible = await pushOnlyProtocol.IsCompatibleProtocolAsync( + HandshakeMessageHostTypes.TestHostOrchestrator, additionalHandshakeProperties).ConfigureAwait(false); + if (!isProtocolCompatible) + { + return (int)ExitCode.IncompatibleProtocolVersion; + } + } + int exitCode; await logger.LogInformationAsync($"Running test orchestrator '{testHostOrchestrator.Uid}'").ConfigureAwait(false); try diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs index b81017e268..009f9077ee 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs @@ -87,12 +87,12 @@ public async Task HelpInvokedAsync() public bool IsIDE { get; private set; } - public async Task IsCompatibleProtocolAsync(string hostType) + public async Task IsCompatibleProtocolAsync(string hostType, IReadOnlyDictionary? additionalHandshakeProperties = null) { RoslynDebug.Assert(_dotnetTestPipeClient is not null); string supportedProtocolVersions = ProtocolConstants.Version; - HandshakeMessage handshakeMessage = new(new Dictionary + Dictionary properties = new() { { HandshakeMessagePropertyNames.PID, _environment.ProcessId.ToString(CultureInfo.InvariantCulture) }, { HandshakeMessagePropertyNames.Architecture, RuntimeInformation.ProcessArchitecture.ToString() }, @@ -104,7 +104,17 @@ public async Task IsCompatibleProtocolAsync(string hostType) { HandshakeMessagePropertyNames.ExecutionId, _environment.GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID) ?? string.Empty }, { HandshakeMessagePropertyNames.InstanceId, InstanceId }, { HandshakeMessagePropertyNames.ExecutionMode, GetExecutionMode() }, - }); + }; + + if (additionalHandshakeProperties is not null) + { + foreach (KeyValuePair property in additionalHandshakeProperties) + { + properties[property.Key] = property.Value; + } + } + + HandshakeMessage handshakeMessage = new(properties); HandshakeMessage response = await _dotnetTestPipeClient.RequestReplyAsync(handshakeMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs index becd021416..ab38cf7280 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs @@ -39,6 +39,28 @@ internal static class HandshakeMessagePropertyNames // as a help/list-tests option leaking from RunArguments into a normal run. // Values come from HandshakeMessageExecutionModes. internal const byte ExecutionMode = 10; + + // Identifies the orchestration feature responsible for the orchestrator host + // (e.g. "retry"). Only sent by the test host orchestrator so consumers (e.g. + // dotnet test in the SDK) can understand why an orchestrator is participating + // in the run. The value is the orchestrator extension Uid. + internal const byte OrchestratorFeature = 11; +} + +internal static class HandshakeMessageHostTypes +{ + // A regular (console or server) test host that actually runs tests. + internal const string TestHost = "TestHost"; + + // A test host controller process that restarts/monitors the test host. + internal const string TestHostController = "TestHostController"; + + // A test host running in server (JSON-RPC) mode. + internal const string ServerTestHost = "ServerTestHost"; + + // A test host orchestrator process (e.g. the retry orchestrator) that drives + // one or more test host executions. + internal const string TestHostOrchestrator = "TestHostOrchestrator"; } internal static class HandshakeMessageExecutionModes diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/IPushOnlyProtocol.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/IPushOnlyProtocol.cs index 33a6c79bdf..e5c6173245 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/IPushOnlyProtocol.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/IPushOnlyProtocol.cs @@ -11,7 +11,7 @@ internal interface IPushOnlyProtocol : IDisposable Task HelpInvokedAsync(); - Task IsCompatibleProtocolAsync(string testHostType); + Task IsCompatibleProtocolAsync(string testHostType, IReadOnlyDictionary? additionalHandshakeProperties = null); Task GetDataConsumerAsync(); diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeOrchestratorHandshakeTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeOrchestratorHandshakeTests.cs new file mode 100644 index 0000000000..c7242ae49a --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeOrchestratorHandshakeTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests.DotnetTestPipe; + +/// +/// Verifies that, under the --server dotnettestcli pipe protocol, the test host orchestrator +/// (here the retry orchestrator) performs the handshake as well — not only the test hosts it spawns. +/// This locks down microsoft/testfx#6179: +/// the orchestrator handshakes with HostType=TestHostOrchestrator and advertises the +/// orchestration feature responsible for the run via the OrchestratorFeature property. +/// +[TestClass] +public class DotnetTestPipeOrchestratorHandshakeTests : AcceptanceTestBase +{ + private const string AssetName = "DotnetTestPipeOrchestratorHandshake"; + + // Mirrors HandshakeMessageHostTypes.TestHostOrchestrator on the testfx side. Kept as a literal + // here so the black-box harness stays decoupled from internal testfx types. + private const string TestHostOrchestratorHostType = "TestHostOrchestrator"; + private const string TestHostHostType = "TestHost"; + + // The retry orchestrator extension Uid, sent as the OrchestratorFeature value. + private const string RetryOrchestratorFeature = "RetryOrchestrator"; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task DotnetTestPipe_Orchestrator_HandshakesWithFeature() + { + var testHost = TestInfrastructure.TestHost.LocateFrom( + AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + string resultDirectory = Path.Combine(testHost.DirectoryName, Guid.NewGuid().ToString("N")); + + FakeDotnetTestSdkMultiConnectionResult result = await FakeDotnetTestSdkMultiConnection.RunAsync( + testHost, + extraArguments: $"--retry-failed-tests 3 --results-directory {resultDirectory}", + environmentVariables: new() { { EnvironmentVariableConstants.TESTINGPLATFORM_TELEMETRY_OPTOUT, "1" } }, + cancellationToken: TestContext.CancellationToken); + + // The orchestrator process and the single (passing) test host it spawned should both have + // connected and handshaked. + Dictionary orchestratorHandshake = Assert.ContainsSingle(result.HandshakesWithHostType(TestHostOrchestratorHostType)); + + Assert.IsTrue( + orchestratorHandshake.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.OrchestratorFeature, out string? feature), + "Orchestrator handshake is missing the OrchestratorFeature property."); + Assert.AreEqual(RetryOrchestratorFeature, feature); + + // The actual test host (spawned by the orchestrator) handshakes as a plain TestHost and must + // NOT carry an orchestrator feature. + Dictionary testHostHandshake = Assert.ContainsSingle(result.HandshakesWithHostType(TestHostHostType)); + Assert.IsFalse( + testHostHandshake.ContainsKey(DotnetTestPipeProtocol.HandshakeProperties.OrchestratorFeature), + "Test host handshake should not carry the OrchestratorFeature property."); + } + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + private const string AssetCode = """ +#file DotnetTestPipeOrchestratorHandshake.csproj + + + $TargetFrameworks$ + enable + enable + Exe + true + preview + + + + + + + + +#file Program.cs +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.TestFramework; + +public class Program +{ + public static async Task Main(string[] args) + { + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); + builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestFramework()); + builder.AddRetryProvider(); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + } +} + +public class DummyTestFramework : ITestFramework +{ + public string Uid => nameof(DummyTestFramework); + public string Version => "2.0.0"; + public string DisplayName => nameof(DummyTestFramework); + public string Description => nameof(DummyTestFramework); + public Task IsEnabledAsync() => Task.FromResult(true); + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + public Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + return Task.CompletedTask; + } +} +"""; + + public string TargetAssetPath => GetAssetPath(AssetName); + + public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName, + AssetCode + .PatchTargetFrameworks(TargetFrameworks.NetCurrent) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + } +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs index f6ca6151e0..df37adba03 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs @@ -55,6 +55,7 @@ public static class HandshakeProperties public const byte InstanceId = 8; public const byte IsIDE = 9; public const byte ExecutionMode = 10; + public const byte OrchestratorFeature = 11; } public static class SessionEventTypes diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs index fb98303e30..47f524f8c0 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs @@ -104,7 +104,7 @@ public static async Task RunAsync( /// also in . Returns if none /// match. /// - private static string SelectHighestMutuallySupportedVersion(Dictionary handshakeProperties, string sdkSupportedVersions) + internal static string SelectHighestMutuallySupportedVersion(Dictionary handshakeProperties, string sdkSupportedVersions) { if (!handshakeProperties.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.SupportedProtocolVersions, out string? appVersions) || string.IsNullOrWhiteSpace(appVersions)) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkMultiConnection.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkMultiConnection.cs new file mode 100644 index 0000000000..3c46223c6d --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkMultiConnection.cs @@ -0,0 +1,212 @@ +// 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.Globalization; +using System.IO.Pipes; +using System.Runtime.InteropServices; + +namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests.DotnetTestPipe; + +/// +/// Multi-connection variant of . The single-connection harness can +/// only service one peer, which is enough for a plain test host run. Scenarios that involve an +/// orchestrator (e.g. --retry-failed-tests) produce several peers on the same pipe: the +/// orchestrator process itself plus every test host process it spawns. This harness mirrors the +/// real SDK's accept loop (see TestApplication.WaitConnectionAsync) by continuously +/// accepting new instances and servicing each on its own task, +/// performing the same handshake negotiation on every connection. +/// +internal static class FakeDotnetTestSdkMultiConnection +{ + /// + /// Spins up a fake SDK pipe server that accepts multiple connections, runs + /// against it, and returns every handshake observed across all + /// connections together with the process-level result. + /// + public static async Task RunAsync( + global::Microsoft.Testing.TestInfrastructure.TestHost testHost, + string extraArguments, + Dictionary? environmentVariables = null, + string supportedProtocolVersions = FakeDotnetTestSdk.DefaultSupportedProtocolVersions, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(testHost); + + string pipeId = Guid.NewGuid().ToString("N"); + string osPipeName = DotnetTestPipeProtocol.GetPipeName(pipeId); + + var receivedHandshakes = new ConcurrentBag>(); + var connectionTasks = new ConcurrentBag(); + + using var acceptLoopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task acceptLoop = AcceptConnectionsAsync(osPipeName, supportedProtocolVersions, receivedHandshakes, connectionTasks, acceptLoopCts.Token); + + string pipeArgs = $"--server dotnettestcli --dotnet-test-pipe {osPipeName}"; + string finalArgs = $"{pipeArgs} {extraArguments}"; + TestHostResult hostResult = await testHost.ExecuteAsync(finalArgs, environmentVariables, cancellationToken: cancellationToken).ConfigureAwait(false); + + // The test app (and any child processes it spawned) has exited, so no further connections + // will be made. Stop accepting and let the in-flight connection handlers drain. + await acceptLoopCts.CancelAsync().ConfigureAwait(false); + await SwallowCancellationAsync(acceptLoop).ConfigureAwait(false); + foreach (Task connectionTask in connectionTasks) + { + await SwallowCancellationAsync(connectionTask).ConfigureAwait(false); + } + + return new FakeDotnetTestSdkMultiConnectionResult(hostResult, [.. receivedHandshakes]); + } + + private static async Task AcceptConnectionsAsync( + string osPipeName, + string supportedProtocolVersions, + ConcurrentBag> receivedHandshakes, + ConcurrentBag connectionTasks, + CancellationToken cancellationToken) + { + PipeOptions options = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var connectionEstablished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Each connection's NamedPipeServerStream is created, awaited, used and disposed + // entirely within ServeConnectionAsync (via await using), so a single method owns + // its whole lifetime and it never leaks on the cancellation path. + connectionTasks.Add(ServeConnectionAsync(osPipeName, options, supportedProtocolVersions, receivedHandshakes, connectionEstablished, cancellationToken)); + + // Throttle the accept loop to one outstanding listener at a time (mirrors the real + // SDK's serial accept loop): only create the next server instance after the current + // one has connected. If teardown cancels us first, the TCS is canceled and we exit. + await connectionEstablished.Task.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected: we cancel the accept loop once the test app process exits. + } + } + + private static async Task ServeConnectionAsync( + string osPipeName, + PipeOptions options, + string supportedProtocolVersions, + ConcurrentBag> receivedHandshakes, + TaskCompletionSource connectionEstablished, + CancellationToken cancellationToken) + { + await using NamedPipeServerStream stream = new( + osPipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + options); + + try + { + await stream.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Teardown canceled us before a peer connected; unblock the accept loop and stop. + connectionEstablished.TrySetCanceled(cancellationToken); + return; + } + catch (Exception ex) + { + // Surface unexpected accept failures to the loop instead of leaving it waiting forever. + connectionEstablished.TrySetException(ex); + return; + } + + // Let the accept loop create the next listener now that this one has connected. + connectionEstablished.TrySetResult(); + + await HandleConnectionAsync(stream, supportedProtocolVersions, receivedHandshakes, cancellationToken).ConfigureAwait(false); + } + + private static async Task HandleConnectionAsync( + NamedPipeServerStream stream, + string supportedProtocolVersions, + ConcurrentBag> receivedHandshakes, + CancellationToken cancellationToken) + { + try + { + while (true) + { + RawMessage? frame = await DotnetTestPipeProtocol.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false); + if (frame is null) + { + break; + } + + if (frame.SerializerId == DotnetTestPipeProtocol.SerializerIds.HandshakeMessage) + { + Dictionary handshake = DotnetTestPipeProtocol.DecodeHandshakeBody(frame.Body); + receivedHandshakes.Add(handshake); + + string selected = FakeDotnetTestSdk.SelectHighestMutuallySupportedVersion(handshake, supportedProtocolVersions); + + byte[] replyBody = DotnetTestPipeProtocol.EncodeHandshakeBody(BuildSdkHandshakeReply(selected)); + await DotnetTestPipeProtocol.WriteFrameAsync(stream, DotnetTestPipeProtocol.SerializerIds.HandshakeMessage, replyBody, cancellationToken).ConfigureAwait(false); + } + else + { + await DotnetTestPipeProtocol.WriteFrameAsync(stream, DotnetTestPipeProtocol.SerializerIds.VoidResponse, body: [], cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Expected when the harness is torn down. + } + catch (IOException) + { + // The peer disconnected abruptly; nothing more to read on this connection. + } + } + + private static Dictionary BuildSdkHandshakeReply(string selectedVersion) + => new(capacity: 5) + { + { DotnetTestPipeProtocol.HandshakeProperties.PID, Environment.ProcessId.ToString(CultureInfo.InvariantCulture) }, + { DotnetTestPipeProtocol.HandshakeProperties.Architecture, RuntimeInformation.ProcessArchitecture.ToString() }, + { DotnetTestPipeProtocol.HandshakeProperties.Framework, RuntimeInformation.FrameworkDescription }, + { DotnetTestPipeProtocol.HandshakeProperties.OS, RuntimeInformation.OSDescription }, + { DotnetTestPipeProtocol.HandshakeProperties.SupportedProtocolVersions, selectedVersion }, + }; + + private static async Task SwallowCancellationAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected during teardown. + } + } +} + +/// Captures the handshakes observed across all pipe connections plus the process result. +internal sealed class FakeDotnetTestSdkMultiConnectionResult +{ + public FakeDotnetTestSdkMultiConnectionResult(TestHostResult testHostResult, IReadOnlyList> receivedHandshakes) + { + TestHostResult = testHostResult; + ReceivedHandshakes = receivedHandshakes; + } + + public TestHostResult TestHostResult { get; } + + public IReadOnlyList> ReceivedHandshakes { get; } + + public IEnumerable> HandshakesWithHostType(string hostType) + => ReceivedHandshakes.Where(h => h.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.HostType, out string? value) && value == hostType); +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs index b7405d6451..c48007ff3e 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs @@ -173,6 +173,7 @@ public void HandshakeMessagePropertyNames_ValuesAreStable() { HandshakeMessagePropertyNames.InstanceId, nameof(HandshakeMessagePropertyNames.InstanceId) }, { HandshakeMessagePropertyNames.IsIDE, nameof(HandshakeMessagePropertyNames.IsIDE) }, { HandshakeMessagePropertyNames.ExecutionMode, nameof(HandshakeMessagePropertyNames.ExecutionMode) }, + { HandshakeMessagePropertyNames.OrchestratorFeature, nameof(HandshakeMessagePropertyNames.OrchestratorFeature) }, }; Assert.AreEqual(nameof(HandshakeMessagePropertyNames.PID), properties[0]); @@ -186,6 +187,7 @@ public void HandshakeMessagePropertyNames_ValuesAreStable() Assert.AreEqual(nameof(HandshakeMessagePropertyNames.InstanceId), properties[8]); Assert.AreEqual(nameof(HandshakeMessagePropertyNames.IsIDE), properties[9]); Assert.AreEqual(nameof(HandshakeMessagePropertyNames.ExecutionMode), properties[10]); + Assert.AreEqual(nameof(HandshakeMessagePropertyNames.OrchestratorFeature), properties[11]); } // The HandshakeMessageExecutionModes string values flow over IPC to