Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,5 +156,123 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario()
Environment.GetEnvironmentVariable("DOTNET_ROOT_ARM64").ShouldBeNull(); // Was already null
}
}

/// <summary>
/// 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.
/// </summary>
#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

/// <summary>
/// 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.
/// </summary>
#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
}
}
75 changes: 75 additions & 0 deletions src/Build.UnitTests/NetTaskHost_E2E_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -274,5 +275,79 @@ public void NetTaskWithImplicitHostParamsTest_AppHost()
testTaskOutput.ShouldContain("The task is executed in process: MSBuild");
testTaskOutput.ShouldContain("/nodereuse:True");
}

#if NET
/// <summary>
/// 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.
/// </summary>
[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<string, string>
{
[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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -744,7 +763,7 @@ private NodeLaunchData ResolveAppHostOrFallback(
: new NodeLaunchData(
appHostPath,
commandLineArgs,
new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath),
handshake,
dotnetOverrides);
}

Expand All @@ -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} ";
Expand Down