MSB4216: .NET task host handshake fails due to case-sensitive drive-letter casing in salt (VS 18.6 + .NET 11 SDK)
- Dominant language
- C#
- Stars
- 5.5k
- Forks
- 1.5k
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 141
Description
## Summary
The .NET task host fails to connect with **MSB4216** when launched from a .NET Framework MSBuild host (e.g. VS) on hosted CI agents, because the handshake **salt is a case-sensitive hash of a filesystem path** and the two sides derive that path from independent sources whose drive-letter casing can differ.
```
InstallDotNetCore.targets(17,5): error MSB4216: Could not run the "InstallDotNetCore" task
because MSBuild could not create or connect to a task host with runtime "NET" and architecture "x86".
...
Handshake failed. Received -1569896665 from host for Salt but expected -930293759.
Probably the host is a different MSBuild build.
```
Observed in an internal build: VS MSBuild `18.6.3+84d3e95b4` (host) launching the .NET 11 SDK `11.0.100-preview.5.26227.104` task host.
## Root cause
The handshake salt is computed as a **case-sensitive** hash:
```
salt = GetHashCode($"{MSBUILDNODEHANDSHAKESALT}{toolsDirectory}")
```
The two sides derive `toolsDirectory` differently:
| Side | Source | Value in failing build |
|------|--------|------------------------|
| Host (`NodeProviderOutOfProcTaskHost.ResolveAppHostOrFallback`, `#else` branch) | `$(NetCoreSdkRoot)` via `taskHostParameters.MSBuildAssemblyPath` | `D:\a\_work\1\s\.dotnet\sdk\11.0.100-preview.5.26227.104` |
| Child (`NodeEndpointOutOfProcTaskHost.GetHandshake` -> `new Handshake(options)`) | `BuildEnvironmentHelper.MSBuildToolsDirectoryRoot` (self-resolved) | `d:\a\_work\1\s\.dotnet\sdk\11.0.100-preview.5.26227.104` |
Computing the hash of both strings confirms the mismatch exactly matches the on-the-wire values:
- `D:\...26227.104` -> `-1569896665` (host wrote)
- `d:\...26227.104` -> `-930293759` (child expected)
The "VS bin vs SDK" tools-directory lines that appear in the diagnostic logs are a red herring from an unrelated handshake context; the actual mismatch is purely **drive-letter casing of the same SDK path**.
## Why it surfaced now / "how could it have ever worked"
This is a latent fragility rather than a single regressing commit. The host-side salt logic in VS 18.6.3 is identical to `main`. The cross-install path-in-salt was introduced incrementally:
- **#11543** (Aug 2025) - first derived the NET task host SDK path from a project property (`RuntimeIdentifierGraphPath`).
- **#13176** (Feb 2026) - switched the source to `$(NetCoreSdkRoot)` (exposed by dotnet/sdk `e0c4dda`, new in **.NET 11**). Both old and new resolve to `$(MSBuildThisFileDirectory)`, so this did not itself change the casing source.
- **#13175** (Mar 2026) - App Host support wired `new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath)` for the Framework -> .NET cross-install case, feeding the externally-sourced path into the salt.
It "worked" only while the host-provided path and the child's self-resolved path happened to share casing. The trigger is the **VS 18.6.x + .NET 11 SDK** combination on hosted agents, where the SDK-resolver path is spelled `D:\` but the child resolves `d:\`. It is environment/SDK-driven, not a product behavior change.
## Fix
Normalize path casing on Windows before hashing, on both handshake implementations (the host and the .NET child both use `src/Framework/BackEnd/Handshake.cs`; the CLR2 host uses `src/MSBuildTaskHost/CommunicationsUtilities.cs`):
- `Handshake.cs`: `toolsDirectory.ToUpperInvariant()` when `NativeMethods.IsWindows`.
- `MSBuildTaskHost/CommunicationsUtilities.cs`: unconditional `ToUpperInvariant()` (Windows-only assembly).
### Caveat
Because the NET task host handshake uses sentinel version `99.99.99.99`, the host and child can legitimately be **different MSBuild versions**. Both sides must carry the normalization for the salts to agree, so the fix only fully takes effect once both a VS MSBuild build and the SDK MSBuild include it.
## Affected files
- `src/Framework/BackEnd/Handshake.cs`
- `src/MSBuildTaskHost/CommunicationsUtilities.cs`
- `src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs` (context)
- `src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs` (context)
## Why this is NOT the VS-path-vs-SDK-path (toolset directory) difference
It is tempting to read the diagnostic log line `Tools directory root is c:\program files\...\visual studio\...\bin` and conclude the host hashed its own VS tools directory while the child hashed the SDK directory. That is not what happens here:
- For the Framework -> .NET cross-install task host, the host **deliberately does not use its own tools directory**. `ResolveAppHostOrFallback` passes `toolsDirectory: msbuildAssemblyPath` (the resolved **SDK** path) exactly so the salt matches the SDK child. The VS `...\MSBuild\Current\Bin` path is therefore **never fed into this handshake's salt**.
- The `c:\program files\...\visual studio\...\bin` "Tools directory root" line in the logs comes from a **different, unrelated handshake context** (e.g. a regular worker-node handshake), not the .NET task host handshake that produced this MSB4216.
- Decisive evidence: **both** salt values on the wire decode to the **same SDK path**, differing only by drive-letter casing:
- `D:\...\sdk\11.0.100-preview.5.26227.104` -> `-1569896665` (host wrote)
- `d:\...\sdk\11.0.100-preview.5.26227.104` -> `-930293759` (child expected)
Neither value corresponds to any VS path. If the VS tools directory had actually entered the salt, the host value would hash `c:\program files\...\visual studio\...\bin`, which produces a completely different number (`-617072364` / `-199492840`). It does not.
So the bug is purely the **case-sensitive hashing of the SDK path** (drive-letter casing), independent of the VS-vs-SDK install location difference, which is already handled correctly.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.