Skip to content

Commit 673ef31

Browse files
Fix macOS symlink handshake mismatch in .NET task host (MSB4216) (dotnet#13406)
### Context PR dotnet#13175 (App Host Support) introduced a regression on macOS when the SDK is accessed through a symlinked path. On macOS, `/tmp` is a symlink to `/private/tmp`. When Helix tests run from `/tmp/helix/...`, the `$(NetCoreSdkRoot)` MSBuild property preserves the unresolved path `/tmp/helix/.../sdk/11.0.100-ci`, but the child task host process resolves it to `/private/tmp/helix/.../sdk/11.0.100-ci` via `AppContext.BaseDirectory`. ### Root Cause In `ResolveAppHostOrFallback`, the parent passed `toolsDirectory: msbuildAssemblyPath` (from `$(NetCoreSdkRoot)`) to the `Handshake` constructor, while the child (`NodeEndpointOutOfProcTaskHost`) passed no explicit `toolsDirectory`, defaulting to `BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot` (which resolves symlinks). This produced different handshake hashes: - **Parent**: `hash("/tmp/.../sdk/11.0.100-ci")` - **Child**: `hash("/private/tmp/.../sdk/11.0.100-ci")` - **Result**: Handshake mismatch -> MSB4216 Before PR dotnet#13175, neither side passed explicit `toolsDirectory`, so both defaulted to `BuildEnvironmentHelper` and always matched. ### Changes Made - On .NET Core (`#if RUNTIME_TYPE_NETCORE`): omit explicit `toolsDirectory` so both parent and child default to `BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot`, which resolves symlinks consistently via `AppContext.BaseDirectory`. - On .NET Framework: keep `toolsDirectory: msbuildAssemblyPath` because the parent (VS) and child (.NET task host) are in **different directories**, and Windows has no symlink issues. - Updated regression test to validate actual fix behavior (not tautological). ### Testing - `Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches` - proves that an external path (like `$(NetCoreSdkRoot)`) produces a different handshake than the default, and that omitting `toolsDirectory` on both sides always matches. - `Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey` - proves the bug mechanism with real symlinks on Unix. - Existing E2E tests for TaskHostFactory tasks. - SDK test validation on Helix macOS (the original failing environment). ### Notes This fix addresses the MSB4216 errors seen in SDK Helix tests for `ComputeWasmBuildAssets`, `ComputeManagedAssemblies`, `MarshalingPInvokeScanner`, and other `TaskHostFactory` tasks on macOS. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8d5ad98 commit 673ef31

3 files changed

Lines changed: 215 additions & 2 deletions

File tree

src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.IO;
77
using Microsoft.Build.BackEnd;
8+
using Microsoft.Build.Framework;
89
using Microsoft.Build.Internal;
910
using Microsoft.Build.Shared;
1011
using Microsoft.Build.UnitTests;
@@ -155,5 +156,123 @@ public void ClearBootstrapDotnetRootEnvironment_HandlesMixedScenario()
155156
Environment.GetEnvironmentVariable("DOTNET_ROOT_ARM64").ShouldBeNull(); // Was already null
156157
}
157158
}
159+
160+
/// <summary>
161+
/// Regression test for the macOS /tmp → /private/tmp symlink issue (MSB4216).
162+
///
163+
/// Before the fix, the parent passed $(NetCoreSdkRoot) as toolsDirectory —
164+
/// an MSBuild property that can contain unresolved symlinks. The child always
165+
/// defaults to BuildEnvironmentHelper (which resolves symlinks via
166+
/// AppContext.BaseDirectory). This caused different handshake hashes.
167+
///
168+
/// After the fix (on .NET Core), the parent also omits toolsDirectory,
169+
/// so both sides default to BuildEnvironmentHelper.
170+
///
171+
/// This test proves that an arbitrary external path (simulating $(NetCoreSdkRoot))
172+
/// CAN produce a different handshake than the BuildEnvironmentHelper default,
173+
/// and that omitting toolsDirectory on both sides always matches.
174+
/// </summary>
175+
#if NET
176+
[Fact]
177+
public void Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches()
178+
{
179+
// Use explicit NET runtime and current architecture to ensure the NET
180+
// HandshakeOptions flag is set, which is required for passing toolsDirectory
181+
// to the Handshake constructor.
182+
var netTaskHostParams = new TaskHostParameters(
183+
runtime: XMakeAttributes.MSBuildRuntimeValues.net,
184+
architecture: XMakeAttributes.GetCurrentMSBuildArchitecture(),
185+
dotnetHostPath: null,
186+
msBuildAssemblyPath: null);
187+
188+
HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions(
189+
taskHost: true,
190+
taskHostParameters: netTaskHostParams,
191+
nodeReuse: false);
192+
193+
// Simulate child: no explicit toolsDirectory → defaults to BuildEnvironmentHelper.
194+
var childHandshake = new Handshake(options);
195+
196+
// After the fix: parent also omits toolsDirectory → same default → must match.
197+
var parentFixedHandshake = new Handshake(options);
198+
parentFixedHandshake.GetKey().ShouldBe(childHandshake.GetKey(),
199+
"When both parent and child omit toolsDirectory, they must produce " +
200+
"identical handshake keys (both default to BuildEnvironmentHelper).");
201+
202+
// Before the fix: parent passed an external path ($(NetCoreSdkRoot)).
203+
// If that path differs from BuildEnvironmentHelper (e.g. symlinks),
204+
// the handshake would mismatch.
205+
string externalPath = Path.Combine(Path.GetTempPath(), $"different_path_{Guid.NewGuid():N}");
206+
var parentBrokenHandshake = new Handshake(options, externalPath);
207+
parentBrokenHandshake.GetKey().ShouldNotBe(childHandshake.GetKey(),
208+
"An arbitrary external toolsDirectory should produce a different handshake " +
209+
"than the BuildEnvironmentHelper default, proving the mismatch scenario.");
210+
}
211+
#endif
212+
213+
/// <summary>
214+
/// Proves that using a symlinked path vs a resolved path in the handshake
215+
/// produces DIFFERENT keys — demonstrating the exact bug on macOS where
216+
/// /tmp is a symlink to /private/tmp.
217+
///
218+
/// This test creates a real symlink to prove the mismatch. It only runs on
219+
/// Unix (.NET Core) where symlinks are natively supported and the scenario is relevant.
220+
/// </summary>
221+
#if NET
222+
[UnixOnlyFact]
223+
public void Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey()
224+
{
225+
// Create a real directory and a symlink pointing to it.
226+
string realDir = Path.Combine(Path.GetTempPath(), $"msbuild_test_real_{Guid.NewGuid():N}");
227+
string symlinkDir = Path.Combine(Path.GetTempPath(), $"msbuild_test_link_{Guid.NewGuid():N}");
228+
229+
try
230+
{
231+
Directory.CreateDirectory(realDir);
232+
Directory.CreateSymbolicLink(symlinkDir, realDir);
233+
234+
HandshakeOptions options = CommunicationsUtilities.GetHandshakeOptions(
235+
taskHost: true,
236+
taskHostParameters: TaskHostParameters.Empty,
237+
nodeReuse: false);
238+
239+
// Parent using the symlink path (like $(MSBuildThisFileDirectory) would on macOS /tmp)
240+
var symlinkHandshake = new Handshake(options, symlinkDir);
241+
242+
// Child using the resolved real path (like AppContext.BaseDirectory resolves /private/tmp)
243+
var realHandshake = new Handshake(options, realDir);
244+
245+
// These produce DIFFERENT keys — this is the bug.
246+
// If these were used as parent vs child toolsDirectory, the pipe names would
247+
// differ and the parent could never connect to the child → MSB4216.
248+
symlinkHandshake.GetKey().ShouldNotBe(realHandshake.GetKey(),
249+
"Symlinked and resolved paths should produce different handshake keys " +
250+
"(they are different strings). This demonstrates why the parent must NOT " +
251+
"use an MSBuild property path that may contain unresolved symlinks — it " +
252+
"must use MSBuildToolsDirectoryRoot (same source as the child) instead.");
253+
254+
// Using the SAME source (MSBuildToolsDirectoryRoot) on both sides always matches,
255+
// regardless of symlinks, because both compute it from AppContext.BaseDirectory.
256+
string consistentDir = BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot;
257+
var parentFixed = new Handshake(options, consistentDir);
258+
var childDefault = new Handshake(options);
259+
260+
parentFixed.GetKey().ShouldBe(childDefault.GetKey(),
261+
"Using MSBuildToolsDirectoryRoot on both sides must produce matching keys.");
262+
}
263+
finally
264+
{
265+
if (Directory.Exists(symlinkDir))
266+
{
267+
Directory.Delete(symlinkDir);
268+
}
269+
270+
if (Directory.Exists(realDir))
271+
{
272+
Directory.Delete(realDir);
273+
}
274+
}
275+
}
276+
#endif
158277
}
159278
}

src/Build.UnitTests/NetTaskHost_E2E_Tests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.IO;
67
using Microsoft.Build.Framework;
78
using Microsoft.Build.Internal;
@@ -274,5 +275,79 @@ public void NetTaskWithImplicitHostParamsTest_AppHost()
274275
testTaskOutput.ShouldContain("The task is executed in process: MSBuild");
275276
testTaskOutput.ShouldContain("/nodereuse:True");
276277
}
278+
279+
#if NET
280+
/// <summary>
281+
/// Regression test: proves that launching the MSBuild task host through a symlinked
282+
/// SDK path causes MSB4216 due to handshake mismatch.
283+
///
284+
/// On macOS, /tmp is a symlink to /private/tmp. When the SDK is under /tmp, the
285+
/// MSBuild property $(NetCoreSdkRoot) = $(MSBuildThisFileDirectory) preserves the
286+
/// unresolved /tmp form. But the child task host's AppContext.BaseDirectory resolves
287+
/// to /private/tmp. The parent and child compute different handshake hashes → different
288+
/// pipe names → MSB4216.
289+
///
290+
/// This test recreates the scenario by symlinking the bootstrap SDK directory and
291+
/// running MSBuild through the symlink.
292+
/// </summary>
293+
[UnixOnlyFact]
294+
public void NetTaskHost_SymlinkedSdkPath_ShouldNotCauseMSB4216()
295+
{
296+
using TestEnvironment env = TestEnvironment.Create(_output);
297+
298+
// Create a symlink pointing to the bootstrap SDK binary location.
299+
// This simulates the macOS /tmp → /private/tmp scenario.
300+
string realSdkPath = RunnerUtilities.BootstrapMsBuildBinaryLocation;
301+
string symlinkPath = Path.Combine(Path.GetTempPath(), $"msbuild_symlink_test_{Guid.NewGuid():N}");
302+
303+
try
304+
{
305+
Directory.CreateSymbolicLink(symlinkPath, realSdkPath);
306+
307+
// Launch the MSBuild apphost through the symlink path.
308+
// This causes $(MSBuildThisFileDirectory) to use the symlink form,
309+
// while the child's AppContext.BaseDirectory resolves to the real path.
310+
string apphostPath = Path.Combine(symlinkPath, "sdk", RunnerUtilities.BootstrapSdkVersion, Constants.MSBuildExecutableName);
311+
312+
if (!File.Exists(apphostPath))
313+
{
314+
// If the apphost isn't present, we can't test the symlink scenario.
315+
// Fail explicitly so this doesn't silently pass in broken environments.
316+
Assert.Fail($"MSBuild apphost not found at: {apphostPath}. " +
317+
"The bootstrap layout must include the MSBuild apphost for this test.");
318+
}
319+
320+
string testProjectPath = Path.Combine(TestAssetsRootPath, "ExampleNetTask", "TestNetTask", "TestNetTask.csproj");
321+
322+
string testTaskOutput = RunnerUtilities.RunProcessAndGetOutput(
323+
apphostPath,
324+
$"\"{testProjectPath}\" -restore -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild}",
325+
out bool successTestTask,
326+
shellExecute: false,
327+
outputHelper: _output,
328+
environmentVariables: new Dictionary<string, string>
329+
{
330+
[Constants.DotnetHostPathEnvVarName] = Path.Combine(realSdkPath, "dotnet"),
331+
});
332+
333+
_output.WriteLine(testTaskOutput);
334+
335+
// Without the fix, this fails with MSB4216 because the parent's handshake
336+
// uses the symlink path from $(NetCoreSdkRoot) while the child resolves
337+
// to the real path via AppContext.BaseDirectory.
338+
testTaskOutput.ShouldNotContain("MSB4216");
339+
340+
successTestTask.ShouldBeTrue(
341+
"TaskHostFactory task should execute successfully when MSBuild runs from a symlinked SDK path.");
342+
}
343+
finally
344+
{
345+
if (Directory.Exists(symlinkPath))
346+
{
347+
Directory.Delete(symlinkPath);
348+
}
349+
}
350+
}
351+
#endif
277352
}
278353
}

src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,25 @@ private NodeLaunchData ResolveAppHostOrFallback(
733733
string appHostPath = Path.Combine(msbuildAssemblyPath, Constants.MSBuildExecutableName);
734734
string commandLineArgs = BuildCommandLineArgs(nodeReuseEnabled);
735735

736+
// The child task host (NodeEndpointOutOfProcTaskHost) computes its handshake
737+
// toolsDirectory from BuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot,
738+
// which derives from AppContext.BaseDirectory (resolves symlinks).
739+
//
740+
// On .NET Framework, the parent MSBuild (VS) is in a different directory than the
741+
// child .NET task host (SDK), so we must pass msbuildAssemblyPath explicitly to
742+
// match the child's location. Windows has no symlink issues so this is safe.
743+
//
744+
// On .NET Core, parent and child are always from the same SDK directory. Passing
745+
// msbuildAssemblyPath from $(NetCoreSdkRoot) can cause a handshake mismatch on
746+
// macOS where /tmp → /private/tmp symlink means the property value differs from
747+
// AppContext.BaseDirectory. By omitting toolsDirectory, both sides default to
748+
// BuildEnvironmentHelper which resolves symlinks consistently.
749+
#if RUNTIME_TYPE_NETCORE
750+
Handshake handshake = new Handshake(hostContext);
751+
#else
752+
Handshake handshake = new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath);
753+
#endif
754+
736755
if (FileSystems.Default.FileExists(appHostPath))
737756
{
738757
CommunicationsUtilities.Trace("For a host context of {0}, using app host from {1}.", hostContext, appHostPath);
@@ -744,7 +763,7 @@ private NodeLaunchData ResolveAppHostOrFallback(
744763
: new NodeLaunchData(
745764
appHostPath,
746765
commandLineArgs,
747-
new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath),
766+
handshake,
748767
dotnetOverrides);
749768
}
750769

@@ -762,7 +781,7 @@ private NodeLaunchData ResolveAppHostOrFallback(
762781
return new NodeLaunchData(
763782
resolvedDotnetHostPath,
764783
$"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}",
765-
new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath));
784+
handshake);
766785
}
767786

768787
private string BuildCommandLineArgs(bool nodeReuseEnabled) => $"/nologo {NodeModeHelper.ToCommandLineArgument(NodeMode.OutOfProcTaskHostNode)} /nodereuse:{nodeReuseEnabled} /low:{ComponentHost.BuildParameters.LowPriority} /parentpacketversion:{NodePacketTypeExtensions.PacketVersion} ";

0 commit comments

Comments
 (0)