diff --git a/src/Build.UnitTests/CLR2TaskHost_E2E_Tests.cs b/src/Build.UnitTests/CLR2TaskHost_E2E_Tests.cs
new file mode 100644
index 00000000000..25dc52fff89
--- /dev/null
+++ b/src/Build.UnitTests/CLR2TaskHost_E2E_Tests.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using Microsoft.Build.UnitTests;
+using Microsoft.Build.UnitTests.Shared;
+using Shouldly;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Build.Engine.UnitTests;
+
+///
+/// End-to-end tests for the CLR2 task host (MSBuildTaskHost.exe).
+/// These tests explicitly force tasks to run out-of-proc in CLR2 via
+/// TaskFactory="TaskHostFactory" and Runtime="CLR2",
+/// exercising the CLR2 branch in ResolveNodeLaunchConfiguration.
+///
+public class CLR2TaskHost_E2E_Tests
+{
+ private readonly ITestOutputHelper _output;
+
+ public CLR2TaskHost_E2E_Tests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
+
+ ///
+ /// Verifies that the CLR2 task host (MSBuildTaskHost.exe) can be launched and connected to
+ /// when a task explicitly requests Runtime="CLR2" with TaskHostFactory.
+ ///
+ /// Regression test for the apphost changes (PR #13175) that replaced the three-branch
+ /// ResolveNodeLaunchConfiguration with a two-branch version, losing the CLR2-specific path:
+ /// 1. Empty command-line args (MSBuildTaskHost.Main() takes no arguments)
+ /// 2. Handshake with toolsDirectory set to the EXE's directory so the pipe name
+ /// salt matches what the child process computes on startup
+ /// Without these, the parent and child compute different pipe name hashes → MSB4216.
+ ///
+ [WindowsNet35OnlyFact]
+ public void ExplicitCLR2TaskHostFactory_RunsTaskSuccessfully()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ TransientTestFolder testFolder = env.CreateFolder(createFolder: true);
+
+ string projectContent = """
+
+
+
+
+
+
+
+
+ """;
+
+ string projectPath = Path.Combine(testFolder.Path, "CLR2ExplicitTest.proj");
+ File.WriteAllText(projectPath, projectContent);
+
+ string testOutput = RunnerUtilities.ExecBootstrapedMSBuild(
+ $"\"{projectPath}\" -v:n",
+ out bool success,
+ outputHelper: _output);
+
+
+ // MSB4216 occurs when the parent can't connect to MSBuildTaskHost.exe —
+ // either due to handshake salt mismatch (missing toolsDirectory) or wrong process routing.
+ testOutput.ShouldNotContain("MSB4216", customMessage: "CLR2 task host connection should succeed with correct handshake salt and empty command-line args");
+
+ success.ShouldBeTrue(customMessage: "Task explicitly requesting CLR2 + TaskHostFactory should execute in MSBuildTaskHost.exe");
+
+ // Verify the task actually ran by checking for its output.
+ testOutput.ShouldContain("CLR2TaskHostSuccess", customMessage: "Exec task output should be visible, confirming it ran in CLR2 task host");
+ }
+}
diff --git a/src/Build/BackEnd/Components/Communications/NodeLauncher.cs b/src/Build/BackEnd/Components/Communications/NodeLauncher.cs
index f746924d6c1..2163353b6ff 100644
--- a/src/Build/BackEnd/Components/Communications/NodeLauncher.cs
+++ b/src/Build/BackEnd/Components/Communications/NodeLauncher.cs
@@ -83,14 +83,17 @@ private string ResolveExecutableName(string msbuildLocation, out bool isNativeAp
isNativeAppHost = false;
#if RUNTIME_TYPE_NETCORE
- // If msbuildLocation is a native app host (e.g., MSBuild.exe on Windows, MSBuild on Linux), run it directly.
- // Otherwise, use dotnet.exe to run the managed assembly (e.g., MSBuild.dll).
string fileName = Path.GetFileName(msbuildLocation);
- isNativeAppHost = fileName.Equals(Constants.MSBuildExecutableName, StringComparison.OrdinalIgnoreCase);
- if (!isNativeAppHost)
+
+ // Only managed assemblies (.dll) need dotnet.exe as a host.
+ // All native executables — MSBuild app host, MSBuildTaskHost.exe, etc. — run directly.
+ if (fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
return CurrentHost.GetCurrentHost();
}
+
+ // Any .exe or extensionless binary (Linux app host) is a native executable.
+ isNativeAppHost = true;
#endif
return msbuildLocation;
}
diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
index 468fea63eaf..a7987f124f7 100644
--- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
+++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
@@ -683,12 +683,30 @@ internal bool CreateNode(TaskHostNodeKey nodeKey, INodePacketFactory factory, IN
return nodeContexts.Count == 1;
// Resolves the node launch configuration based on the host context.
- NodeLaunchData ResolveNodeLaunchConfiguration(HandshakeOptions hostContext, in TaskHostParameters taskHostParameters) =>
-
+ NodeLaunchData ResolveNodeLaunchConfiguration(HandshakeOptions hostContext, in TaskHostParameters taskHostParameters)
+ {
// Handle .NET task host context
- Handshake.IsHandshakeOptionEnabled(hostContext, HandshakeOptions.NET)
- ? ResolveAppHostOrFallback(GetMSBuildPath(taskHostParameters), taskHostParameters.DotnetHostPath, hostContext, IsNodeReuseEnabled(hostContext))
- : new NodeLaunchData(GetMSBuildExecutablePathForNonNETRuntimes(hostContext), BuildCommandLineArgs(IsNodeReuseEnabled(hostContext)), new Handshake(hostContext));
+ if (Handshake.IsHandshakeOptionEnabled(hostContext, HandshakeOptions.NET))
+ {
+ return ResolveAppHostOrFallback(GetMSBuildPath(taskHostParameters), taskHostParameters.DotnetHostPath, hostContext, IsNodeReuseEnabled(hostContext));
+ }
+
+#if FEATURE_NET35_TASKHOST
+ // CLR2 task host (MSBuildTaskHost.exe) requires special handling:
+ // - Empty command-line args (MSBuildTaskHost.Main() takes no arguments)
+ // - Handshake with toolsDirectory set to the EXE's directory so the
+ // salt matches what the child process computes on startup.
+ if (Handshake.IsHandshakeOptionEnabled(hostContext, HandshakeOptions.CLR2))
+ {
+ string msbuildLocation = GetMSBuildExecutablePathForNonNETRuntimes(hostContext);
+ string toolsDirectory = Path.GetDirectoryName(msbuildLocation) ?? string.Empty;
+ return new NodeLaunchData(msbuildLocation, string.Empty, new Handshake(hostContext, toolsDirectory));
+ }
+#endif
+
+ // CLR4 task host (MSBuild.exe on .NET Framework)
+ return new NodeLaunchData(GetMSBuildExecutablePathForNonNETRuntimes(hostContext), BuildCommandLineArgs(IsNodeReuseEnabled(hostContext)), new Handshake(hostContext));
+ }
}
///
@@ -730,10 +748,19 @@ private NodeLaunchData ResolveAppHostOrFallback(
dotnetOverrides);
}
- CommunicationsUtilities.Trace("For a host context of {0}, app host not found at {1}, falling back to dotnet.exe from {2}.", hostContext, appHostPath, dotnetHostPath);
+ // Auto-discover dotnet host path when not explicitly provided.
+ string resolvedDotnetHostPath = dotnetHostPath;
+#if RUNTIME_TYPE_NETCORE
+ if (string.IsNullOrEmpty(resolvedDotnetHostPath))
+ {
+ resolvedDotnetHostPath = CurrentHost.GetCurrentHost();
+ }
+#endif
+
+ CommunicationsUtilities.Trace("For a host context of {0}, app host not found at {1}, falling back to dotnet.exe from {2}.", hostContext, appHostPath, resolvedDotnetHostPath);
return new NodeLaunchData(
- dotnetHostPath,
+ resolvedDotnetHostPath,
$"\"{Path.Combine(msbuildAssemblyPath, Constants.MSBuildAssemblyName)}\" {commandLineArgs}",
new Handshake(hostContext, toolsDirectory: msbuildAssemblyPath));
}
diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs
index 1b76ee832ef..4a9dfdd5180 100644
--- a/src/Build/Instance/TaskFactories/TaskHostTask.cs
+++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs
@@ -5,6 +5,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
+using System.IO;
using System.Reflection;
using System.Threading;
using Microsoft.Build.BackEnd.Logging;
@@ -13,6 +14,7 @@
using Microsoft.Build.Framework;
using Microsoft.Build.Internal;
using Microsoft.Build.Shared;
+using Constants = Microsoft.Build.Framework.Constants;
#if FEATURE_REPORTFILEACCESSES
using Microsoft.Build.Experimental.FileAccess;
using Microsoft.Build.FileAccesses;
@@ -700,13 +702,20 @@ private void HandleCoresRequest(TaskHostCoresRequest request)
///
private void LogErrorUnableToCreateTaskHost(HandshakeOptions requiredContext, string runtime, string architecture, Exception e)
{
- string taskHostLocation = NodeProviderOutOfProcTaskHost.GetMSBuildExecutablePathForNonNETRuntimes(requiredContext);
-#if NETFRAMEWORK
+ string taskHostLocation;
+
if (Handshake.IsHandshakeOptionEnabled(requiredContext, HandshakeOptions.NET))
{
- taskHostLocation = NodeProviderOutOfProcTaskHost.GetMSBuildLocationForNETRuntime(requiredContext, _taskHostParameters).MSBuildPath;
+ (_, string msbuildPath) = NodeProviderOutOfProcTaskHost.GetMSBuildLocationForNETRuntime(requiredContext, _taskHostParameters);
+ taskHostLocation = msbuildPath != null
+ ? Path.Combine(msbuildPath, Constants.MSBuildExecutableName)
+ : null;
}
-#endif
+ else
+ {
+ taskHostLocation = NodeProviderOutOfProcTaskHost.GetMSBuildExecutablePathForNonNETRuntimes(requiredContext);
+ }
+
string msbuildLocation = taskHostLocation ??
// We don't know the path -- probably we're trying to get a 64-bit assembly on a
// 32-bit machine. At least give them the exe name to look for, though ...