Fix macOS symlink handshake mismatch in .NET task host (MSB4216)#13406
Merged
YuliiaKovalova merged 14 commits intomainfrom Mar 19, 2026
Merged
Fix macOS symlink handshake mismatch in .NET task host (MSB4216)#13406YuliiaKovalova merged 14 commits intomainfrom
YuliiaKovalova merged 14 commits intomainfrom
Conversation
…B4216 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 dotnet/sdk#53350 (comment) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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>
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>
TaskHostParameters is in the Microsoft.Build.Framework namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
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>
- Add using System.Collections.Generic for Dictionary<string, string> - 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>
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>
…mit 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>
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>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a macOS regression in the .NET task host launch path where symlink-resolved vs unresolved SDK directories could cause parent/child handshake salt mismatches (MSB4216), preventing the parent from connecting to the task host.
Changes:
- On
RUNTIME_TYPE_NETCORE, stop passing an explicittoolsDirectorytoHandshakeso both parent and child default toBuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot(symlink-resolved). - Keep explicit
toolsDirectory: msbuildAssemblyPathon .NET Framework to preserve the required VS-parent/SDK-child directory separation behavior. - Add/adjust regression tests covering handshake mismatches and a symlinked SDK execution scenario.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs | Adjusts handshake construction to avoid symlink-induced mismatches on .NET Core while preserving .NET Framework behavior. |
| src/Build.UnitTests/NetTaskHost_E2E_Tests.cs | Adds an E2E Unix regression test that runs MSBuild via a symlinked SDK path and asserts MSB4216 does not occur. |
| src/Build.UnitTests/BackEnd/AppHostSupport_Tests.cs | Adds unit-level handshake regression coverage around external toolsDirectory values and real symlink vs real-path key mismatches. |
…host - 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>
GetHandshakeOptions requires non-null architecture when taskHostParameters has a non-null runtime. Use GetCurrentMSBuildArchitecture(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Mar 19, 2026
AR-May
pushed a commit
to AR-May/msbuild
that referenced
this pull request
Mar 19, 2026
…net#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
PR #13175 (App Host Support) introduced a regression on macOS when the SDK is accessed through a symlinked path. On macOS,
/tmpis 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-civiaAppContext.BaseDirectory.Root Cause
In
ResolveAppHostOrFallback, the parent passedtoolsDirectory: msbuildAssemblyPath(from$(NetCoreSdkRoot)) to theHandshakeconstructor, while the child (NodeEndpointOutOfProcTaskHost) passed no explicittoolsDirectory, defaulting toBuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot(which resolves symlinks).This produced different handshake hashes:
hash("/tmp/.../sdk/11.0.100-ci")hash("/private/tmp/.../sdk/11.0.100-ci")Before PR #13175, neither side passed explicit
toolsDirectory, so both defaulted toBuildEnvironmentHelperand always matched.Changes Made
#if RUNTIME_TYPE_NETCORE): omit explicittoolsDirectoryso both parent and child default toBuildEnvironmentHelper.Instance.MSBuildToolsDirectoryRoot, which resolves symlinks consistently viaAppContext.BaseDirectory.toolsDirectory: msbuildAssemblyPathbecause the parent (VS) and child (.NET task host) are in different directories, and Windows has no symlink issues.Testing
Handshake_ExternalPathCanMismatch_DefaultAlwaysMatches- proves that an external path (like$(NetCoreSdkRoot)) produces a different handshake than the default, and that omittingtoolsDirectoryon both sides always matches.Handshake_WithSymlinkedToolsDirectory_ProducesDifferentKey- proves the bug mechanism with real symlinks on Unix.Notes
This fix addresses the MSB4216 errors seen in SDK Helix tests for
ComputeWasmBuildAssets,ComputeManagedAssemblies,MarshalingPInvokeScanner, and otherTaskHostFactorytasks on macOS.