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