diff --git a/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs b/src/Platform/Microsoft.Testing.Platform/Hosts/TestHostControllersTestHost.ProcessLifecycle.cs index 0dff4bca6a..0b8be1b913 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 @@ -45,6 +47,7 @@ internal sealed partial class TestHostControllersTestHost using IDisposable forceExitRegistration = applicationCancellationTokenSource.RegisterForceExitAction(testHostProcess.Kill); int? testHostProcessId = null; + bool testHostControllerConnectionTimedOut = false; try { testHostProcessId = testHostProcess.Id; @@ -54,13 +57,14 @@ 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 '{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 @@ -68,7 +72,20 @@ 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( + 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 { @@ -77,14 +94,29 @@ 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); - - // 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( + $"Test host controller named-pipe connection timeout is '{timeoutSeconds}' seconds.").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, + 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. @@ -94,7 +126,7 @@ await RunWithCancellationTeardownAsync( _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) { @@ -123,7 +155,7 @@ await RunWithCancellationTeardownAsync( } } - 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); @@ -139,6 +171,16 @@ await RunWithCancellationTeardownAsync( TestHostTerminationTimeout).ConfigureAwait(false); } + if (testHostControllerConnectionTimedOut) + { + await TerminateTestHostAfterConnectionFailureAsync(testHostProcess, _logger, TestHostTerminationTimeout).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; @@ -215,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)) @@ -352,11 +394,74 @@ 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); } + 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 process did not connect to the controller's named pipe after '{waitDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture)}' seconds.{timeoutDetails} {processState}"; + } + + internal static async Task TerminateTestHostAfterConnectionFailureAsync( + IProcess testHostProcess, + ILogger logger, + TimeSpan terminationTimeout) + { + if (testHostProcess.HasExited) + { + return; + } + + try + { + testHostProcess.Kill(); + } + catch (Exception ex) + { + await logger.LogDebugAsync($"Test host termination after a connection failure failed; continuing cleanup. {ex}").ConfigureAwait(false); + } + + bool exited = await WaitForExitAfterTerminationAsync(testHostProcess, terminationTimeout, logger).ConfigureAwait(false); + if (!exited && testHostProcess is TestHostHandleToProcessAdapter adapter) + { + adapter.DeferDisposalUntilExit(); + } + } + private CancellationTokenSource EnsureControllerFinalizationCancellationTokenSource() { var candidate = new CancellationTokenSource(); @@ -414,7 +519,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; } } @@ -432,7 +537,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(); @@ -444,7 +549,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)) @@ -476,7 +581,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, @@ -545,9 +650,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); } } diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 25436496de..76e72ebbbc 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -33,7 +33,10 @@ 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.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 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 650687087c..6454cb8792 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; @@ -219,6 +220,145 @@ 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 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() + { + Mock process = new(); + process.SetupGet(x => x.HasExited).Returns(true); + process.SetupGet(x => x.ExitCode).Returns(134); + + 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("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() { @@ -268,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() { @@ -582,6 +748,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(