File: BackEnd\TaskHostCallback_Tests.cs
Web Access
Project: ..\..\..\src\Build.UnitTests\Microsoft.Build.Engine.UnitTests.csproj (Microsoft.Build.Engine.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.IO;
using Microsoft.Build.BackEnd;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.UnitTests.Shared;
using Shouldly;
using Xunit;
 
namespace Microsoft.Build.UnitTests.BackEnd
{
    /// <summary>
    /// Integration tests for IBuildEngine callback support in TaskHost.
    /// These tests use BuildManager to run real builds with TaskHostFactory.
    /// For packet serialization tests, see <see cref="TaskHostCallbackPacket_Tests"/>.
    /// </summary>
    public class TaskHostCallback_Tests
    {
        private readonly ITestOutputHelper _output;
 
        public TaskHostCallback_Tests(ITestOutputHelper output)
        {
            _output = output;
        }
 
        /// <summary>
        /// Verifies IsRunningMultipleNodes callback works when task is explicitly run in TaskHost via TaskHostFactory.
        /// IsRunningMultipleNodes is configuration-based (MaxNodeCount > 1), not based on actual running nodes.
        /// See TaskHost.IsRunningMultipleNodes: returns _host.BuildParameters.MaxNodeCount > 1 || _disableInprocNode.
        /// </summary>
        [Theory]
        [InlineData(1, false)]  // MaxNodeCount=1 -> IsRunningMultipleNodes=false
        [InlineData(4, true)]   // MaxNodeCount=4 -> IsRunningMultipleNodes=true (even with one project)
        public void IsRunningMultipleNodes_WorksWithExplicitTaskHostFactory(int maxNodeCount, bool expectedResult)
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(IsRunningMultipleNodesTask)}"" AssemblyFile=""{typeof(IsRunningMultipleNodesTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(IsRunningMultipleNodesTask)}>
            <Output PropertyName=""Result"" TaskParameter=""IsRunningMultipleNodes"" />
        </{nameof(IsRunningMultipleNodesTask)}>
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = maxNodeCount, EnableNodeReuse = false },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            bool.Parse(projectInstance.GetPropertyValue("Result")).ShouldBe(expectedResult);
        }
 
        /// <summary>
        /// Verifies IsRunningMultipleNodes callback works when unmarked task is auto-ejected to TaskHost in MT mode.
        /// </summary>
        [Theory]
        [InlineData(1, false)]
        [InlineData(4, true)]
        public void IsRunningMultipleNodes_WorksWhenAutoEjectedInMultiThreadedMode(int maxNodeCount, bool expectedResult)
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
            string testDir = env.CreateFolder().Path;
 
            // IsRunningMultipleNodesTask lacks MSBuildMultiThreadableTask attribute, so it's auto-ejected to TaskHost in MT mode
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(IsRunningMultipleNodesTask)}"" AssemblyFile=""{typeof(IsRunningMultipleNodesTask).Assembly.Location}"" />
    <Target Name=""Test"">
        <{nameof(IsRunningMultipleNodesTask)}>
            <Output PropertyName=""Result"" TaskParameter=""IsRunningMultipleNodes"" />
        </{nameof(IsRunningMultipleNodesTask)}>
    </Target>
</Project>";
 
            string projectFile = Path.Combine(testDir, "Test.proj");
            File.WriteAllText(projectFile, projectContents);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters
                {
                    MultiThreaded = true,
                    MaxNodeCount = maxNodeCount,
                    Loggers = [logger],
                    EnableNodeReuse = false
                },
                new BuildRequestData(projectFile, new Dictionary<string, string?>(), null, ["Test"], null));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
 
            // Verify task was ejected to TaskHost
            logger.FullLog.ShouldContain("external task host");
 
            // Verify callback returned correct value
            logger.FullLog.ShouldContain($"IsRunningMultipleNodes = {expectedResult}");
        }
 
        /// <summary>
        /// Verifies RequestCores callback works when task is explicitly run in TaskHost via TaskHostFactory.
        /// The first RequestCores call should always return at least 1 (the implicit core).
        /// </summary>
        [Fact]
        public void RequestCores_WorksWithExplicitTaskHostFactory()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(RequestCoresTask)}"" AssemblyFile=""{typeof(RequestCoresTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(RequestCoresTask)} CoreCount=""2"">
            <Output PropertyName=""Result"" TaskParameter=""GrantedCores"" />
        </{nameof(RequestCoresTask)}>
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            // First RequestCores call gets at least the implicit core
            int.Parse(projectInstance.GetPropertyValue("Result")).ShouldBeGreaterThanOrEqualTo(1);
        }
 
        /// <summary>
        /// Verifies RequestCores + ReleaseCores works end-to-end when task runs in TaskHost.
        /// </summary>
        [Fact]
        public void RequestAndReleaseCores_WorksWithExplicitTaskHostFactory()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(RequestCoresTask)}"" AssemblyFile=""{typeof(RequestCoresTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(RequestCoresTask)} CoreCount=""2"" ReleaseAfter=""true"">
            <Output PropertyName=""Result"" TaskParameter=""GrantedCores"" />
        </{nameof(RequestCoresTask)}>
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            // Verify both RequestCores and ReleaseCores ran without error
            logger.AssertNoErrors();
            logger.FullLog.ShouldContain("ReleaseCores(");
        }
 
        /// <summary>
        /// Verifies RequestCores callback works when task is auto-ejected to TaskHost in multithreaded mode.
        /// </summary>
        [Fact]
        public void RequestCores_WorksWhenAutoEjectedInMultiThreadedMode()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
            string testDir = env.CreateFolder().Path;
 
            // RequestCoresTask lacks MSBuildMultiThreadableTask attribute, so it's auto-ejected to TaskHost in MT mode
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(RequestCoresTask)}"" AssemblyFile=""{typeof(RequestCoresTask).Assembly.Location}"" />
    <Target Name=""Test"">
        <{nameof(RequestCoresTask)} CoreCount=""1"">
            <Output PropertyName=""Result"" TaskParameter=""GrantedCores"" />
        </{nameof(RequestCoresTask)}>
    </Target>
</Project>";
 
            string projectFile = Path.Combine(testDir, "Test.proj");
            File.WriteAllText(projectFile, projectContents);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters
                {
                    MultiThreaded = true,
                    MaxNodeCount = 4,
                    Loggers = [logger],
                    EnableNodeReuse = false
                },
                new BuildRequestData(projectFile, new Dictionary<string, string?>(), null, ["Test"], null));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            logger.FullLog.ShouldContain("external task host");
            logger.FullLog.ShouldContain("RequestCores(1) =");
        }
 
        /// <summary>
        /// Regression test for https://github.com/dotnet/msbuild/issues/13153
        /// Verifies that request-level global properties (passed via BuildRequestData, not just
        /// BuildParameters.GlobalProperties) are forwarded through TaskHostTask to the out-of-proc
        /// TaskHost when a task is auto-ejected in multithreaded mode.
        ///
        /// Before the fix, TaskHostTask.Execute() used BuildParameters.GlobalProperties (build-level),
        /// which did not include per-request properties like MSBuildRestoreSessionId. This caused
        /// NuGet static graph restore to fail for conditional ProjectReference items.
        /// </summary>
        [Fact]
        public void GlobalProperties_ForwardedToAutoEjectedTaskInMultiThreadedMode()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
            string testDir = env.CreateFolder().Path;
 
            // GetGlobalPropertiesTask lacks MSBuildMultiThreadableTask attribute,
            // so it's auto-ejected to TaskHost in MT mode
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(GetGlobalPropertiesTask)}"" AssemblyFile=""{typeof(GetGlobalPropertiesTask).Assembly.Location}"" />
    <Target Name=""Test"">
        <{nameof(GetGlobalPropertiesTask)}>
            <Output PropertyName=""PropCount"" TaskParameter=""GlobalPropertyCount"" />
        </{nameof(GetGlobalPropertiesTask)}>
    </Target>
</Project>";
 
            string projectFile = Path.Combine(testDir, "Test.proj");
            File.WriteAllText(projectFile, projectContents);
 
            // Pass request-level global properties via BuildRequestData (simulates what
            // ExecuteRestore() does when adding MSBuildRestoreSessionId)
            var requestGlobalProperties = new Dictionary<string, string?>
            {
                ["TestRequestProperty"] = "RequestValue",
                ["AnotherRequestProp"] = "AnotherValue",
            };
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters
                {
                    MultiThreaded = true,
                    MaxNodeCount = 4,
                    Loggers = [logger],
                    EnableNodeReuse = false,
                },
                new BuildRequestData(projectFile, requestGlobalProperties, null, ["Test"], null));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
 
            // Verify task was ejected to TaskHost
            logger.FullLog.ShouldContain("external task host");
 
            // Verify request-level global properties were forwarded to the TaskHost
            logger.FullLog.ShouldContain("GlobalProperty: TestRequestProperty=RequestValue");
            logger.FullLog.ShouldContain("GlobalProperty: AnotherRequestProp=AnotherValue");
        }
 
        /// <summary>
        /// Verifies that when ChangeWave 18.6 is disabled, the old behavior is preserved:
        /// TaskHostTask sends build-level properties (BuildParameters.GlobalProperties) instead
        /// of request-level properties. This is the opt-out for the fix in #13153.
        /// </summary>
        [Fact]
        public void GlobalProperties_UseBuildLevelWhenChangeWaveDisabled()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
            env.SetEnvironmentVariable("MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_6.ToString());
            string testDir = env.CreateFolder().Path;
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(GetGlobalPropertiesTask)}"" AssemblyFile=""{typeof(GetGlobalPropertiesTask).Assembly.Location}"" />
    <Target Name=""Test"">
        <{nameof(GetGlobalPropertiesTask)}>
            <Output PropertyName=""PropCount"" TaskParameter=""GlobalPropertyCount"" />
        </{nameof(GetGlobalPropertiesTask)}>
    </Target>
</Project>";
 
            string projectFile = Path.Combine(testDir, "Test.proj");
            File.WriteAllText(projectFile, projectContents);
 
            // These request-level properties should NOT be forwarded when the wave is disabled
            var requestGlobalProperties = new Dictionary<string, string?>
            {
                ["TestRequestProperty"] = "RequestValue",
            };
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters
                {
                    MultiThreaded = true,
                    MaxNodeCount = 4,
                    Loggers = [logger],
                    EnableNodeReuse = false,
                },
                new BuildRequestData(projectFile, requestGlobalProperties, null, ["Test"], null));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
 
            // With wave disabled, build-level properties are used (empty in this test),
            // so request-level properties should NOT appear
            logger.FullLog.ShouldNotContain("GlobalProperty: TestRequestProperty=RequestValue");
            logger.FullLog.ShouldContain("GlobalPropertyCount = 0");
        }
 
        /// <summary>
        /// Verifies BuildProjectFile callback works when task is explicitly run in TaskHost via TaskHostFactory.
        /// The child project should build and the task should return success.
        /// </summary>
        [Fact]
        public void BuildProjectFile_WorksWithExplicitTaskHostFactory()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string childProject = env.CreateFile("Child.proj", """
                <Project>
                    <Target Name="Build">
                        <Message Text="ChildProjectBuilt" Importance="high" />
                    </Target>
                </Project>
                """).Path;
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(BuildProjectFileTask)}"" AssemblyFile=""{typeof(BuildProjectFileTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
            <Output PropertyName=""Result"" TaskParameter=""BuildSucceeded"" />
        </{nameof(BuildProjectFileTask)}>
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            bool.Parse(projectInstance.GetPropertyValue("Result")).ShouldBeTrue();
            logger.FullLog.ShouldContain("ChildProjectBuilt");
        }
 
        /// <summary>
        /// Verifies BuildProjectFile forwards global properties to the child build.
        /// </summary>
        [Fact]
        public void BuildProjectFile_ForwardsGlobalProperties()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string childProject = env.CreateFile("Child.proj", """
                <Project>
                    <Target Name="Build">
                        <Message Text="Config=$(Configuration)" Importance="high" />
                    </Target>
                </Project>
                """).Path;
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(BuildProjectFileTask)}"" AssemblyFile=""{typeof(BuildProjectFileTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"" Properties=""Configuration=Release"">
            <Output PropertyName=""Result"" TaskParameter=""BuildSucceeded"" />
        </{nameof(BuildProjectFileTask)}>
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            logger.FullLog.ShouldContain("Config=Release");
        }
 
        /// <summary>
        /// Verifies BuildProjectFile returns ITaskItem[] target outputs through the TaskHost callback.
        /// </summary>
        [Fact]
        public void BuildProjectFile_ReturnsTargetOutputs()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string childProject = env.CreateFile("Child.proj", """
                <Project>
                    <ItemGroup>
                        <OutputItem Include="Output1.dll">
                            <CustomMeta>Value1</CustomMeta>
                        </OutputItem>
                        <OutputItem Include="Output2.dll" />
                    </ItemGroup>
                    <Target Name="GetOutputs" Returns="@(OutputItem)">
                        <Message Text="GetOutputs executed" Importance="high" />
                    </Target>
                </Project>
                """).Path;
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(BuildProjectFileTask)}"" AssemblyFile=""{typeof(BuildProjectFileTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""GetOutputs"">
            <Output PropertyName=""Result"" TaskParameter=""BuildSucceeded"" />
            <Output ItemName=""Items"" TaskParameter=""OutputItems"" />
        </{nameof(BuildProjectFileTask)}>
        <Message Text=""OutputItemCount=@(Items->Count())"" Importance=""high"" />
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            bool.Parse(projectInstance.GetPropertyValue("Result")).ShouldBeTrue();
            logger.FullLog.ShouldContain("OutputItemCount=2");
        }
 
        /// <summary>
        /// Verifies BuildProjectFile returns false when the child project fails.
        /// </summary>
        [Fact]
        public void BuildProjectFile_ChildFailure_ReturnsFalse()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
 
            string childProject = env.CreateFile("Child.proj", """
                <Project>
                    <Target Name="Build">
                        <Error Text="Intentional failure" />
                    </Target>
                </Project>
                """).Path;
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(BuildProjectFileTask)}"" AssemblyFile=""{typeof(BuildProjectFileTask).Assembly.Location}"" TaskFactory=""TaskHostFactory"" />
    <Target Name=""Test"">
        <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
            <Output PropertyName=""Result"" TaskParameter=""BuildSucceeded"" />
        </{nameof(BuildProjectFileTask)}>
        <Message Text=""ChildResult=$(Result)"" Importance=""high"" />
    </Target>
</Project>";
 
            TransientTestProjectWithFiles project = env.CreateTestProjectWithFiles(projectContents);
            ProjectInstance projectInstance = new(project.ProjectFile);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters { MaxNodeCount = 4, EnableNodeReuse = false, Loggers = [logger] },
                new BuildRequestData(projectInstance, targetsToBuild: ["Test"]));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            logger.FullLog.ShouldContain("ChildResult=False");
        }
 
        /// <summary>
        /// Verifies BuildProjectFile auto-ejection works in multithreaded mode.
        /// </summary>
        [Fact]
        public void BuildProjectFile_WorksWhenAutoEjectedInMultiThreadedMode()
        {
            using TestEnvironment env = TestEnvironment.Create(_output);
            string testDir = env.CreateFolder().Path;
 
            string childProject = Path.Combine(testDir, "Child.proj");
            File.WriteAllText(childProject, """
                <Project>
                    <Target Name="Build">
                        <Message Text="ChildBuiltInMT" Importance="high" />
                    </Target>
                </Project>
                """);
 
            string projectContents = $@"
<Project>
    <UsingTask TaskName=""{nameof(BuildProjectFileTask)}"" AssemblyFile=""{typeof(BuildProjectFileTask).Assembly.Location}"" />
    <Target Name=""Test"">
        <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
            <Output PropertyName=""Result"" TaskParameter=""BuildSucceeded"" />
        </{nameof(BuildProjectFileTask)}>
    </Target>
</Project>";
 
            string projectFile = Path.Combine(testDir, "Test.proj");
            File.WriteAllText(projectFile, projectContents);
 
            var logger = new MockLogger(_output);
            BuildResult buildResult = BuildManager.DefaultBuildManager.Build(
                new BuildParameters
                {
                    MultiThreaded = true,
                    MaxNodeCount = 4,
                    Loggers = [logger],
                    EnableNodeReuse = false
                },
                new BuildRequestData(projectFile, new Dictionary<string, string?>(), null, ["Test"], null));
 
            buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
            logger.FullLog.ShouldContain("external task host");
            logger.FullLog.ShouldContain("ChildBuiltInMT");
        }
    }
}