Skip to content

Pin the MSBuild debug dump directory per test assembly so node crash dumps are found - #14980

Closed
AR-May wants to merge 1 commit into
dotnet:mainfrom
AR-May:ar-may-per-assembly-dump-dir
Closed

AR-May wants to merge 1 commit into
dotnet:mainfrom
AR-May:ar-may-per-assembly-dump-dir

Conversation

@AR-May

@AR-May AR-May commented Sep 8, 2026

Copy link
Copy Markdown
Member

What happens today

Test assemblies run concurrently as separate processes, because MSBuild builds the test projects in parallel (xunit.runner.json's maxParallelThreads/parallelizeTestCollections only disable parallelism within an assembly). Each assembly already gets its own temp root: MSBuildTestPipelineStartup calls TestEnvironment.SetTempPath(...), which sets TMP/TEMP before any test runs.

The debug dump directory, though, is resolved independently per process. With MSBUILDDEBUGPATH unset — which is the case in tests today — FrameworkDebugUtils.DebugPath is null, so DebugUtils.DebugDumpPath falls back to FileUtilities.TempFileDirectory. That value comes from FileUtilities.CreateFolderUnderTemp(), which is Directory.CreateTempSubdirectory("MSBuildTemp") on .NET and Path.Combine(Path.GetTempPath(), $"MSBuildTemp{Guid.NewGuid():N}") on .NET Framework. Either way it is a fresh randomly-named subdirectory, created once per process.

So a parent test process and the out-of-proc nodes it spawns resolve different dump directories, both nested one level below the temp root that BuildFailureLogInvariant scans.

Measured in a real test run (Microsoft.Build.Engine.UnitTests, pid 4752), printed from inside a test:

FileUtilities.TempFileDirectory = C:\Users\alinama\AppData\Local\Temp\5kbnthy4.fzp\MSBuildTempzym3i3a4.3oz\
Path.GetTempPath()              = C:\Users\alinama\AppData\Local\Temp\5kbnthy4.fzp\

Path.GetTempPath() is the assembly's own temp root, not the machine-wide temp folder — SetTempPath has already redirected it. The two locations the invariant scans are the parent's random MSBuildTemp* subdirectory and its parent directory; a child node's own MSBuildTemp* subdirectory is neither.

Consequence: an out-of-process node crash dump can land in a directory the invariant never scans, so the crash is missed rather than attributed to the wrong test.

Evidential status

The parent-side paths above are measured and quoted verbatim. The child-node behaviour is inferred from CreateFolderUnderTemp() plus those measured paths — no real out-of-proc node crash was reproduced for this PR. An earlier framing of this problem as "all assemblies share one machine-wide temp folder" was an assumption and is not what the measurements show; it has been dropped.

The fix

Set MSBUILDDEBUGPATH once per assembly, in MSBuildTestPipelineStartup before any test runs, to a directory named after the assembly and process. Child nodes inherit the variable when they are spawned, so the parent and every node it starts resolve the same durable dump directory, and BuildFailureLogInvariant scans exactly that directory.

Only the path is set. Tracing stays off — it is gated separately on MSBuildDebugEngine/MSBUILDDEBUGENGINE and MSBUILDDEBUGCOMM via Traits. Every debug-file writer (BuildRequestEngine.TraceEngine, Scheduler, CoordinatorServer.DefaultDebugOutput, CommunicationsUtilities.Trace, the XMake deferred message) is gated on one of those flags, never on DebugPath being non-null.

As a secondary benefit, dumps that are found can no longer have come from a concurrently running assembly. Attribution within an assembly is still approximate, because MSBuild reuses nodes across tests.

Implementation notes

Three details forced small deviations from the most direct version of this change.

1. The DebugUtils reset lives in Microsoft.Build.UnitTests.Shared, not in TestAssemblyInfo.cs. Microsoft.Build.Shared.Debugging.DebugUtils is internal and compiled into Microsoft.Build, Microsoft.Build.Tasks, Microsoft.Build.Utilities and MSBuild. TestAssemblyInfo.cs is compiled into every non-library test project, including Microsoft.Build.EndToEnd.Tests, which has no InternalsVisibleTo grant from any of them — only from Microsoft.Build.Framework. Referencing DebugUtils there would not compile. The logic therefore sits in a new TestEnvironment.UseIsolatedDebugPath helper, which Microsoft.Build.UnitTests.Shared can express (it has grants from both Framework and Build) and which every test project already references.

2. FrameworkDebugUtils.SetDebugPath() has to be called too. FrameworkDebugUtils.DebugPath is computed in a static constructor and is one of the locations the invariant scans. Setting MSBUILDDEBUGPATH without refreshing it would leave DebugPath null, so the new directory would never be scanned and the invariant would silently stop detecting crashes.

3. BuildFailureLogInvariant scans a startup-captured path. The Path.GetTempPath() block is replaced by one scanning TestEnvironment.AssemblyDebugPath, a static captured once per process, rather than relying only on FrameworkDebugUtils.DebugPath. Several tests repoint MSBUILDDEBUGPATH temporarily, and at least one — BuildManager_Tests.MultiThreadedBuild_WithDebugSchedulerTracing_DoesNotDeadlock — never calls SetDebugPath() again on cleanup, leaving DebugPath pointing at a deleted folder for the rest of the assembly. A startup-captured path cannot be mutated by a test, so the invariant stays reliable regardless.

TransientDebugEngine set MSBUILDDEBUGPATH to FileUtilities.TempFileDirectory when enabling, which would have overridden the isolation for its caller. It now sets it to FrameworkDebugUtils.DebugPath, falling back to the previous value when that is somehow unset. Its save/restore behaviour and its else branch are unchanged.

The directory is created under the machine temp folder — deliberately not under the per-assembly temp folder, which is swapped out by per-test TransientTempPath and deleted at teardown — and is removed best-effort in StopAsync.

Validation

Planted-dump verification (strongest evidence here). A throwaway test wrote MSBuild_pid-9999_deadbeef.failure.txt into the resolved dump directory and returned, letting BuildFailureLogInvariant.AssertInvariant run. It failed, as intended:

Xunit.MicrosoftTestingPlatform.XunitException: Assert.Equal() Failure: Values differ
Expected: 0
Actual:   1
  at Microsoft.Build.UnitTests.BuildFailureLogInvariant.AssertInvariant(ITestOutputHelper output) in src\UnitTests.Shared\TestEnvironment.cs:649
  at Microsoft.Build.UnitTests.TestEnvironment.Cleanup() in src\UnitTests.Shared\TestEnvironment.cs:154
  at Microsoft.Build.UnitTests.TestEnvironment.Dispose() in src\UnitTests.Shared\TestEnvironment.cs:127
  at Microsoft.Build.UnitTests.MSBuildTestFramework.MSBuildTestCase.Run(...) in src\Shared\UnitTests\TestAssemblyInfo.cs:175

and named the file in the invariant's own output:

Build Error File MSBuild_pid-9999_deadbeef.failure.txt: PLANTED BY VERIFICATION TEST - simulated worker node crash. AssemblyDebugPath=C:\Users\alinama\AppData\Local\Temp\MSBuildTests\Microsoft.Build.Engine.UnitTests_4752 FrameworkDebugUtils.DebugPath=C:\Users\alinama\AppData\Local\Temp\MSBuildTests\Microsoft.Build.Engine.UnitTests_4752 ...

This exercises the whole chain end to end — directory resolution, the MSBuild_*.txt glob, Except, and the assert — rather than assuming it. The test was removed afterwards; it is not part of this PR.

A second throwaway test (also removed) confirmed the path wiring in a live run: MSBUILDDEBUGPATH, FrameworkDebugUtils.DebugPath and DebugUtils.DebugDumpPath all resolve to the per-assembly directory, Traits.DebugEngine stays false, and the directory is deleted at teardown.

Other checks:

  • Full repo build succeeds with no new warnings, for both net11.0 and net472, including the assemblies with the narrowest InternalsVisibleTo grants (EndToEnd.Tests, Utilities.UnitTests, Tasks.UnitTests, MSBuild.UnitTests).
  • DebugUtils_Tests 6/6 and MSBuildServer_Tests.PropertyMSBuildStartupDirectoryOnServer — the only TransientDebugEngine caller — pass. Every test in DebugUtils_tests.cs sets and restores MSBUILDDEBUGPATH explicitly, so none depends on the ambient value; SetDebugPath_WhenUserNotSetDebugPath nulls it first.
  • Microsoft.Build.Framework.UnitTests passes in full (1103 passed, 37 platform skips).
  • The full test suite was not run locally.

Test assemblies run concurrently as separate processes and all wrote MSBuild
debug and crash files into the same machine-wide temp folder, which
BuildFailureLogInvariant scans to detect out-of-process node crashes. A crash
caused by one assembly could therefore be blamed on a test in another one.

Set MSBUILDDEBUGPATH once per process in MSBuildTestPipelineStartup so each
test assembly gets its own directory. Only the path is set - tracing stays
gated on MSBuildDebugEngine and MSBUILDDEBUGCOMM.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@AR-May AR-May changed the title Give each test assembly its own MSBuild debug dump directory Pin the MSBuild debug dump directory per test assembly so node crash dumps are found Sep 8, 2026
@AR-May AR-May self-assigned this Sep 11, 2026
@AR-May AR-May closed this Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant