diff --git a/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs b/src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs
index 1ce5dff91d6..87de6e545c2 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;
@@ -155,5 +156,123 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario()
Environment.GetEnvironmentVariable("DOTNET_ROOT_ARM64").ShouldBeNull(); // Was already null
}
}
+
+ ///
+ /// Regression test for the macOS /tmp → /private/tmp symlink issue (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.
+ ///
+#if NET
+ [Fact]
+ public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches()
+ {
+ // 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: XMakeAttributes.GetCurrentMSBuildArchitecture(),
+ dotnetHostPath: null,
+ msBuildAssemblyPath: null);
+
+ HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions(
+ taskHost: true,
+ taskHostParameters: netTaskHostParams,
+ nodeReuse: false);
+
+ // 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.");
+ }
+#endif
+
+ ///
+ /// 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 (.NET Core) where symlinks are natively supported and the scenario is relevant.
+ ///
+#if NET
+ [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);
+ }
+ }
+ }
+#endif
}
}
diff --git a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs
index 70a8d38733d..4e52d3243b0 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;
@@ -274,5 +275,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))
+ {
+ // 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");
+
+ string testTaskOutput = RunnerUtilities.RunProcessAndGetOutput(
+ apphostPath,
+ $"\"{testProjectPath}\" -restore -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild}",
+ out bool successTestTask,
+ shellExecute: false,
+ 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");
+
+ successTestTask.ShouldBeTrue(
+ "TaskHostFactory task should execute successfully when MSBuild runs from a symlinked SDK path.");
+ }
+ finally
+ {
+ if (Directory.Exists(symlinkPath))
+ {
+ Directory.Delete(symlinkPath);
+ }
+ }
+ }
+#endif
}
}
diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
index a7987f124f7..b4f6a34cd67 100644
--- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
+++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
@@ -733,6 +733,25 @@ private NodeLaunchData ResolveAppHostOrFallback(
string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName);
string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled);
+ // 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);
@@ -744,7 +763,7 @@ private NodeLaunchData ResolveAppHostOrFallback(
: new NodeLaunchData(
appHostPath,
commandLineArgs,
- new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath),
+ handshake,
dotnetOverrides);
}
@@ -762,7 +781,7 @@ private NodeLaunchData ResolveAppHostOrFallback(
return new NodeLaunchData(
resolvedDotnetHostPath,
$"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}",
- new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath));
+ handshake);
}
private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} ";