From d6f926928ac8b2df2e5e50e2bd3b526ca3aa68ad Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 10:42:56 +0100 Subject: [PATCH 01/13] Use MSBuildToolsDirectoryRoot for task host handshake to fix macOS MSB4216 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, /tmp is a symlink to /private/tmp. PR #13175 changed ResolveAppHostOrFallback to pass an explicit toolsDirectory to the parent's Handshake constructor, sourced from the MSBuild property (NetCoreSdkRoot = MSBuildThisFileDirectory). The child task host process computes its toolsDirectory from AppContext.BaseDirectory via MSBuildToolsDirectoryRoot. On macOS, MSBuild properties preserve the unresolved /tmp form while AppContext.BaseDirectory resolves to /private/tmp. The different strings produce different hashes in the handshake salt, resulting in mismatched pipe names — the parent listens on one pipe, the child connects to another — causing MSB4216. Before #13175, both parent and child used MSBuildToolsDirectoryRoot as the default (no explicit toolsDirectory), so both resolved symlinks identically and the handshake matched. The fix uses BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot for the handshake toolsDirectory on the parent side — the same source the child uses — ensuring path strings match regardless of symlinks. File I/O operations continue to use the original msbuildAssemblyPath (both path forms work for filesystem access on macOS). Fixes https://github.com/dotnet/sdk/pull/53350#issuecomment-4078785888 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Communications/NodeProviderOutOfProcTaskHost.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs index a7987f124f7..6ae2a97827b 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs @@ -733,6 +733,14 @@ private NodeLaunchData ResolveAppHostOrFallback( string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName); string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled); + // Use the same tools directory source as the child process for the handshake. + // The child (NodeEndpointOutOfProcTaskHost) defaults to MSBuildToolsDirectoryRoot + // which is derived from AppContext.BaseDirectory. On macOS, the runtime resolves + // the /tmp → /private/tmp symlink, but MSBuild properties (like $(NetCoreSdkRoot)) + // preserve the unresolved form. Using different path forms produces different hashes + // in the handshake salt → pipe name mismatch → MSB4216. + string handshakeToolsDirectory = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot; + if (FileSystems.Default.FileExists(appHostPath)) { CommunicationsUtilities.Trace("For a host context of {0}, using app host from {1}.", hostContext, appHostPath); @@ -744,7 +752,7 @@ private NodeLaunchData ResolveAppHostOrFallback( : new NodeLaunchData( appHostPath, commandLineArgs, - new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath), + new Handshake(hostContext, toolsDirectory: handshakeToolsDirectory), dotnetOverrides); } @@ -762,7 +770,7 @@ private NodeLaunchData ResolveAppHostOrFallback( return new NodeLaunchData( resolvedDotnetHostPath, $"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}", - new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath)); + new Handshake(hostContext, toolsDirectory: handshakeToolsDirectory)); } private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} "; From c32f12df5b841dfb56314d5da5cdcc95f59e19db Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 10:50:53 +0100 Subject: [PATCH 02/13] Add test: parent and child task host handshake keys must match Regression test for macOS /tmp -> /private/tmp symlink issue. Verifies that ResolveAppHostOrFallback uses the same toolsDirectory source (MSBuildToolsDirectoryRoot) as the child process, ensuring the handshake hash and pipe name match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackEnd/AppHostSupport_Tests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index 1ce5dff91d6..d9f416c2404 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -155,5 +155,44 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario() Environment.GetEnvironmentVariable("DOTNET_ROOT_ARM64").ShouldBeNull(); // Was already null } } + + /// + /// Verifies that the handshake toolsDirectory used by the parent + /// (ResolveAppHostOrFallback) matches what the child process + /// (NodeEndpointOutOfProcTaskHost) computes via its GetHandshake(). + /// + /// Regression test for the macOS /tmp → /private/tmp symlink issue + /// where the parent used $(NetCoreSdkRoot) from MSBuild properties + /// (unresolved symlink) while the child used MSBuildToolsDirectoryRoot + /// from AppContext.BaseDirectory (resolved symlink), causing a + /// handshake hash mismatch → MSB4216. + /// + [Fact] + public void ResolveAppHostOrFallback_HandshakeMatchesChildHandshake() + { + // The parent's handshake should use MSBuildToolsDirectoryRoot — + // the same source the child (NodeEndpointOutOfProcTaskHost) defaults to. + string parentToolsDir = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot; + + // Simulate what the child does in NodeEndpointOutOfProcTaskHost.GetHandshake(): + // new Handshake(options) with no explicit toolsDirectory → defaults to MSBuildToolsDirectoryRoot. + HandshakeOptions childOptions = CommunicationsUtilities.GetHandshakeOptions( + taskHost: true, + taskHostParameters: TaskHostParameters.Empty, + nodeReuse: false); + + var childHandshake = new Handshake(childOptions); + + // Simulate what the parent does in ResolveAppHostOrFallback (after the fix): + // new Handshake(hostContext, toolsDirectory: MSBuildToolsDirectoryRoot) + var parentHandshake = new Handshake(childOptions, parentToolsDir); + + // The handshake keys must match for the pipe connection to succeed. + parentHandshake.GetKey().ShouldBe(childHandshake.GetKey(), + "Parent and child handshake keys must match. A mismatch here causes MSB4216 " + + "because the parent and child listen/connect on different pipe names. " + + "On macOS, this can happen when symlinks (like /tmp → /private/tmp) cause " + + "the toolsDirectory to have different string representations."); + } } } From d06967965e21a1921d382c1f832b300766a7349b Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 10:56:52 +0100 Subject: [PATCH 03/13] Add symlink test proving handshake mismatch causes MSB4216 Creates a real symlink on Unix and proves that using the symlink path vs the resolved real path in the Handshake constructor produces different keys. This is the exact bug on macOS where /tmp -> /private/tmp: the parent used the unresolved MSBuild property path while the child used the resolved AppContext.BaseDirectory path. The test also verifies the fix: using MSBuildToolsDirectoryRoot on both sides always produces matching keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackEnd/AppHostSupport_Tests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index d9f416c2404..a3d74dd270f 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -194,5 +194,68 @@ public void ResolveAppHostOrFallback_HandshakeMatchesChildHandshake() "On macOS, this can happen when symlinks (like /tmp → /private/tmp) cause " + "the toolsDirectory to have different string representations."); } + + /// + /// Proves that using a symlinked path vs a resolved path in the handshake + /// produces DIFFERENT keys — demonstrating the exact bug on macOS where + /// /tmp is a symlink to /private/tmp. + /// + /// This test creates a real symlink to prove the mismatch. It only runs on + /// Unix where symlinks are natively supported and the scenario is relevant. + /// + [UnixOnlyFact] + public void Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey() + { + // Create a real directory and a symlink pointing to it. + string realDir = Path.Combine(Path.GetTempPath(), $"msbuild_test_real_{Guid.NewGuid():N}"); + string symlinkDir = Path.Combine(Path.GetTempPath(), $"msbuild_test_link_{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(realDir); + Directory.CreateSymbolicLink(symlinkDir, realDir); + + HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions( + taskHost: true, + taskHostParameters: TaskHostParameters.Empty, + nodeReuse: false); + + // Parent using the symlink path (like $(MSBuildThisFileDirectory) would on macOS /tmp) + var symlinkHandshake = new Handshake(options, symlinkDir); + + // Child using the resolved real path (like AppContext.BaseDirectory resolves /private/tmp) + var realHandshake = new Handshake(options, realDir); + + // These produce DIFFERENT keys — this is the bug. + // If these were used as parent vs child toolsDirectory, the pipe names would + // differ and the parent could never connect to the child → MSB4216. + symlinkHandshake.GetKey().ShouldNotBe(realHandshake.GetKey(), + "Symlinked and resolved paths should produce different handshake keys " + + "(they are different strings). This demonstrates why the parent must NOT " + + "use an MSBuild property path that may contain unresolved symlinks — it " + + "must use MSBuildToolsDirectoryRoot (same source as the child) instead."); + + // Using the SAME source (MSBuildToolsDirectoryRoot) on both sides always matches, + // regardless of symlinks, because both compute it from AppContext.BaseDirectory. + string consistentDir = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot; + var parentFixed = new Handshake(options, consistentDir); + var childDefault = new Handshake(options); + + parentFixed.GetKey().ShouldBe(childDefault.GetKey(), + "Using MSBuildToolsDirectoryRoot on both sides must produce matching keys."); + } + finally + { + if (Directory.Exists(symlinkDir)) + { + Directory.Delete(symlinkDir); + } + + if (Directory.Exists(realDir)) + { + Directory.Delete(realDir); + } + } + } } } From 885e3314dd622441361e8b8ffab80235b18764d7 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 10:59:02 +0100 Subject: [PATCH 04/13] Revert fix to validate tests catch the symlink regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporarily revert the handshake fix to confirm that: 1. Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey passes (proving symlink paths produce different keys) 2. ResolveAppHostOrFallback_HandshakeMatchesChildHandshake passes (this uses MSBuildToolsDirectoryRoot on both sides, so it passes regardless — it validates the fix approach, not the bug) If the symlink test passes on macOS CI, it confirms that the /tmp -> /private/tmp symlink causes handshake key mismatches, proving the root cause of the MSB4216 failures. The fix should be re-applied after validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Communications/NodeProviderOutOfProcTaskHost.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs index 6ae2a97827b..a7987f124f7 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs @@ -733,14 +733,6 @@ private NodeLaunchData ResolveAppHostOrFallback( string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName); string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled); - // Use the same tools directory source as the child process for the handshake. - // The child (NodeEndpointOutOfProcTaskHost) defaults to MSBuildToolsDirectoryRoot - // which is derived from AppContext.BaseDirectory. On macOS, the runtime resolves - // the /tmp → /private/tmp symlink, but MSBuild properties (like $(NetCoreSdkRoot)) - // preserve the unresolved form. Using different path forms produces different hashes - // in the handshake salt → pipe name mismatch → MSB4216. - string handshakeToolsDirectory = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot; - if (FileSystems.Default.FileExists(appHostPath)) { CommunicationsUtilities.Trace("For a host context of {0}, using app host from {1}.", hostContext, appHostPath); @@ -752,7 +744,7 @@ private NodeLaunchData ResolveAppHostOrFallback( : new NodeLaunchData( appHostPath, commandLineArgs, - new Handshake(hostContext, toolsDirectory: handshakeToolsDirectory), + new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath), dotnetOverrides); } @@ -770,7 +762,7 @@ private NodeLaunchData ResolveAppHostOrFallback( return new NodeLaunchData( resolvedDotnetHostPath, $"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}", - new Handshake(hostContext, toolsDirectory: handshakeToolsDirectory)); + new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath)); } private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} "; From c46c55d90657359dd0100d79498cb5ab2614b8c1 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 12:18:27 +0100 Subject: [PATCH 05/13] Fix test compilation: add missing using Microsoft.Build.Framework TaskHostParameters is in the Microsoft.Build.Framework namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index a3d74dd270f..986bc1b14dc 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using Microsoft.Build.BackEnd; +using Microsoft.Build.Framework; using Microsoft.Build.Internal; using Microsoft.Build.Shared; using Microsoft.Build.UnitTests; From dfd46ffdd615f6215cb03e618c4522febca2564f Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 13:39:34 +0100 Subject: [PATCH 06/13] Guard symlink test with #if NET for .NET Framework compatibility Directory.CreateSymbolicLink is only available in .NET 7+. The test is already [UnixOnlyFact] so it only runs on macOS/Linux .NET Core builds. The #if NET guard prevents compilation errors on .NET Framework (Windows Full/Core) builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index 986bc1b14dc..9fb3caea9d9 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -202,8 +202,9 @@ public void ResolveAppHostOrFallback_HandshakeMatchesChildHandshake() /// /tmp is a symlink to /private/tmp. /// /// This test creates a real symlink to prove the mismatch. It only runs on - /// Unix where symlinks are natively supported and the scenario is relevant. + /// Unix (.NET Core) where symlinks are natively supported and the scenario is relevant. /// +#if NET [UnixOnlyFact] public void Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey() { @@ -258,5 +259,6 @@ public void Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey() } } } +#endif } } From b6bfe5a206b9419a513275b12a6276b88796c756 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 13:48:38 +0100 Subject: [PATCH 07/13] Add E2E test: TaskHostFactory through symlinked SDK path Creates a symlink to the bootstrap SDK directory and launches the MSBuild apphost through it. This reproduces the macOS /tmp -> /private/tmp regression where the parent handshake uses the unresolved symlink path from MSBuild properties while the child resolves to the real path via AppContext.BaseDirectory -> handshake mismatch -> MSB4216. Without the fix (reverted in this PR), this test should FAIL on macOS CI with MSB4216, proving the symlink is the root cause. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/NetTaskHost_E2E_Tests.cs | 74 ++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs index 70a8d38733d..3322590aa26 100644 --- a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs +++ b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs @@ -274,5 +274,79 @@ public void NetTaskWithImplicitHostParamsTest_AppHost() testTaskOutput.ShouldContain("The task is executed in process: MSBuild"); testTaskOutput.ShouldContain("/nodereuse:True"); } + +#if NET + /// + /// Regression test: proves that launching the MSBuild task host through a symlinked + /// SDK path causes MSB4216 due to handshake mismatch. + /// + /// On macOS, /tmp is a symlink to /private/tmp. When the SDK is under /tmp, the + /// MSBuild property $(NetCoreSdkRoot) = $(MSBuildThisFileDirectory) preserves the + /// unresolved /tmp form. But the child task host's AppContext.BaseDirectory resolves + /// to /private/tmp. The parent and child compute different handshake hashes → different + /// pipe names → MSB4216. + /// + /// This test recreates the scenario by symlinking the bootstrap SDK directory and + /// running MSBuild through the symlink. + /// + [UnixOnlyFact] + public void NetTaskHost_SymlinkedSdkPath_ShouldNotCauseMSB4216() + { + using TestEnvironment env = TestEnvironment.Create(_output); + + // Create a symlink pointing to the bootstrap SDK binary location. + // This simulates the macOS /tmp → /private/tmp scenario. + string realSdkPath = RunnerUtilities.BootstrapMsBuildBinaryLocation; + string symlinkPath = Path.Combine(Path.GetTempPath(), $"msbuild_symlink_test_{Guid.NewGuid():N}"); + + try + { + Directory.CreateSymbolicLink(symlinkPath, realSdkPath); + + // Launch the MSBuild apphost through the symlink path. + // This causes $(MSBuildThisFileDirectory) to use the symlink form, + // while the child's AppContext.BaseDirectory resolves to the real path. + string apphostPath = Path.Combine(symlinkPath, "sdk", RunnerUtilities.BootstrapSdkVersion, Constants.MSBuildExecutableName); + + if (!File.Exists(apphostPath)) + { + _output.WriteLine($"[SKIP] MSBuild apphost not found at: {apphostPath}"); + return; + } + + string testProjectPath = Path.Combine(TestAssetsRootPath, "ExampleNetTask", "TestNetTask", "TestNetTask.csproj"); + + string testTaskOutput = RunnerUtilities.RunProcessAndGetOutput( + apphostPath, + $"\"{testProjectPath}\" -restore -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild}", + out bool successTestTask, + outputHelper: _output, + environmentVariables: new Dictionary + { + [Constants.DotnetHostPathEnvVarName] = Path.Combine(realSdkPath, "dotnet"), + }); + + _output.WriteLine(testTaskOutput); + + // Without the fix, this fails with MSB4216 because the parent's handshake + // uses the symlink path from $(NetCoreSdkRoot) while the child resolves + // to the real path via AppContext.BaseDirectory. + testTaskOutput.ShouldNotContain("MSB4216", + "Task host connection should succeed even when MSBuild is launched " + + "through a symlinked path. MSB4216 here indicates a handshake mismatch " + + "between parent (symlink path) and child (resolved path)."); + + successTestTask.ShouldBeTrue( + "TaskHostFactory task should execute successfully when MSBuild runs from a symlinked SDK path."); + } + finally + { + if (Directory.Exists(symlinkPath)) + { + Directory.Delete(symlinkPath); + } + } + } +#endif } } From 72e190dd17943f1b5027d089e1e4645bae78026d Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Wed, 18 Mar 2026 14:09:42 +0100 Subject: [PATCH 08/13] Fix E2E test compilation: add missing using and fix Shouldly API - Add using System.Collections.Generic for Dictionary - Remove custom message from ShouldNotContain (Shouldly string overload interprets second arg as customMessage differently) - Add explicit shellExecute parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/NetTaskHost_E2E_Tests.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs index 3322590aa26..5a5f68db16b 100644 --- a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs +++ b/src/Build.UnitTests/NetTaskHost_E2E_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.IO; using Microsoft.Build.Framework; using Microsoft.Build.Internal; @@ -320,6 +321,7 @@ public void NetTaskHost_SymlinkedSdkPath_ShouldNotCauseMSB4216() apphostPath, $"\"{testProjectPath}\" -restore -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild}", out bool successTestTask, + shellExecute: false, outputHelper: _output, environmentVariables: new Dictionary { @@ -331,10 +333,7 @@ public void NetTaskHost_SymlinkedSdkPath_ShouldNotCauseMSB4216() // Without the fix, this fails with MSB4216 because the parent's handshake // uses the symlink path from $(NetCoreSdkRoot) while the child resolves // to the real path via AppContext.BaseDirectory. - testTaskOutput.ShouldNotContain("MSB4216", - "Task host connection should succeed even when MSBuild is launched " + - "through a symlinked path. MSB4216 here indicates a handshake mismatch " + - "between parent (symlink path) and child (resolved path)."); + testTaskOutput.ShouldNotContain("MSB4216"); successTestTask.ShouldBeTrue( "TaskHostFactory task should execute successfully when MSBuild runs from a symlinked SDK path."); From ac5f5b1af31cfcc6d3834fdfd882242ee97a5584 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Thu, 19 Mar 2026 07:41:58 +0100 Subject: [PATCH 09/13] Fix macOS symlink handshake mismatch in .NET task host (MSB4216) Remove explicit toolsDirectory from Handshake in ResolveAppHostOrFallback. Root cause: On macOS, /tmp is a symlink to /private/tmp. The parent process passed toolsDirectory from $(NetCoreSdkRoot) MSBuild property (which preserves the unresolved symlink path /tmp/...), while the child task host computed its toolsDirectory from BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot (derived from AppContext.BaseDirectory, which resolves symlinks to /private/tmp/...). This produced different handshake hashes, causing the parent to fail to connect to the child task host process with MSB4216. Fix: Don't pass explicit toolsDirectory to the Handshake constructor. Both parent and child now default to BuildEnvironmentHelper, which consistently resolves symlinks via AppContext.BaseDirectory. Before PR #13175, neither side passed explicit toolsDirectory, so both defaulted to BuildEnvironmentHelper and always matched. PR #13175 introduced the asymmetry by passing on the parent side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Communications/NodeProviderOutOfProcTaskHost.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs index a7987f124f7..fcad612f435 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs @@ -733,6 +733,13 @@ private NodeLaunchData ResolveAppHostOrFallback( string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName); string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled); + // Do not pass explicit toolsDirectory to the Handshake. The child task host process + // computes its own toolsDirectory from BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot + // (derived from AppContext.BaseDirectory, which resolves symlinks). If we pass + // msbuildAssemblyPath here (from the $(NetCoreSdkRoot) MSBuild property), it may contain + // unresolved symlinks (e.g. /tmp/... vs /private/tmp/... on macOS) causing a handshake + // mismatch and MSB4216. By omitting it, both parent and child default to + // BuildEnvironmentHelper which resolves symlinks consistently. if (FileSystems.Default.FileExists(appHostPath)) { CommunicationsUtilities.Trace("For a host context of {0}, using app host from {1}.", hostContext, appHostPath); @@ -744,7 +751,7 @@ private NodeLaunchData ResolveAppHostOrFallback( : new NodeLaunchData( appHostPath, commandLineArgs, - new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath), + new Handshake(hostContext), dotnetOverrides); } @@ -762,7 +769,7 @@ private NodeLaunchData ResolveAppHostOrFallback( return new NodeLaunchData( resolvedDotnetHostPath, $"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}", - new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath)); + new Handshake(hostContext)); } private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} "; From 8f89b394b88e293975c2b64e3c9b273e081be8e7 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Thu, 19 Mar 2026 07:55:31 +0100 Subject: [PATCH 10/13] Use conditional compilation: keep toolsDirectory on .NET Framework, omit on .NET Core On .NET Framework (VS), parent and child MSBuild are in different directories (VS dir vs SDK dir), so explicit toolsDirectory is needed. Windows has no symlink issues. On .NET Core, parent and child are always from the same SDK, so both can safely default to BuildEnvironmentHelper (which resolves symlinks via AppContext.BaseDirectory). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NodeProviderOutOfProcTaskHost.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs index fcad612f435..b4f6a34cd67 100644 --- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs +++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs @@ -733,13 +733,25 @@ private NodeLaunchData ResolveAppHostOrFallback( string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName); string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled); - // Do not pass explicit toolsDirectory to the Handshake. The child task host process - // computes its own toolsDirectory from BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot - // (derived from AppContext.BaseDirectory, which resolves symlinks). If we pass - // msbuildAssemblyPath here (from the $(NetCoreSdkRoot) MSBuild property), it may contain - // unresolved symlinks (e.g. /tmp/... vs /private/tmp/... on macOS) causing a handshake - // mismatch and MSB4216. By omitting it, both parent and child default to + // The child task host (NodeEndpointOutOfProcTaskHost) computes its handshake + // toolsDirectory from BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot, + // which derives from AppContext.BaseDirectory (resolves symlinks). + // + // On .NET Framework, the parent MSBuild (VS) is in a different directory than the + // child .NET task host (SDK), so we must pass msbuildAssemblyPath explicitly to + // match the child's location. Windows has no symlink issues so this is safe. + // + // On .NET Core, parent and child are always from the same SDK directory. Passing + // msbuildAssemblyPath from $(NetCoreSdkRoot) can cause a handshake mismatch on + // macOS where /tmp → /private/tmp symlink means the property value differs from + // AppContext.BaseDirectory. By omitting toolsDirectory, both sides default to // BuildEnvironmentHelper which resolves symlinks consistently. +#if RUNTIME_TYPE_NETCORE + Handshake handshake = new Handshake(hostContext); +#else + Handshake handshake = new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath); +#endif + if (FileSystems.Default.FileExists(appHostPath)) { CommunicationsUtilities.Trace("For a host context of {0}, using app host from {1}.", hostContext, appHostPath); @@ -751,7 +763,7 @@ private NodeLaunchData ResolveAppHostOrFallback( : new NodeLaunchData( appHostPath, commandLineArgs, - new Handshake(hostContext), + handshake, dotnetOverrides); } @@ -769,7 +781,7 @@ private NodeLaunchData ResolveAppHostOrFallback( return new NodeLaunchData( resolvedDotnetHostPath, $"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}", - new Handshake(hostContext)); + handshake); } private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} "; From 22f91a3c5de5fea625cc3fdec632df542fd4c77b Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Thu, 19 Mar 2026 08:02:07 +0100 Subject: [PATCH 11/13] Update regression test to validate actual fix behavior The previous test was tautological - it passed BuildEnvironmentHelper as explicit toolsDirectory, which is the same as the default. It would pass with or without the fix. The updated test proves two things: 1. Both sides omitting toolsDirectory produces matching keys (the fix) 2. An external path (like NetCoreSdkRoot) produces a DIFFERENT key than the default (the bug mechanism) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BackEnd/AppHostSupport_Tests.cs | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index 9fb3caea9d9..69c597f1f2b 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -158,42 +158,45 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario() } /// - /// Verifies that the handshake toolsDirectory used by the parent - /// (ResolveAppHostOrFallback) matches what the child process - /// (NodeEndpointOutOfProcTaskHost) computes via its GetHandshake(). + /// Regression test for the macOS /tmp → /private/tmp symlink issue (MSB4216). /// - /// Regression test for the macOS /tmp → /private/tmp symlink issue - /// where the parent used $(NetCoreSdkRoot) from MSBuild properties - /// (unresolved symlink) while the child used MSBuildToolsDirectoryRoot - /// from AppContext.BaseDirectory (resolved symlink), causing a - /// handshake hash mismatch → MSB4216. + /// Before the fix, the parent passed $(NetCoreSdkRoot) as toolsDirectory — + /// an MSBuild property that can contain unresolved symlinks. The child always + /// defaults to BuildEnvironmentHelper (which resolves symlinks via + /// AppContext.BaseDirectory). This caused different handshake hashes. + /// + /// After the fix (on .NET Core), the parent also omits toolsDirectory, + /// so both sides default to BuildEnvironmentHelper. + /// + /// This test proves that an arbitrary external path (simulating $(NetCoreSdkRoot)) + /// CAN produce a different handshake than the BuildEnvironmentHelper default, + /// and that omitting toolsDirectory on both sides always matches. /// [Fact] - public void ResolveAppHostOrFallback_HandshakeMatchesChildHandshake() + public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches() { - // The parent's handshake should use MSBuildToolsDirectoryRoot — - // the same source the child (NodeEndpointOutOfProcTaskHost) defaults to. - string parentToolsDir = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot; - - // Simulate what the child does in NodeEndpointOutOfProcTaskHost.GetHandshake(): - // new Handshake(options) with no explicit toolsDirectory → defaults to MSBuildToolsDirectoryRoot. - HandshakeOptions childOptions = CommunicationsUtilities.GetHandshakeOptions( + HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions( taskHost: true, taskHostParameters: TaskHostParameters.Empty, nodeReuse: false); - var childHandshake = new Handshake(childOptions); - - // Simulate what the parent does in ResolveAppHostOrFallback (after the fix): - // new Handshake(hostContext, toolsDirectory: MSBuildToolsDirectoryRoot) - var parentHandshake = new Handshake(childOptions, parentToolsDir); - - // The handshake keys must match for the pipe connection to succeed. - parentHandshake.GetKey().ShouldBe(childHandshake.GetKey(), - "Parent and child handshake keys must match. A mismatch here causes MSB4216 " + - "because the parent and child listen/connect on different pipe names. " + - "On macOS, this can happen when symlinks (like /tmp → /private/tmp) cause " + - "the toolsDirectory to have different string representations."); + // Simulate child: no explicit toolsDirectory → defaults to BuildEnvironmentHelper. + var childHandshake = new Handshake(options); + + // After the fix: parent also omits toolsDirectory → same default → must match. + var parentFixedHandshake = new Handshake(options); + parentFixedHandshake.GetKey().ShouldBe(childHandshake.GetKey(), + "When both parent and child omit toolsDirectory, they must produce " + + "identical handshake keys (both default to BuildEnvironmentHelper)."); + + // Before the fix: parent passed an external path ($(NetCoreSdkRoot)). + // If that path differs from BuildEnvironmentHelper (e.g. symlinks), + // the handshake would mismatch. + string externalPath = Path.Combine(Path.GetTempPath(), $"different_path_{Guid.NewGuid():N}"); + var parentBrokenHandshake = new Handshake(options, externalPath); + parentBrokenHandshake.GetKey().ShouldNotBe(childHandshake.GetKey(), + "An arbitrary external toolsDirectory should produce a different handshake " + + "than the BuildEnvironmentHelper default, proving the mismatch scenario."); } /// From 8fad6b093961de975fc74a2fcd5d6ac108caa27e Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Thu, 19 Mar 2026 08:14:29 +0100 Subject: [PATCH 12/13] Address review comments: guard test with #if NET, fail on missing apphost - Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches: guard with #if NET and use explicit NET runtime TaskHostParameters so the NET HandshakeOptions flag is set, avoiding VerifyThrow on .NET Framework. - NetTaskHost_SymlinkedSdkPath: replace silent return with Assert.Fail when apphost is missing, so the test doesn't silently pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs | 12 +++++++++++- src/Build.UnitTests/NetTaskHost_E2E_Tests.cs | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index 69c597f1f2b..28beb08f84a 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -172,12 +172,21 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario() /// CAN produce a different handshake than the BuildEnvironmentHelper default, /// and that omitting toolsDirectory on both sides always matches. /// +#if NET [Fact] public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches() { + // Use explicit NET runtime to ensure the NET HandshakeOptions flag is set, + // which is required for passing toolsDirectory to the Handshake constructor. + var netTaskHostParams = new TaskHostParameters( + runtime: XMakeAttributes.MSBuildRuntimeValues.net, + architecture: null, + dotnetHostPath: null, + msBuildAssemblyPath: null); + HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions( taskHost: true, - taskHostParameters: TaskHostParameters.Empty, + taskHostParameters: netTaskHostParams, nodeReuse: false); // Simulate child: no explicit toolsDirectory → defaults to BuildEnvironmentHelper. @@ -198,6 +207,7 @@ public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches() "An arbitrary external toolsDirectory should produce a different handshake " + "than the BuildEnvironmentHelper default, proving the mismatch scenario."); } +#endif /// /// Proves that using a symlinked path vs a resolved path in the handshake diff --git a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs index 5a5f68db16b..4e52d3243b0 100644 --- a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs +++ b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs @@ -311,8 +311,10 @@ public void NetTaskHost_SymlinkedSdkPath_ShouldNotCauseMSB4216() if (!File.Exists(apphostPath)) { - _output.WriteLine($"[SKIP] MSBuild apphost not found at: {apphostPath}"); - return; + // If the apphost isn't present, we can't test the symlink scenario. + // Fail explicitly so this doesn't silently pass in broken environments. + Assert.Fail($"MSBuild apphost not found at: {apphostPath}. " + + "The bootstrap layout must include the MSBuild apphost for this test."); } string testProjectPath = Path.Combine(TestAssetsRootPath, "ExampleNetTask", "TestNetTask", "TestNetTask.csproj"); From 596d4d4952e45a6f91f0339d7fa015d0eed35e76 Mon Sep 17 00:00:00 2001 From: YuliiaKovalova Date: Thu, 19 Mar 2026 08:33:50 +0100 Subject: [PATCH 13/13] Fix test: provide explicit architecture for GetHandshakeOptions GetHandshakeOptions requires non-null architecture when taskHostParameters has a non-null runtime. Use GetCurrentMSBuildArchitecture(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs index 28beb08f84a..87de6e545c2 100644 --- a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs +++ b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs @@ -176,11 +176,12 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario() [Fact] public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches() { - // Use explicit NET runtime to ensure the NET HandshakeOptions flag is set, - // which is required for passing toolsDirectory to the Handshake constructor. + // Use explicit NET runtime and current architecture to ensure the NET + // HandshakeOptions flag is set, which is required for passing toolsDirectory + // to the Handshake constructor. var netTaskHostParams = new TaskHostParameters( runtime: XMakeAttributes.MSBuildRuntimeValues.net, - architecture: null, + architecture: XMakeAttributes.GetCurrentMSBuildArchitecture(), dotnetHostPath: null, msBuildAssemblyPath: null);