diff --git a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs index a11f0472571..a2e9e19e42d 100644 --- a/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs +++ b/src/Build.UnitTests/BackEnd/TaskHostFactory_Tests.cs @@ -87,7 +87,34 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab try { Process taskHostNode = Process.GetProcessById(pid); - taskHostNode.WaitForExit(3000).ShouldBeTrue("The process with taskHostNode is still running."); + + // Capture identity up-front so a PID-reuse race (the OS recycled this + // pid to an unrelated process between build-end and GetProcessById) is + // visible in the failure diagnostic rather than looking like the task + // host hung. + string capturedName = SafeGetProcessField(() => taskHostNode.ProcessName); + string capturedStart = SafeGetProcessField(() => taskHostNode.StartTime.ToString("O", CultureInfo.InvariantCulture)); + + // The task host should exit shortly after the build completes. Use a generous + // timeout because slow CI agents have been observed to take up to ~10s for the + // child process to drain stdio and exit. + // TELEMETRY: elapsedMs is logged so a future iteration can tune this back down + // to a tight-but-safe value. If observed elapsed never approaches the timeout, + // shrink TaskHostExitTimeoutMs in a follow-up PR. + const int TaskHostExitTimeoutMs = 15000; + Stopwatch sw = Stopwatch.StartNew(); + bool exited = taskHostNode.WaitForExit(TaskHostExitTimeoutMs); + sw.Stop(); + _output.WriteLine( + $"TaskHostFactory wait: pid={pid} processName={capturedName} startTime={capturedStart} " + + $"exited={exited} elapsedMs={sw.ElapsedMilliseconds} timeoutMs={TaskHostExitTimeoutMs}"); + + // Wrap HasExited in SafeGetProcessField — Process.HasExited can throw on + // access-denied / transient handle failures, and the message is evaluated + // eagerly even when the assertion passes. + exited.ShouldBeTrue( + $"TaskHost (pid={pid}, name={capturedName}, started={capturedStart}) was still running after {TaskHostExitTimeoutMs}ms. " + + $"elapsedMs={sw.ElapsedMilliseconds} HasExited={SafeGetProcessField(() => taskHostNode.HasExited.ToString())}"); } // We expect the TaskHostNode to exit quickly. If it exits before Process.GetProcessById, it will throw an ArgumentException. @@ -148,6 +175,21 @@ public void TaskNodesDieAfterBuild(bool taskHostFactorySpecified, bool envVariab } } + // Some Process fields (ProcessName, StartTime) can throw if the process + // has already exited or access is denied. We capture them best-effort for + // diagnostic output; never let a diagnostic read fail the test. + private static string SafeGetProcessField(Func read) + { + try + { + return read(); + } + catch (Exception ex) + { + return $""; + } + } + /// /// Verifies that transient (TaskHostFactory) and sidecar (AssemblyTaskFactory) task hosts /// can coexist in the same build and operate independently.