diff --git a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs index 9ebc1d69d3f..3e2ddc21569 100644 --- a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs +++ b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using Microsoft.Build.Execution; @@ -17,6 +18,10 @@ namespace Microsoft.Build.Engine.UnitTests.BackEnd { + /// + /// Tests for the TaskHostFactory functionality, which manages task host processes + /// for executing MSBuild tasks in separate processes. + /// public sealed class TaskHostFactory_Tests { private ITestOutputHelper _output; @@ -26,6 +31,13 @@ public TaskHostFactory_Tests(ITestOutputHelper testOutputHelper) _output = testOutputHelper; } + /// + /// Verifies that task host nodes properly terminate after a build completes. + /// Tests both transient (TaskHostFactory) and sidecar (AssemblyTaskFactory) task hosts + /// with different configuration combinations. + /// + /// Whether to use TaskHostFactory (transient) or AssemblyTaskFactory (sidecar) + /// Whether to set MSBUILDFORCEALLTASKSOUTOFPROC environment variable [Theory] [InlineData(true, false)] [InlineData(false, true)] @@ -34,7 +46,7 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab { using (TestEnvironment env = TestEnvironment.Create()) { - + using ProcessTracker processTracker = new(); string taskFactory = taskHostFactorySpecified ? "TaskHostFactory" : "AssemblyTaskFactory"; string pidTaskProject = $@" @@ -46,11 +58,12 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab "; TransientTestFile project = env.CreateFile("testProject.csproj", pidTaskProject); - + if (envVariableSpecified) { env.SetEnvironmentVariable("MSBUILDFORCEALLTASKSOUTOFPROC", "1"); } + ProjectInstance projectInstance = new(project.Path); projectInstance.Build().ShouldBeTrue(); @@ -64,8 +77,9 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab try { Process taskHostNode = Process.GetProcessById(pid); - taskHostNode.WaitForExit(3000).ShouldBeTrue($"The executed MSBuild Version: {projectInstance.GetProperty("MSBuildVersion")}"); + taskHostNode.WaitForExit(3000).ShouldBeTrue("The process with taskHostNode is still running."); } + // We expect the TaskHostNode to exit quickly. If it exits before Process.GetProcessById, it will throw an ArgumentException. catch (ArgumentException e) { @@ -74,20 +88,40 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab } else { - // This is the sidecar TaskHost case - it should persist after build is done. So we need to clean up and kill it ourselves. - Process taskHostNode = Process.GetProcessById(pid); - taskHostNode.WaitForExit(3000).ShouldBeFalse($"The executed MSBuild Version: {projectInstance.GetProperty("MSBuildVersion")}"); - taskHostNode.Kill(); + try + { + // This is the sidecar TaskHost case - it should persist after build is done. So we need to clean up and kill it ourselves. + Process taskHostNode = Process.GetProcessById(pid); + using var taskHostNodeTracker = processTracker.AttachToProcess(pid, "Sidecar", _output); + bool processExited = taskHostNode.WaitForExit(3000); + if (processExited) + { + processTracker.PrintSummary(_output); + } + + processExited.ShouldBeFalse(); + taskHostNode.Kill(); + } + catch + { + processTracker.PrintSummary(_output); + } } } } + /// + /// Verifies that transient (TaskHostFactory) and sidecar (AssemblyTaskFactory) task hosts + /// can coexist in the same build and operate independently. + /// [Fact] public void TransientandSidecarNodeCanCoexist() { - using (TestEnvironment env = TestEnvironment.Create()) + using (TestEnvironment env = TestEnvironment.Create(_output)) { - string pidTaskProject = $@" + using ProcessTracker processTracker = new(); + { + string pidTaskProject = $@" @@ -102,43 +136,59 @@ public void TransientandSidecarNodeCanCoexist() "; - TransientTestFile project = env.CreateFile("testProject.csproj", pidTaskProject); + TransientTestFile project = env.CreateFile("testProject.csproj", pidTaskProject); + env.SetEnvironmentVariable("MSBUILDFORCEALLTASKSOUTOFPROC", "1"); + ProjectInstance projectInstance = new(project.Path); - env.SetEnvironmentVariable("MSBUILDFORCEALLTASKSOUTOFPROC", "1"); - ProjectInstance projectInstance = new(project.Path); + projectInstance.Build().ShouldBeTrue(); - projectInstance.Build().ShouldBeTrue(); - string processId = projectInstance.GetPropertyValue("PID"); - string processIdSidecar = projectInstance.GetPropertyValue("PID2"); - processIdSidecar.ShouldNotBe(processId, "Each task should have it's own TaskHost node."); + string transientPid = projectInstance.GetPropertyValue("PID"); + string sidecarPid = projectInstance.GetPropertyValue("PID2"); + sidecarPid.ShouldNotBe(transientPid, "Each task should have it's own TaskHost node."); - string.IsNullOrEmpty(processId).ShouldBeFalse(); - Int32.TryParse(processId, out int pid).ShouldBeTrue(); - Int32.TryParse(processIdSidecar, out int pidSidecar).ShouldBeTrue(); + using var sidecarTracker = processTracker.AttachToProcess(int.Parse(sidecarPid), "Sidecar", _output); - Process.GetCurrentProcess().Id.ShouldNotBe(pid); + string.IsNullOrEmpty(transientPid).ShouldBeFalse(); + Int32.TryParse(transientPid, out int pid).ShouldBeTrue(); + Int32.TryParse(sidecarPid, out int pidSidecar).ShouldBeTrue(); + Process.GetCurrentProcess().Id.ShouldNotBe(pid); - try - { - Process taskHostNode1 = Process.GetProcessById(pid); - taskHostNode1.WaitForExit(3000).ShouldBeTrue("The node should be dead since this is the transient case."); - } - catch (ArgumentException e) - { - // We expect the TaskHostNode to exit quickly. If it exits before Process.GetProcessById, it will throw an ArgumentException. - e.Message.ShouldBe($"Process with an Id of {pid} is not running."); + try + { + Process transientTaskHostNode = Process.GetProcessById(pid); + transientTaskHostNode.WaitForExit(3000).ShouldBeTrue("The node should be dead since this is the transient case."); + } + catch (ArgumentException e) + { + // We expect the TaskHostNode to exit quickly. If it exits before Process.GetProcessById, it will throw an ArgumentException. + e.Message.ShouldBe($"Process with an Id of {pid} is not running."); + } + + try + { + // This is the sidecar TaskHost case - it should persist after build is done. So we need to clean up and kill it ourselves. + Process sidecarTaskHostNode = Process.GetProcessById(pidSidecar); + sidecarTaskHostNode.WaitForExit(3000).ShouldBeFalse($"The node should be alive since it is the sidecar node."); + sidecarTaskHostNode.Kill(); + } + catch (Exception e) + { + processTracker.PrintSummary(_output); + e.Message.ShouldNotBe($"Process with an Id of {pidSidecar} is not running"); + } } - // This is the sidecar TaskHost case - it should persist after build is done. So we need to clean up and kill it ourselves. - Process taskHostNode2 = Process.GetProcessById(pidSidecar); - taskHostNode2.WaitForExit(3000).ShouldBeFalse($"The node should be alive since it is the sidecar node."); - taskHostNode2.Kill(); } } + /// + /// Verifies that various parameter types can be correctly transmitted to and received from + /// a task host process, ensuring proper serialization/deserialization of all supported types. + /// Tests include primitive types, arrays, strings, dates, enums, and custom structures. + /// [Fact] - private void VariousParameterTypesCanBeTransmittedToAndReceivedFromTaskHost() + public void VariousParameterTypesCanBeTransmittedToAndReceivedFromTaskHost() { using TestEnvironment env = TestEnvironment.Create(_output); @@ -282,5 +332,177 @@ private void VariousParameterTypesCanBeTransmittedToAndReceivedFromTaskHost() projectInstance.GetPropertyValue("CustomStructOutput").ShouldBe(TaskBuilderTestTask.s_customStruct.ToString(CultureInfo.InvariantCulture)); projectInstance.GetPropertyValue("EnumOutput").ShouldBe(TargetBuiltReason.BeforeTargets.ToString()); } + + /// + /// Helper class for tracking external processes during tests. + /// Monitors process lifecycle and provides diagnostic information for debugging. + /// + internal sealed class ProcessTracker : IDisposable + { + private readonly List _trackedProcesses = new(); + + /// + /// Attaches to an existing process for monitoring. + /// + /// Process ID to attach to + /// Friendly name for the process + /// Test output helper for logging + /// TrackedProcess instance for the attached process + public TrackedProcess AttachToProcess(int pid, string name, ITestOutputHelper output) + { + try + { + var process = Process.GetProcessById(pid); + var tracked = new TrackedProcess(process, name); + + // Enable event notifications + process.EnableRaisingEvents = true; + + // Subscribe to exit event + process.Exited += (sender, e) => + { + var proc = sender as Process; + tracked.ExitTime = DateTime.Now; + tracked.ExitCode = proc?.ExitCode ?? -999; + tracked.HasExited = true; + + output.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {tracked.Name} (PID {tracked.ProcessId}) EXITED with code {tracked.ExitCode}"); + }; + + _trackedProcesses.Add(tracked); + output.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] Attached to {name} (PID {pid})"); + + return tracked; + } + catch (ArgumentException) + { + output.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] Could not attach to {name} (PID {pid}) - process not found"); + return new TrackedProcess(null, name) { ProcessId = pid, NotFound = true }; + } + } + + /// + /// Prints a summary of all tracked processes for diagnostic purposes. + /// + /// Test output helper for logging + public void PrintSummary(ITestOutputHelper output) + { + output.WriteLine("\n=== PROCESS TRACKING SUMMARY ==="); + foreach (var tracked in _trackedProcesses) + { + tracked.PrintStatus(output); + } + } + + public void Dispose() + { + foreach (var tracked in _trackedProcesses) + { + tracked.Dispose(); + } + } + } + + /// + /// Represents a tracked process with lifecycle monitoring capabilities. + /// + internal sealed class TrackedProcess : IDisposable + { + /// + /// The underlying Process object being tracked. + /// + public Process Process { get; } + + /// + /// Friendly name for the tracked process. + /// + public string Name { get; } + + /// + /// Process ID of the tracked process. + /// + public int ProcessId { get; set; } + + /// + /// Time when tracking began for this process. + /// + public DateTime AttachTime { get; } + + /// + /// Time when the process exited, if applicable. + /// + public DateTime? ExitTime { get; set; } + + /// + /// Exit code of the process, if it has exited. + /// + public int? ExitCode { get; set; } + + /// + /// Whether the process has exited. + /// + public bool HasExited { get; set; } + + /// + /// Whether the process was not found when attempting to attach. + /// + public bool NotFound { get; set; } + + public TrackedProcess(Process process, string name) + { + Process = process; + Name = name; + ProcessId = process?.Id ?? -1; + AttachTime = DateTime.Now; + } + + /// + /// Prints detailed status information about the tracked process. + /// + /// Test output helper for logging + public void PrintStatus(ITestOutputHelper output) + { + output.WriteLine($"\n{Name} (PID {ProcessId}):"); + output.WriteLine($" Attached at: {AttachTime:HH:mm:ss.fff}"); + + if (NotFound) + { + output.WriteLine(" Status: Not found when trying to attach"); + } + else if (HasExited) + { + var duration = (ExitTime.Value - AttachTime).TotalMilliseconds; + output.WriteLine($" Status: Exited with code {ExitCode}"); + output.WriteLine($" Exit time: {ExitTime:HH:mm:ss.fff}"); + output.WriteLine($" Duration: {duration:F0}ms"); + } + else + { + try + { + if (Process != null && !Process.HasExited) + { + output.WriteLine(" Status: Still running"); + output.WriteLine($" Start time: {Process.StartTime:HH:mm:ss.fff}"); + output.WriteLine($" CPU time: {Process.TotalProcessorTime.TotalMilliseconds:F0}ms"); + } + else + { + output.WriteLine(" Status: Exited (detected during status check)"); + if (Process != null) + { + output.WriteLine($" Exit code: {Process.ExitCode}"); + } + } + } + catch (Exception ex) + { + output.WriteLine($" Status: Error checking process - {ex.Message}"); + } + } + } + + public void Dispose() => Process?.Dispose(); + } } }