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} ";