From 1c22f3bfd0cfe21ce07b5440a9179e10b6f0a895 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 9 Sep 2026 20:25:48 +0200 Subject: [PATCH 1/7] Handle test host controller pipe timeouts Related to #4774 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ostControllersTestHost.ProcessLifecycle.cs | 100 +++++++++++++++++- .../TestApplicationBuilderTests.cs | 49 +++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index 0dff4bca6a..4f4272099a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs @@ -45,6 +45,7 @@ internal sealed partial class TestHostControllersTestHost using IDisposable forceExitRegistration = applicationCancellationTokenSource.RegisterForceExitAction(testHostProcess.Kill); int? testHostProcessId = null; + bool testHostControllerConnectionTimedOut = false; try { testHostProcessId = testHostProcess.Id; @@ -69,6 +70,18 @@ internal sealed partial class TestHostControllersTestHost if (testHostProcess.HasExited) { await _logger.LogDebugAsync("Test host process exited prematurely").ConfigureAwait(false); + await outputDevice.DisplayAsync( + this, + new ErrorMessageOutputDeviceData(CreateTestHostControllerConnectionFailureMessage( + waitDuration: TimeSpan.Zero, + timeout: null, + testHostProcess)), + CancellationToken.None).ConfigureAwait(false); + int fallbackPid = testHostProcessId ?? 0; + return ( + (int)ExitCode.GenericFailure, + new TestHostProcessInformation(fallbackPid, (int)ExitCode.GenericFailure, testHostCompletedReceived: false), + telemetryInformation.IsEnabled ? "[]" : null); } else { @@ -80,11 +93,25 @@ await RunWithCancellationTeardownAsync( await _logger.LogDebugAsync($"Setting PlatformTestHostControllersManagerSingleConnectionNamedPipeServerWaitConnectionTimeoutSeconds '{timeoutSeconds}'").ConfigureAwait(false); // Wait for the test host controller to connect. - using (CancellationTokenSource timeout = new(TimeSpan.FromSeconds(timeoutSeconds))) - using (var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, applicationCancellationToken)) + await _logger.LogDebugAsync("Wait connection from the test host process").ConfigureAwait(false); + bool connected = await WaitForTestHostControllerConnectionAsync( + testHostControllerIpc.WaitConnectionAsync, + timeoutSeconds, + applicationCancellationToken, + async () => + { + testHostControllerConnectionTimedOut = true; + await outputDevice.DisplayAsync( + this, + new ErrorMessageOutputDeviceData(CreateTestHostControllerConnectionFailureMessage( + TimeSpan.FromSeconds(timeoutSeconds), + TimeSpan.FromSeconds(timeoutSeconds), + testHostProcess)), + CancellationToken.None).ConfigureAwait(false); + }).ConfigureAwait(false); + if (!connected) { - await _logger.LogDebugAsync("Wait connection from the test host process").ConfigureAwait(false); - await testHostControllerIpc.WaitConnectionAsync(linkedToken.Token).ConfigureAwait(false); + return; } // Wait for the test host controller to send the PID of the test host process. @@ -139,6 +166,16 @@ await RunWithCancellationTeardownAsync( TestHostTerminationTimeout).ConfigureAwait(false); } + if (testHostControllerConnectionTimedOut) + { + await TerminateTestHostAfterConnectionFailureAsync(testHostProcess, _logger).ConfigureAwait(false); + int fallbackPid = testHostProcessId ?? 0; + return ( + (int)ExitCode.GenericFailure, + new TestHostProcessInformation(fallbackPid, (int)ExitCode.GenericFailure, testHostCompletedReceived: false), + telemetryInformation.IsEnabled ? "[]" : null); + } + if (_testHostPID is null && applicationCancellationToken.IsCancellationRequested) { int fallbackPid = testHostProcessId ?? 0; @@ -357,6 +394,61 @@ Test host did not exit gracefully. return (exitCode, testHostProcessInformation, extensionInformation); } + internal static async Task WaitForTestHostControllerConnectionAsync( + Func waitConnectionAsync, + double timeoutSeconds, + CancellationToken applicationCancellationToken, + Func onTimeoutAsync) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, applicationCancellationToken); + try + { + await waitConnectionAsync(linkedToken.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !applicationCancellationToken.IsCancellationRequested) + { + await onTimeoutAsync().ConfigureAwait(false); + return false; + } + } + + internal static string CreateTestHostControllerConnectionFailureMessage( + TimeSpan waitDuration, + TimeSpan? timeout, + IProcess testHostProcess) + { + bool hasExited = testHostProcess.HasExited; + string processState = hasExited + ? $"The test host process exited with code '{testHostProcess.ExitCode}'." + : "The test host process is still running."; + + string timeoutDetails = timeout is null + ? string.Empty + : $" The configured connection timeout was '{timeout.Value.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds."; + return $"The test host controller did not connect to the named pipe after '{waitDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds.{timeoutDetails} {processState}"; + } + + private static async Task TerminateTestHostAfterConnectionFailureAsync(IProcess testHostProcess, ILogger logger) + { + if (testHostProcess.HasExited) + { + return; + } + + try + { + testHostProcess.Kill(); + } + catch (Exception ex) + { + await logger.LogDebugAsync($"Ignoring failure while terminating the test host after a connection failure: {ex}").ConfigureAwait(false); + } + + await WaitForExitAfterTerminationAsync(testHostProcess, TestHostTerminationTimeout, logger).ConfigureAwait(false); + } + private CancellationTokenSource EnsureControllerFinalizationCancellationTokenSource() { var candidate = new CancellationTokenSource(); diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs index 650687087c..ccd9387611 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs @@ -219,6 +219,55 @@ public async Task TestHostControllerProcessTermination_WaitIsBounded() Assert.IsLessThan(5, stopwatch.Elapsed.TotalSeconds); } + [TestMethod] + public async Task TestHostControllerConnectionTimeout_IsHandledWithoutThrowing() + { + bool timeoutHandled = false; + + bool connected = await TestHostControllersTestHost.WaitForTestHostControllerConnectionAsync( + token => Task.Delay(Timeout.InfiniteTimeSpan, token), + timeoutSeconds: 0.01, + CancellationToken.None, + () => + { + timeoutHandled = true; + return Task.CompletedTask; + }); + + Assert.IsFalse(connected); + Assert.IsTrue(timeoutHandled); + } + + [TestMethod] + public async Task TestHostControllerConnectionTimeout_PreservesApplicationCancellation() + { + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAsync( + () => TestHostControllersTestHost.WaitForTestHostControllerConnectionAsync( + token => Task.Delay(Timeout.InfiniteTimeSpan, token), + timeoutSeconds: 1, + cancellationTokenSource.Token, + () => throw new AssertFailedException("The connection timeout handler should not run for application cancellation."))); + } + + [TestMethod] + public void TestHostControllerConnectionFailureMessage_ReportsProcessState() + { + Mock process = new(); + process.SetupGet(x => x.HasExited).Returns(true); + process.SetupGet(x => x.ExitCode).Returns(134); + + string message = TestHostControllersTestHost.CreateTestHostControllerConnectionFailureMessage( + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + process.Object); + + Assert.Contains("5", message); + Assert.Contains("exited with code '134'", message); + } + [TestMethod] public async Task TestHostControllerProcessTermination_FaultedWaitAdvancesToTermination() { From 1379df6bd42b301ac28e66540c7a438c0633820c Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 9 Sep 2026 20:45:35 +0200 Subject: [PATCH 2/7] Improve MTP test host timeout diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ostControllersTestHost.ProcessLifecycle.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index 4f4272099a..c22f7282e7 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs @@ -61,7 +61,7 @@ internal sealed partial class TestHostControllersTestHost testHostProcess.Exited += (_, _) => _logger.LogDebug($"Test host process exited, PID: '{testHostProcessId}'"); - await _logger.LogDebugAsync($"Started test host process '{testHostProcessId}' HasExited: {testHostProcess.HasExited}").ConfigureAwait(false); + await _logger.LogDebugAsync($"Started test host process. PID: '{testHostProcessId}'. HasExited: '{testHostProcess.HasExited}'.").ConfigureAwait(false); // Note: we intentionally gate on HasExited only and not on 'testHostProcessId is null'. // A custom ITestHostLauncher may legitimately not expose a local PID (e.g. container, // remote, or AUMID-activated apps); the real test host PID still arrives via the IPC @@ -69,7 +69,8 @@ internal sealed partial class TestHostControllersTestHost // coincides with HasExited == true, so behavior is unchanged there. if (testHostProcess.HasExited) { - await _logger.LogDebugAsync("Test host process exited prematurely").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Test host process exited before connecting to the test host controller. Exit code: '{testHostProcess.ExitCode}'.").ConfigureAwait(false); await outputDevice.DisplayAsync( this, new ErrorMessageOutputDeviceData(CreateTestHostControllerConnectionFailureMessage( @@ -90,10 +91,11 @@ await RunWithCancellationTeardownAsync( { string? seconds = configuration[PlatformConfigurationConstants.PlatformTestHostControllersManagerSingleConnectionNamedPipeServerWaitConnectionTimeoutSeconds]; double timeoutSeconds = seconds is null ? TimeoutHelper.DefaultHangTimeoutSeconds : double.Parse(seconds, CultureInfo.InvariantCulture); - await _logger.LogDebugAsync($"Setting PlatformTestHostControllersManagerSingleConnectionNamedPipeServerWaitConnectionTimeoutSeconds '{timeoutSeconds}'").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Test host controller named-pipe connection timeout is '{timeoutSeconds}' seconds.").ConfigureAwait(false); // Wait for the test host controller to connect. - await _logger.LogDebugAsync("Wait connection from the test host process").ConfigureAwait(false); + await _logger.LogDebugAsync("Waiting for the test host controller to connect to the named pipe.").ConfigureAwait(false); bool connected = await WaitForTestHostControllerConnectionAsync( testHostControllerIpc.WaitConnectionAsync, timeoutSeconds, @@ -121,7 +123,7 @@ await outputDevice.DisplayAsync( _waitForPid.Wait(timeout.Token); } - await _logger.LogDebugAsync("Fire OnTestHostProcessStartedAsync").ConfigureAwait(false); + await _logger.LogDebugAsync("Invoking test-host-process-started lifecycle handlers.").ConfigureAwait(false); if (_testHostPID is null) { @@ -150,7 +152,7 @@ await outputDevice.DisplayAsync( } } - await _logger.LogDebugAsync("Wait for test host process exit").ConfigureAwait(false); + await _logger.LogDebugAsync("Waiting for the test host process to exit.").ConfigureAwait(false); if (!startHandlersCompleted) { throw new OperationCanceledException(applicationCancellationToken); @@ -446,7 +448,11 @@ private static async Task TerminateTestHostAfterConnectionFailureAsync(IProcess await logger.LogDebugAsync($"Ignoring failure while terminating the test host after a connection failure: {ex}").ConfigureAwait(false); } - await WaitForExitAfterTerminationAsync(testHostProcess, TestHostTerminationTimeout, logger).ConfigureAwait(false); + bool exited = await WaitForExitAfterTerminationAsync(testHostProcess, TestHostTerminationTimeout, logger).ConfigureAwait(false); + if (!exited && testHostProcess is TestHostHandleToProcessAdapter adapter) + { + adapter.DeferDisposalUntilExit(); + } } private CancellationTokenSource EnsureControllerFinalizationCancellationTokenSource() From 63ce9aa7686d3a6ecb6c14f5648634e95fe7bc34 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 9 Sep 2026 20:45:43 +0200 Subject: [PATCH 3/7] Add pipe timeout regression coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InternalAPI/InternalAPI.Unshipped.txt | 2 ++ .../TestApplicationBuilderTests.cs | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index d7729f4d4b..178a1fff1d 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -34,7 +34,9 @@ const Microsoft.Testing.Platform.Helpers.EnvironmentVariableConstants.TESTINGPLA const Microsoft.Testing.Platform.IPC.ServerControlKinds.CloseChannel = 2 -> byte static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.HandleCanceledTestHostAsync(Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess, System.Action! requestCancellation, Microsoft.Testing.Platform.Logging.ILogger! logger, System.TimeSpan cooperativeShutdownTimeout, System.TimeSpan terminationTimeout) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.GetTestHostCooperativeShutdownTimeout(Microsoft.Testing.Platform.Extensions.TestHostControllers.IReadOnlyEnvironmentVariables! environmentVariables) -> System.TimeSpan +static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.CreateTestHostControllerConnectionFailureMessage(System.TimeSpan waitDuration, System.TimeSpan? timeout, Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess) -> string! static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.RunWithCancellationTeardownAsync(System.Func! runAsync, System.Threading.CancellationToken applicationCancellationToken, Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess, System.Action! requestCancellation, Microsoft.Testing.Platform.Logging.ILogger! logger, System.TimeSpan cooperativeShutdownTimeout, System.TimeSpan terminationTimeout) -> System.Threading.Tasks.Task! +static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.WaitForTestHostControllerConnectionAsync(System.Func! waitConnectionAsync, double timeoutSeconds, System.Threading.CancellationToken applicationCancellationToken, System.Func! onTimeoutAsync) -> System.Threading.Tasks.Task Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.TestHostCooperativeShutdownTimeout.get -> System.TimeSpan Microsoft.Testing.Platform.IPC.NamedPipeServer.WaitForDisconnectAsync(System.TimeSpan timeout) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.Messages.ShutdownTimeouts.GetCanceledConsumerCompletion(string? value) -> System.TimeSpan diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs index ccd9387611..4f6b0b39e3 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs @@ -260,14 +260,31 @@ public void TestHostControllerConnectionFailureMessage_ReportsProcessState() process.SetupGet(x => x.ExitCode).Returns(134); string message = TestHostControllersTestHost.CreateTestHostControllerConnectionFailureMessage( - TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5), process.Object); - Assert.Contains("5", message); + Assert.Contains("after '3' seconds", message); + Assert.Contains("timeout was '5' seconds", message); Assert.Contains("exited with code '134'", message); } + [TestMethod] + public void TestHostControllerConnectionFailureMessage_ReportsRunningProcess() + { + Mock process = new(); + process.SetupGet(x => x.HasExited).Returns(false); + + string message = TestHostControllersTestHost.CreateTestHostControllerConnectionFailureMessage( + TimeSpan.FromSeconds(3), + TimeSpan.FromSeconds(5), + process.Object); + + Assert.Contains("after '3' seconds", message); + Assert.Contains("timeout was '5' seconds", message); + Assert.Contains("still running", message); + } + [TestMethod] public async Task TestHostControllerProcessTermination_FaultedWaitAdvancesToTermination() { From 363457839a426608eb4a5156450fcf7c44889553 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 9 Sep 2026 20:46:51 +0200 Subject: [PATCH 4/7] Polish MTP test host lifecycle logs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ostControllersTestHost.ProcessLifecycle.cs | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index c22f7282e7..6efe9b7550 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs @@ -33,8 +33,10 @@ internal sealed partial class TestHostControllersTestHost // Launch the test host process string testHostProcessStartupTime = _clock.UtcNow.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); processStartInfo.EnvironmentVariables.Add($"{EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME}_{currentPid}", testHostProcessStartupTime); - await _logger.LogDebugAsync($"{EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME}_{currentPid} '{testHostProcessStartupTime}'").ConfigureAwait(false); - await _logger.LogDebugAsync($"Starting test host process '{processStartInfo.FileName}' with args '{processStartInfo.Arguments}'").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Test host process startup timestamp environment variable '{EnvironmentVariableConstants.TESTINGPLATFORM_TESTHOSTCONTROLLER_TESTHOSTPROCESSSTARTTIME}_{currentPid}' is '{testHostProcessStartupTime}'.").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Starting test host process '{processStartInfo.FileName}' with arguments '{processStartInfo.Arguments}'.").ConfigureAwait(false); ITestHostLauncher? testHostLauncher = _testHostsInformation.TestHostLauncher; using IProcess testHostProcess = testHostLauncher is null @@ -55,11 +57,12 @@ internal sealed partial class TestHostControllersTestHost // Access PID can throw InvalidOperationException if the process has already exited: // System.InvalidOperationException: No process is associated with this object. // A custom launcher may also legitimately not expose a local PID (e.g. container/remote). - await _logger.LogDebugAsync($"Unable to obtain test host PID; process had already exited or does not expose a PID (HasExited: {testHostProcess.HasExited}). {ex.GetType().FullName}: {ex.Message}").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Unable to obtain the test host PID because the process had already exited or does not expose a PID. HasExited: '{testHostProcess.HasExited}'. {ex.GetType().FullName}: {ex.Message}").ConfigureAwait(false); } testHostProcess.Exited += (_, _) => - _logger.LogDebug($"Test host process exited, PID: '{testHostProcessId}'"); + _logger.LogDebug($"Test host process exited. PID: '{testHostProcessId}'."); await _logger.LogDebugAsync($"Started test host process. PID: '{testHostProcessId}'. HasExited: '{testHostProcess.HasExited}'.").ConfigureAwait(false); // Note: we intentionally gate on HasExited only and not on 'testHostProcessId is null'. @@ -254,7 +257,7 @@ async Task JoinAbortCallbacksIfCanceledAsync() { if (testHostProcessExited && _testHostsInformation.LifetimeHandlers.Length > 0) { - await _logger.LogDebugAsync($"Fire OnTestHostProcessExitedAsync: ExitCode: {testHostProcessExitCode}").ConfigureAwait(false); + await _logger.LogDebugAsync($"Invoking test-host-process-exited lifecycle handlers. Exit code: '{testHostProcessExitCode}'.").ConfigureAwait(false); foreach (ITestHostProcessLifetimeHandler lifetimeHandler in _testHostsInformation.LifetimeHandlers) { if (_servicesStillRunning.Contains(lifetimeHandler)) @@ -391,7 +394,8 @@ Test host did not exit gracefully. exitCode = CoverageThresholdExitCodePolicy.Apply(exitCode, ServiceProvider); exitCode = ExitCodeIgnorePolicy.Apply(exitCode, ServiceProvider.GetCommandLineOptions(), ServiceProvider.GetEnvironment()); - await _logger.LogInformationAsync($"TestHostControllersTestHost ended with exit code '{exitCode}' (real test host exit code '{testHostProcessExitCode}') in '{consoleRunStarted.Elapsed}'").ConfigureAwait(false); + await _logger.LogInformationAsync( + $"TestHostControllersTestHost ended with exit code '{exitCode}' (real test host exit code '{testHostProcessExitCode}') in '{consoleRunStarted.Elapsed}'.").ConfigureAwait(false); return (exitCode, testHostProcessInformation, extensionInformation); } @@ -445,7 +449,7 @@ private static async Task TerminateTestHostAfterConnectionFailureAsync(IProcess } catch (Exception ex) { - await logger.LogDebugAsync($"Ignoring failure while terminating the test host after a connection failure: {ex}").ConfigureAwait(false); + await logger.LogDebugAsync($"Test host termination after a connection failure failed; continuing cleanup. {ex}").ConfigureAwait(false); } bool exited = await WaitForExitAfterTerminationAsync(testHostProcess, TestHostTerminationTimeout, logger).ConfigureAwait(false); @@ -512,7 +516,7 @@ await testHostProcess.WaitForExitAsync(CancellationToken.None) } catch (Exception ex) { - await logger.LogDebugAsync($"Ignoring failure while waiting for the test host to exit during cancellation teardown: {ex}").ConfigureAwait(false); + await logger.LogDebugAsync($"Waiting for the test host to exit during cancellation teardown failed; continuing cleanup. {ex}").ConfigureAwait(false); return false; } } @@ -530,7 +534,7 @@ internal static async Task HandleCanceledTestHostAsync( return; } - await logger.LogDebugAsync($"Test host did not exit within {cooperativeShutdownTimeout} after cooperative cancellation; terminating it").ConfigureAwait(false); + await logger.LogDebugAsync($"Test host did not exit within '{cooperativeShutdownTimeout}' after cooperative cancellation; terminating it.").ConfigureAwait(false); try { testHostProcess.Kill(); @@ -542,7 +546,7 @@ internal static async Task HandleCanceledTestHostAsync( // ITestHostLauncher's Terminate() which can throw anything (e.g. NotSupportedException, // Win32Exception). Either way the host is on its way out, so swallow and log rather // than letting it mask the cancellation teardown flow. - await logger.LogDebugAsync($"Ignoring failure while terminating the test host during cancellation: {ex}").ConfigureAwait(false); + await logger.LogDebugAsync($"Test host termination during cancellation failed; continuing cleanup. {ex}").ConfigureAwait(false); } if (await WaitForExitAfterTerminationAsync(testHostProcess, terminationTimeout, logger).ConfigureAwait(false)) @@ -574,7 +578,7 @@ internal static async Task RunWithCancellationTeardownAsync( } catch (OperationCanceledException) when (applicationCancellationToken.IsCancellationRequested) { - await logger.LogDebugAsync("Test host execution was canceled; requesting cooperative test host cancellation").ConfigureAwait(false); + await logger.LogDebugAsync("Test host execution was canceled; requesting cooperative test host cancellation.").ConfigureAwait(false); await HandleCanceledTestHostAsync( testHostProcess, requestCancellation, @@ -643,9 +647,11 @@ private async Task LaunchUsingCustomLauncherAsync( string? workingDirectory = RoslynString.IsNullOrEmpty(processStartInfo.WorkingDirectory) ? null : processStartInfo.WorkingDirectory; TestHostLaunchContext context = new(processStartInfo.FileName, arguments, environmentVariables, workingDirectory); - await _logger.LogDebugAsync($"Delegating test host launch to '{testHostLauncher.DisplayName}' (UID: {testHostLauncher.Uid})").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Delegating test host launch to '{testHostLauncher.DisplayName}' (UID: '{testHostLauncher.Uid}').").ConfigureAwait(false); ITestHostHandle handle = await testHostLauncher.LaunchTestHostAsync(context, cancellationToken).ConfigureAwait(false); - await _logger.LogDebugAsync($"Test host launched by '{testHostLauncher.Uid}' (Identifier: '{handle.Identifier ?? ""}')").ConfigureAwait(false); + await _logger.LogDebugAsync( + $"Test host launched by '{testHostLauncher.Uid}'. Identifier: '{handle.Identifier ?? ""}'.").ConfigureAwait(false); return new TestHostHandleToProcessAdapter(handle); } } From 625d7590c06741661e74c35f40393f4d3026fa6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 10 Sep 2026 10:08:23 +0200 Subject: [PATCH 5/7] Address test host timeout review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ostControllersTestHost.ProcessLifecycle.cs | 4 +- .../TestApplicationBuilderTests.cs | 121 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index 6efe9b7550..e9ce9e061a 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs @@ -97,8 +97,8 @@ await RunWithCancellationTeardownAsync( await _logger.LogDebugAsync( $"Test host controller named-pipe connection timeout is '{timeoutSeconds}' seconds.").ConfigureAwait(false); - // Wait for the test host controller to connect. - await _logger.LogDebugAsync("Waiting for the test host controller to connect to the named pipe.").ConfigureAwait(false); + // Wait for the test host process to connect. + await _logger.LogDebugAsync("Waiting for the test host process to connect to the named pipe.").ConfigureAwait(false); bool connected = await WaitForTestHostControllerConnectionAsync( testHostControllerIpc.WaitConnectionAsync, timeoutSeconds, diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs index 4f6b0b39e3..e317f5d336 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs @@ -12,6 +12,7 @@ using Microsoft.Testing.Platform.Messages; using Microsoft.Testing.Platform.OutputDevice; using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.Telemetry; using Microsoft.Testing.Platform.TestHost; using Microsoft.Testing.Platform.TestHostControllers; @@ -252,6 +253,79 @@ await Assert.ThrowsAsync( () => throw new AssertFailedException("The connection timeout handler should not run for application cancellation."))); } + [TestMethod] + public async Task TestHostControllerConnectionTimeout_TerminatesCustomHandleDefersDisposalAndReturnsFailure() + { + TaskCompletionSource exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + Mock handle = new(); + handle.SetupGet(x => x.Identifier).Returns("custom-host"); + handle.SetupGet(x => x.HasExited).Returns(() => exited.Task.IsCompleted); + handle.SetupSequence(x => x.WaitForExitAsync(It.IsAny())) + .Returns(exited.Task) + .ThrowsAsync(new IOException("remote wait failed")) + .Returns(exited.Task); + handle.Setup(x => x.Dispose()).Callback(() => disposed.TrySetResult(true)); + + Mock launcher = new(); + launcher.SetupGet(x => x.Uid).Returns("custom-launcher"); + launcher.SetupGet(x => x.DisplayName).Returns("Custom launcher"); + launcher.Setup(x => x.LaunchTestHostAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(handle.Object); + + ServiceProvider serviceProvider = new(); + using var cancellationTokenSource = new CTRLPlusCCancellationTokenSource(); + serviceProvider.AddService(cancellationTokenSource); + var task = new SystemTask(); + serviceProvider.AddService(task); + Mock loggerFactory = new(); + loggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(new NopLogger()); + Mock clock = new(); + clock.SetupGet(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); + using var host = new TestHostControllersTestHost( + new([], [], [], launcher.Object, requireProcessRestart: true), + serviceProvider, + passiveNode: null, + new SystemEnvironment(), + loggerFactory.Object, + clock.Object); + Mock configuration = new(); + configuration + .SetupGet(x => x[PlatformConfigurationConstants.PlatformTestHostControllersManagerSingleConnectionNamedPipeServerWaitConnectionTimeoutSeconds]) + .Returns("0.01"); + var outputDevice = new ProxyOutputDevice(new NopPlatformOutputDevice(), serverModeOutputDevice: null); + var telemetryInformation = new TelemetryInformation(isEnabled: false, version: "test"); + using IDisposable testHostControllerIpc = CreateTestHostControllerIpc(host, CancellationToken.None); + using var cancellationServer = new TestHostControllerCancellationServer( + authorizedSecurityIdentities: null, + new SystemEnvironment(), + loggerFactory.Object, + task); + + (int exitCode, TestHostProcessInformation processInformation, _) = await RunTestHostProcessAsync( + host, + new ProcessStartInfo("custom-test-host"), + [], + currentPid: 42, + Mock.Of(), + configuration.Object, + testHostControllerIpc, + cancellationServer, + outputDevice, + telemetryInformation, + CancellationToken.None); + + Assert.AreEqual((int)ExitCode.GenericFailure, exitCode); + Assert.AreEqual((int)ExitCode.GenericFailure, processInformation.ExitCode); + Assert.IsFalse(processInformation.HasExitedGracefully); + handle.Verify(x => x.Terminate(), Times.Once); + handle.Verify(x => x.Dispose(), Times.Never); + + exited.SetResult(true); + await disposed.Task.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout); + handle.Verify(x => x.Dispose(), Times.Once); + } + [TestMethod] public void TestHostControllerConnectionFailureMessage_ReportsProcessState() { @@ -648,6 +722,53 @@ private static Task WaitForExitAfterTerminationAsync(IProcess process, Tim ?? throw new InvalidOperationException("TestHostControllersTestHost.WaitForExitAfterTerminationAsync returned null."); } + private static Task<(int ExitCode, TestHostProcessInformation ProcessInformation, string? ExtensionInformation)> RunTestHostProcessAsync( + TestHostControllersTestHost host, + ProcessStartInfo processStartInfo, + IReadOnlyList partialCommandLine, + int currentPid, + IProcessHandler process, + IConfiguration configuration, + object testHostControllerIpc, + TestHostControllerCancellationServer testHostControllerCancellationServer, + ProxyOutputDevice outputDevice, + ITelemetryInformation telemetryInformation, + CancellationToken applicationCancellationToken) + { + MethodInfo method = typeof(TestHostControllersTestHost).GetMethod( + "RunTestHostProcessAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Could not find TestHostControllersTestHost.RunTestHostProcessAsync."); + return (Task<(int ExitCode, TestHostProcessInformation ProcessInformation, string? ExtensionInformation)>?)method.Invoke( + host, + [ + processStartInfo, + partialCommandLine, + currentPid, + process, + configuration, + testHostControllerIpc, + testHostControllerCancellationServer, + outputDevice, + telemetryInformation, + Stopwatch.StartNew(), + applicationCancellationToken, + ]) + ?? throw new InvalidOperationException("TestHostControllersTestHost.RunTestHostProcessAsync returned null."); + } + + private static IDisposable CreateTestHostControllerIpc( + TestHostControllersTestHost host, + CancellationToken cancellationToken) + { + MethodInfo method = typeof(TestHostControllersTestHost).GetMethod( + "CreateTestHostControllerIpc", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("Could not find TestHostControllersTestHost.CreateTestHostControllerIpc."); + return (IDisposable?)method.Invoke(host, [null, cancellationToken]) + ?? throw new InvalidOperationException("TestHostControllersTestHost.CreateTestHostControllerIpc returned null."); + } + private static void MarkOutputDeviceStillRunning(List servicesStillRunning, ProxyOutputDevice outputDevice) { MethodInfo method = typeof(TestHostControllersTestHost).GetMethod( From 1e21377cb8587f4530348beff878aca495726e2a Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 10 Sep 2026 10:16:16 +0200 Subject: [PATCH 6/7] Polish MTP timeout diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ostControllersTestHost.ProcessLifecycle.cs | 15 ++++++----- .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../TestApplicationBuilderTests.cs | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index e9ce9e061a..0b8be1b913 100644 --- a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs +++ b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs @@ -97,8 +97,8 @@ await RunWithCancellationTeardownAsync( await _logger.LogDebugAsync( $"Test host controller named-pipe connection timeout is '{timeoutSeconds}' seconds.").ConfigureAwait(false); - // Wait for the test host process to connect. - await _logger.LogDebugAsync("Waiting for the test host process to connect to the named pipe.").ConfigureAwait(false); + // Wait for the test host process to connect to the controller's named pipe. + await _logger.LogDebugAsync("Waiting for the test host process to connect to the controller's named pipe.").ConfigureAwait(false); bool connected = await WaitForTestHostControllerConnectionAsync( testHostControllerIpc.WaitConnectionAsync, timeoutSeconds, @@ -173,7 +173,7 @@ await outputDevice.DisplayAsync( if (testHostControllerConnectionTimedOut) { - await TerminateTestHostAfterConnectionFailureAsync(testHostProcess, _logger).ConfigureAwait(false); + await TerminateTestHostAfterConnectionFailureAsync(testHostProcess, _logger, TestHostTerminationTimeout).ConfigureAwait(false); int fallbackPid = testHostProcessId ?? 0; return ( (int)ExitCode.GenericFailure, @@ -433,10 +433,13 @@ internal static string CreateTestHostControllerConnectionFailureMessage( string timeoutDetails = timeout is null ? string.Empty : $" The configured connection timeout was '{timeout.Value.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds."; - return $"The test host controller did not connect to the named pipe after '{waitDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds.{timeoutDetails} {processState}"; + return $"The test host process did not connect to the controller's named pipe after '{waitDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds.{timeoutDetails} {processState}"; } - private static async Task TerminateTestHostAfterConnectionFailureAsync(IProcess testHostProcess, ILogger logger) + internal static async Task TerminateTestHostAfterConnectionFailureAsync( + IProcess testHostProcess, + ILogger logger, + TimeSpan terminationTimeout) { if (testHostProcess.HasExited) { @@ -452,7 +455,7 @@ private static async Task TerminateTestHostAfterConnectionFailureAsync(IProcess await logger.LogDebugAsync($"Test host termination after a connection failure failed; continuing cleanup. {ex}").ConfigureAwait(false); } - bool exited = await WaitForExitAfterTerminationAsync(testHostProcess, TestHostTerminationTimeout, logger).ConfigureAwait(false); + bool exited = await WaitForExitAfterTerminationAsync(testHostProcess, terminationTimeout, logger).ConfigureAwait(false); if (!exited && testHostProcess is TestHostHandleToProcessAdapter adapter) { adapter.DeferDisposalUntilExit(); diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 178a1fff1d..fd505eb330 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -35,6 +35,7 @@ const Microsoft.Testing.Platform.IPC.ServerControlKinds.CloseChannel = 2 -> byte static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.HandleCanceledTestHostAsync(Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess, System.Action! requestCancellation, Microsoft.Testing.Platform.Logging.ILogger! logger, System.TimeSpan cooperativeShutdownTimeout, System.TimeSpan terminationTimeout) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.GetTestHostCooperativeShutdownTimeout(Microsoft.Testing.Platform.Extensions.TestHostControllers.IReadOnlyEnvironmentVariables! environmentVariables) -> System.TimeSpan static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.CreateTestHostControllerConnectionFailureMessage(System.TimeSpan waitDuration, System.TimeSpan? timeout, Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess) -> string! +static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.TerminateTestHostAfterConnectionFailureAsync(Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess, Microsoft.Testing.Platform.Logging.ILogger! logger, System.TimeSpan terminationTimeout) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.RunWithCancellationTeardownAsync(System.Func! runAsync, System.Threading.CancellationToken applicationCancellationToken, Microsoft.Testing.Platform.Helpers.IProcess! testHostProcess, System.Action! requestCancellation, Microsoft.Testing.Platform.Logging.ILogger! logger, System.TimeSpan cooperativeShutdownTimeout, System.TimeSpan terminationTimeout) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.WaitForTestHostControllerConnectionAsync(System.Func! waitConnectionAsync, double timeoutSeconds, System.Threading.CancellationToken applicationCancellationToken, System.Func! onTimeoutAsync) -> System.Threading.Tasks.Task Microsoft.Testing.Platform.Hosts.TestHostControllersTestHost.TestHostCooperativeShutdownTimeout.get -> System.TimeSpan diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs index e317f5d336..6454cb8792 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/TestApplicationBuilderTests.cs @@ -408,6 +408,32 @@ await TestHostControllersTestHost.HandleCanceledTestHostAsync( handle.Verify(x => x.Dispose(), Times.Once); } + [TestMethod] + public async Task TestHostControllerConnectionFailure_UnresponsiveCustomHandleIsTerminatedAndDeferred() + { + TaskCompletionSource exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource disposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + Mock handle = new(); + handle.SetupGet(x => x.HasExited).Returns(() => exited.Task.IsCompleted); + handle.Setup(x => x.WaitForExitAsync(It.IsAny())).Returns(exited.Task); + handle.Setup(x => x.Dispose()).Callback(() => disposed.TrySetResult(true)); + var adapter = new TestHostHandleToProcessAdapter(handle.Object); + + await TestHostControllersTestHost.TerminateTestHostAfterConnectionFailureAsync( + adapter, + new NopLogger(), + TimeSpan.FromMilliseconds(50)); + + handle.Verify(x => x.Terminate(), Times.Once); + + adapter.Dispose(); + handle.Verify(x => x.Dispose(), Times.Never); + + exited.SetResult(true); + await disposed.Task.TimeoutAfterAsync(TimeoutHelper.DefaultHangTimeSpanTimeout); + handle.Verify(x => x.Dispose(), Times.Once); + } + [TestMethod] public async Task TestHostControllerProcessTermination_CancellationDuringStartupUsesTeardownPath() { From ead0eba4e04744f8f2fb76fef595f5b9222d4939 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 10 Sep 2026 17:14:40 +0200 Subject: [PATCH 7/7] Handle ConnectionReset exception in pipe timeout test on Unix platforms Unix systems can report a forced close of an idle pipe as a connection reset instead of EOF. The test was failing on Linux with .NET 9.0 because it didn't handle this exception. This change adds a try-catch block to handle the IOException with ConnectionReset SocketError, which is the expected behavior on Unix when a pipe is closed abruptly. This fixes the failing test ServerDisposal_WhenClientConnectsWithoutSendingRequest_DoesNotHang in the Linux Release build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TestHostControllerCancellationTests.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/TestHostControllerCancellationTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/TestHostControllerCancellationTests.cs index 88812ba0dd..75b2b3726a 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/TestHostControllerCancellationTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/TestHostControllerCancellationTests.cs @@ -94,8 +94,21 @@ public async Task ServerDisposal_WhenClientConnectsWithoutSendingRequest_DoesNot await Task.Run(server.Dispose, TestContext.CancellationToken).TimeoutAfterAsync(DisposalTestTimeout); byte[] responseBuffer = new byte[1]; - int bytesRead = await client.ReadAsync(responseBuffer, 0, responseBuffer.Length, TestContext.CancellationToken); - Assert.AreEqual(0, bytesRead); + try + { + Task readTask = client.ReadAsync(responseBuffer, 0, responseBuffer.Length, TestContext.CancellationToken); + Task completedTask = await Task.WhenAny(readTask, Task.Delay(DisposalTestTimeout, TestContext.CancellationToken)); + Assert.AreSame(readTask, completedTask, "The client remained connected after server disposal."); + int bytesRead = await readTask; + Assert.AreEqual(0, bytesRead); + } + catch (IOException ex) when (ex.InnerException is System.Net.Sockets.SocketException + { + SocketErrorCode: System.Net.Sockets.SocketError.ConnectionReset, + }) + { + // Unix can report a forced close of an idle pipe as a connection reset instead of EOF. + } } [TestMethod]