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 ...