diff --git a/scripts/test-external-repos.ps1 b/scripts/test-external-repos.ps1
new file mode 100644
index 00000000000..ae572150167
--- /dev/null
+++ b/scripts/test-external-repos.ps1
@@ -0,0 +1,237 @@
+<#
+.SYNOPSIS
+ Builds external repos and projects using bootstrap MSBuild to validate callback changes.
+
+.DESCRIPTION
+ Clones dotnet/roslyn to Q:\, creates a WPF test project, then builds them using the
+ locally-built bootstrap MSBuild with TaskHost callbacks enabled and multithreaded mode (-mt).
+ Re-run after rebuilding bootstrap to validate new changes.
+
+ WPF repo itself requires SDK 11.0 so we test with 'dotnet new wpf' instead.
+
+.PARAMETER Repos
+ Which to build. Default: both. Options: 'roslyn', 'wpf', 'both'
+
+.PARAMETER SkipClone
+ Skip cloning if repos already exist at Q:\roslyn and Q:\wpf.
+
+.PARAMETER CleanBuild
+ Delete artifacts/bin before building to force a clean build.
+
+.EXAMPLE
+ # First run: clone + build both
+ .\scripts\test-external-repos.ps1
+
+ # Re-run after rebuilding bootstrap (skip clone, just rebuild)
+ .\scripts\test-external-repos.ps1 -SkipClone
+
+ # Build only roslyn
+ .\scripts\test-external-repos.ps1 -Repos roslyn -SkipClone
+#>
+param(
+ [ValidateSet('roslyn', 'wpf', 'both')]
+ [string]$Repos = 'both',
+
+ [switch]$SkipClone,
+
+ [switch]$CleanBuild
+)
+
+$ErrorActionPreference = 'Continue'
+
+# --- Paths ---
+$msbuildRoot = "C:\Users\janprovaznik\dev\msbuild"
+$bootstrapRoot = "$msbuildRoot\artifacts\bin\bootstrap"
+$bootstrapDotnet = "$bootstrapRoot\core\dotnet.exe"
+$bootstrapMSBuildExe = "$bootstrapRoot\net472\MSBuild\Current\Bin\MSBuild.exe"
+
+# Find the SDK version in bootstrap
+$sdkDir = Get-ChildItem "$bootstrapRoot\core\sdk" -Directory | Sort-Object Name -Descending | Select-Object -First 1
+$bootstrapSdkPath = $sdkDir.FullName
+
+Write-Host "============================================" -ForegroundColor Cyan
+Write-Host " External Repo Build Validation" -ForegroundColor Cyan
+Write-Host "============================================" -ForegroundColor Cyan
+Write-Host ""
+Write-Host "Bootstrap dotnet : $bootstrapDotnet"
+Write-Host "Bootstrap SDK : $bootstrapSdkPath"
+Write-Host "Bootstrap MSBuild: $bootstrapMSBuildExe"
+Write-Host ""
+
+# Verify bootstrap exists
+if (-not (Test-Path $bootstrapDotnet)) {
+ Write-Error "Bootstrap dotnet not found at $bootstrapDotnet. Run 'build.cmd' first."
+ exit 1
+}
+
+# --- Environment: enable callbacks + multithreaded ---
+$env:MSBUILDENABLETASKHOSTCALLBACKS = "1"
+$env:DOTNET_ROOT = "$bootstrapRoot\core"
+$env:DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR = "$bootstrapRoot\core"
+# Use bootstrap dotnet for SDK resolution
+$env:PATH = "$bootstrapRoot\core;$env:PATH"
+
+Write-Host "Environment:" -ForegroundColor Yellow
+Write-Host " MSBUILDENABLETASKHOSTCALLBACKS = 1"
+Write-Host " DOTNET_ROOT = $env:DOTNET_ROOT"
+Write-Host ""
+
+# --- Helper: Clone or update repo ---
+function Ensure-Repo {
+ param([string]$Org, [string]$Name, [string]$TargetDir)
+
+ if (Test-Path $TargetDir) {
+ if ($SkipClone) {
+ Write-Host "[$Name] Using existing clone at $TargetDir" -ForegroundColor Green
+ return
+ }
+ Write-Host "[$Name] Removing existing clone..." -ForegroundColor Yellow
+ Remove-Item -Recurse -Force $TargetDir
+ }
+
+ Write-Host "[$Name] Cloning $Org/$Name to $TargetDir (shallow)..." -ForegroundColor Yellow
+ git clone --depth 1 "https://github.com/$Org/$Name.git" $TargetDir
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to clone $Org/$Name"
+ return $false
+ }
+ return $true
+}
+
+# --- Helper: Build a repo ---
+function Build-Repo {
+ param(
+ [string]$Name,
+ [string]$RepoDir,
+ [string]$BuildCommand
+ )
+
+ Write-Host ""
+ Write-Host "============================================" -ForegroundColor Cyan
+ Write-Host " Building $Name" -ForegroundColor Cyan
+ Write-Host "============================================" -ForegroundColor Cyan
+
+ Push-Location $RepoDir
+ try {
+ if ($CleanBuild -and (Test-Path "artifacts\bin")) {
+ Write-Host "[$Name] Cleaning artifacts..." -ForegroundColor Yellow
+ Remove-Item -Recurse -Force "artifacts\bin" -ErrorAction SilentlyContinue
+ }
+
+ $logFile = "Q:\${Name}-build.binlog"
+ Write-Host "[$Name] Build command: $BuildCommand" -ForegroundColor Yellow
+ Write-Host "[$Name] Binlog: $logFile" -ForegroundColor Yellow
+ Write-Host ""
+
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
+ Invoke-Expression $BuildCommand | Out-Host
+ $exitCode = $LASTEXITCODE
+ $sw.Stop()
+
+ if ($exitCode -eq 0) {
+ Write-Host ""
+ Write-Host "[$Name] BUILD SUCCEEDED in $($sw.Elapsed.ToString('mm\:ss'))" -ForegroundColor Green
+ } else {
+ Write-Host ""
+ Write-Host "[$Name] BUILD FAILED (exit code $exitCode) after $($sw.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
+ Write-Host "[$Name] Check binlog: $logFile" -ForegroundColor Red
+ }
+
+ return $exitCode
+ } finally {
+ Pop-Location
+ }
+}
+
+# --- Ensure Q: exists ---
+if (-not (Test-Path "Q:\")) {
+ Write-Error "Q:\ drive does not exist. Please create or mount it first."
+ exit 1
+}
+
+$results = @{}
+
+# --- Roslyn ---
+if ($Repos -eq 'roslyn' -or $Repos -eq 'both') {
+ $roslynDir = "Q:\roslyn"
+ Ensure-Repo -Org "dotnet" -Name "roslyn" -TargetDir $roslynDir
+
+ if (Test-Path $roslynDir) {
+ # Patch global.json to allow rollForward from our bootstrap SDK
+ $globalJsonPath = "$roslynDir\global.json"
+ $globalJson = Get-Content $globalJsonPath -Raw | ConvertFrom-Json
+ $globalJson.sdk.rollForward = "latestFeature"
+ $globalJson | ConvertTo-Json -Depth 10 | Set-Content $globalJsonPath
+
+ # Build Roslyn Compilers solution filter (smaller, faster than full Roslyn.slnx)
+ # -mt enables multithreaded mode which ejects non-thread-safe tasks to TaskHost.
+ $roslynBuildCmd = "& `"$bootstrapDotnet`" build `"$roslynDir\Compilers.slnf`" -c Debug -mt /bl:`"Q:\roslyn-build.binlog`" -v:m --no-restore"
+
+ # Restore with bootstrap dotnet
+ Write-Host "[roslyn] Restoring Compilers.slnf..." -ForegroundColor Yellow
+ Push-Location $roslynDir
+ & $bootstrapDotnet restore Compilers.slnf -v:m 2>&1 | Select-Object -Last 10
+ $restoreExit = $LASTEXITCODE
+ Pop-Location
+
+ if ($restoreExit -ne 0) {
+ Write-Host "[roslyn] RESTORE FAILED (exit code $restoreExit)" -ForegroundColor Red
+ $results['roslyn'] = $restoreExit
+ } else {
+ $results['roslyn'] = Build-Repo -Name "roslyn" -RepoDir $roslynDir -BuildCommand $roslynBuildCmd
+ }
+ }
+}
+
+# --- WPF (dotnet new wpf) ---
+if ($Repos -eq 'wpf' -or $Repos -eq 'both') {
+ $wpfDir = "Q:\wpf-testproject"
+
+ if (-not (Test-Path $wpfDir) -or -not $SkipClone) {
+ Write-Host "[wpf] Creating new WPF project at $wpfDir..." -ForegroundColor Yellow
+ if (Test-Path $wpfDir) { Remove-Item -Recurse -Force $wpfDir }
+ New-Item -ItemType Directory -Path $wpfDir -Force | Out-Null
+
+ # Use system dotnet for scaffolding — temporarily unset bootstrap env to avoid conflicts
+ $savedDotnetRoot = $env:DOTNET_ROOT
+ $savedResolverDir = $env:DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR
+ $env:DOTNET_ROOT = $null
+ $env:DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR = $null
+
+ Push-Location $wpfDir
+ & "C:\Program Files\dotnet\dotnet.exe" new wpf -n WpfTestApp --force 2>&1 | Write-Host
+ Pop-Location
+
+ # Restore bootstrap env
+ $env:DOTNET_ROOT = $savedDotnetRoot
+ $env:DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR = $savedResolverDir
+ } else {
+ Write-Host "[wpf] Using existing WPF project at $wpfDir" -ForegroundColor Green
+ }
+
+ if (Test-Path "$wpfDir\WpfTestApp") {
+ $wpfBuildCmd = "& `"$bootstrapDotnet`" build `"$wpfDir\WpfTestApp\WpfTestApp.csproj`" -c Debug -mt /bl:`"Q:\wpf-build.binlog`" -v:m"
+
+ $results['wpf'] = Build-Repo -Name "wpf" -RepoDir "$wpfDir\WpfTestApp" -BuildCommand $wpfBuildCmd
+ } else {
+ Write-Host "[wpf] Project creation failed." -ForegroundColor Red
+ $results['wpf'] = 1
+ }
+}
+
+# --- Summary ---
+Write-Host ""
+Write-Host "============================================" -ForegroundColor Cyan
+Write-Host " Summary" -ForegroundColor Cyan
+Write-Host "============================================" -ForegroundColor Cyan
+
+foreach ($repo in $results.Keys) {
+ $code = $results[$repo]
+ $status = if ($code -eq 0) { "PASS" } else { "FAIL ($code)" }
+ $color = if ($code -eq 0) { "Green" } else { "Red" }
+ Write-Host " $repo : $status" -ForegroundColor $color
+}
+
+Write-Host ""
+Write-Host "Binlogs at Q:\*-build.binlog" -ForegroundColor Yellow
+Write-Host "Re-run with -SkipClone after rebuilding bootstrap." -ForegroundColor Yellow
diff --git a/src/Build.UnitTests/BackEnd/BuildProjectFileAndReportPidTask.cs b/src/Build.UnitTests/BackEnd/BuildProjectFileAndReportPidTask.cs
new file mode 100644
index 00000000000..864a85362b1
--- /dev/null
+++ b/src/Build.UnitTests/BackEnd/BuildProjectFileAndReportPidTask.cs
@@ -0,0 +1,73 @@
+// 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;
+using System.Diagnostics;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.Build.UnitTests.BackEnd
+{
+ ///
+ /// A test task that reports its process ID and optionally calls BuildProjectFile
+ /// on a child project. Used by TaskHost reuse E2E tests to verify that a parent
+ /// task and a nested child task run in the same OOP TaskHost process.
+ ///
+ public class BuildProjectFileAndReportPidTask : Task
+ {
+ ///
+ /// Path to the project file to build. If empty, just reports PID without building.
+ ///
+ public string ProjectFile { get; set; } = string.Empty;
+
+ ///
+ /// Semicolon-separated list of Property=Value pairs to pass as global properties.
+ ///
+ public string Properties { get; set; } = string.Empty;
+
+ ///
+ /// The process ID of the TaskHost running this task.
+ ///
+ [Output]
+ public int ProcessId { get; set; }
+
+ ///
+ /// Whether the child build succeeded (only meaningful when ProjectFile is set).
+ ///
+ [Output]
+ public bool BuildSucceeded { get; set; }
+
+ public override bool Execute()
+ {
+ ProcessId = Process.GetCurrentProcess().Id;
+ Log.LogMessage(MessageImportance.High, $"TASKHOST_PID={ProcessId}");
+
+ if (!string.IsNullOrEmpty(ProjectFile))
+ {
+ Hashtable? globalProperties = null;
+ if (!string.IsNullOrEmpty(Properties))
+ {
+ globalProperties = new Hashtable();
+ foreach (string pair in Properties.Split(';'))
+ {
+ string[] parts = pair.Split(new[] { '=' }, 2);
+ if (parts.Length == 2)
+ {
+ globalProperties[parts[0].Trim()] = parts[1].Trim();
+ }
+ }
+ }
+
+ Hashtable targetOutputs = new();
+ BuildSucceeded = BuildEngine.BuildProjectFile(ProjectFile, null, globalProperties, targetOutputs);
+ Log.LogMessage(MessageImportance.High, $"BuildProjectFile({ProjectFile}) = {BuildSucceeded}");
+ }
+ else
+ {
+ BuildSucceeded = true;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/Build.UnitTests/BackEnd/BuildProjectFileTask.cs b/src/Build.UnitTests/BackEnd/BuildProjectFileTask.cs
new file mode 100644
index 00000000000..ae4c0aeaaf0
--- /dev/null
+++ b/src/Build.UnitTests/BackEnd/BuildProjectFileTask.cs
@@ -0,0 +1,91 @@
+// 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;
+using System.Collections.Generic;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.Build.UnitTests.BackEnd
+{
+ ///
+ /// A test task that calls IBuildEngine.BuildProjectFile to build another project.
+ /// Used by TaskHostCallback_Tests (in-process) and NetTaskHost_E2E_Tests (cross-runtime).
+ /// The E2E project includes this file via linked compile to avoid duplication.
+ ///
+ public class BuildProjectFileTask : Task
+ {
+ ///
+ /// Path to the project file to build.
+ ///
+ [Required]
+ public string ProjectFile { get; set; } = string.Empty;
+
+ ///
+ /// Semicolon-separated list of targets to build. If empty, builds default targets.
+ ///
+ public string Targets { get; set; } = string.Empty;
+
+ ///
+ /// Semicolon-separated list of Property=Value pairs to pass as global properties.
+ ///
+ public string Properties { get; set; } = string.Empty;
+
+ ///
+ /// Whether the child build succeeded.
+ ///
+ [Output]
+ public bool BuildSucceeded { get; set; }
+
+ ///
+ /// Target output items from the child build (if any).
+ ///
+ [Output]
+ public ITaskItem[] OutputItems { get; set; } = [];
+
+ public override bool Execute()
+ {
+ string[]? targetNames = null;
+ if (!string.IsNullOrEmpty(Targets))
+ {
+ targetNames = Targets.Split(';');
+ }
+
+ Hashtable? globalProperties = null;
+ if (!string.IsNullOrEmpty(Properties))
+ {
+ globalProperties = new Hashtable();
+ foreach (string pair in Properties.Split(';'))
+ {
+ string[] parts = pair.Split('=');
+ if (parts.Length == 2)
+ {
+ globalProperties[parts[0].Trim()] = parts[1].Trim();
+ }
+ }
+ }
+
+ Hashtable targetOutputs = new();
+
+ BuildSucceeded = BuildEngine.BuildProjectFile(ProjectFile, targetNames, globalProperties, targetOutputs);
+
+ // Extract first target's outputs as ITaskItem[] if available.
+ if (BuildSucceeded && targetOutputs.Count > 0)
+ {
+ var items = new List();
+ foreach (DictionaryEntry entry in targetOutputs)
+ {
+ if (entry.Value is ITaskItem[] taskItems)
+ {
+ items.AddRange(taskItems);
+ }
+ }
+
+ OutputItems = items.ToArray();
+ }
+
+ Log.LogMessage(MessageImportance.High, $"BuildProjectFile({ProjectFile}) = {BuildSucceeded}, OutputItems={OutputItems.Length}");
+ return true;
+ }
+ }
+}
diff --git a/src/Build.UnitTests/BackEnd/TaskHostCallbackPacket_Tests.cs b/src/Build.UnitTests/BackEnd/TaskHostCallbackPacket_Tests.cs
index 36de360aa8f..0fbc2ce4496 100644
--- a/src/Build.UnitTests/BackEnd/TaskHostCallbackPacket_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskHostCallbackPacket_Tests.cs
@@ -1,7 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System;
+using System.Collections.Generic;
using Microsoft.Build.BackEnd;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Execution;
using Shouldly;
using Xunit;
@@ -84,5 +88,150 @@ public void TaskHostCoresResponse_RoundTrip_Serialization(int grantedCores)
deserialized.GrantedCores.ShouldBe(grantedCores);
deserialized.Type.ShouldBe(NodePacketType.TaskHostCoresResponse);
}
+
+ [Fact]
+ public void TaskHostBuildRequest_RoundTrip_Serialization()
+ {
+ Dictionary?[] globalProps = [new(StringComparer.OrdinalIgnoreCase) { ["Configuration"] = "Release" }, null];
+ List?[] removeProps = [new() { "Platform" }, null];
+ string?[] toolsVersions = ["17.0", null];
+ var request = new TaskHostBuildRequest(
+ ["proj1.csproj", "proj2.csproj"],
+ ["Build", "Test"],
+ globalProps,
+ removeProps,
+ toolsVersions!,
+ returnTargetOutputs: true);
+ request.RequestId = 55;
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ request.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostBuildRequest)TaskHostBuildRequest.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(55);
+ deserialized.Type.ShouldBe(NodePacketType.TaskHostBuildRequest);
+ deserialized.ProjectFileNames.ShouldBe(["proj1.csproj", "proj2.csproj"]);
+ deserialized.TargetNames.ShouldBe(["Build", "Test"]);
+ deserialized.ToolsVersions.ShouldBe(toolsVersions!);
+ deserialized.ReturnTargetOutputs.ShouldBeTrue();
+ deserialized.GlobalProperties!.Length.ShouldBe(2);
+ deserialized.GlobalProperties![0]!["Configuration"].ShouldBe("Release");
+ deserialized.GlobalProperties[1].ShouldBeNull();
+ deserialized.RemoveGlobalProperties!.Length.ShouldBe(2);
+ deserialized.RemoveGlobalProperties![0].ShouldBe(["Platform"]);
+ deserialized.RemoveGlobalProperties[1].ShouldBeNull();
+ }
+
+ [Fact]
+ public void TaskHostBuildRequest_NullArrays_RoundTrip_Serialization()
+ {
+ var request = new TaskHostBuildRequest(
+ null, null, null, null, null, returnTargetOutputs: false);
+ request.RequestId = 10;
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ request.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostBuildRequest)TaskHostBuildRequest.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(10);
+ deserialized.ProjectFileNames.ShouldBeNull();
+ deserialized.TargetNames.ShouldBeNull();
+ deserialized.GlobalProperties.ShouldBeNull();
+ deserialized.RemoveGlobalProperties.ShouldBeNull();
+ deserialized.ToolsVersions.ShouldBeNull();
+ deserialized.ReturnTargetOutputs.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void TaskHostBuildResponse_Success_WithOutputs_RoundTrip_Serialization()
+ {
+ var outputs = new List>
+ {
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Build"] = new TaskParameter(new ITaskItem[] { new Utilities.TaskItem("item1.dll") }),
+ ["Test"] = new TaskParameter(new ITaskItem[] { new Utilities.TaskItem("result.trx") })
+ }
+ };
+
+ var response = new TaskHostBuildResponse(88, true, outputs);
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ response.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostBuildResponse)TaskHostBuildResponse.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(88);
+ deserialized.Success.ShouldBeTrue();
+ deserialized.Type.ShouldBe(NodePacketType.TaskHostBuildResponse);
+ deserialized.TargetOutputsPerProject.ShouldNotBeNull();
+ deserialized.TargetOutputsPerProject.Count.ShouldBe(1);
+ deserialized.TargetOutputsPerProject[0].ContainsKey("Build").ShouldBeTrue();
+
+ var buildEngineResult = deserialized.ToBuildEngineResult();
+ buildEngineResult.Result.ShouldBeTrue();
+ buildEngineResult.TargetOutputsPerProject.Count.ShouldBe(1);
+ buildEngineResult.TargetOutputsPerProject[0]["Build"].Length.ShouldBe(1);
+ buildEngineResult.TargetOutputsPerProject[0]["Build"][0].ItemSpec.ShouldBe("item1.dll");
+ }
+
+ [Fact]
+ public void TaskHostBuildResponse_Failure_NoOutputs_RoundTrip_Serialization()
+ {
+ var response = new TaskHostBuildResponse(33, false, null);
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ response.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostBuildResponse)TaskHostBuildResponse.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(33);
+ deserialized.Success.ShouldBeFalse();
+ deserialized.TargetOutputsPerProject.ShouldBeNull();
+
+ var buildEngineResult = deserialized.ToBuildEngineResult();
+ buildEngineResult.Result.ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData((byte)YieldOperation.Yield)]
+ [InlineData((byte)YieldOperation.Reacquire)]
+ public void TaskHostYieldRequest_RoundTrip_Serialization(byte operationByte)
+ {
+ YieldOperation operation = (YieldOperation)operationByte;
+ var request = new TaskHostYieldRequest(operation);
+ request.RequestId = 77;
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ request.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostYieldRequest)TaskHostYieldRequest.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(77);
+ deserialized.Operation.ShouldBe(operation);
+ deserialized.Type.ShouldBe(NodePacketType.TaskHostYieldRequest);
+ }
+
+ [Fact]
+ public void TaskHostYieldResponse_RoundTrip_Serialization()
+ {
+ var response = new TaskHostYieldResponse(42);
+
+ ITranslator writeTranslator = TranslationHelpers.GetWriteTranslator();
+ response.Translate(writeTranslator);
+
+ ITranslator readTranslator = TranslationHelpers.GetReadTranslator();
+ var deserialized = (TaskHostYieldResponse)TaskHostYieldResponse.FactoryForDeserialization(readTranslator);
+
+ deserialized.RequestId.ShouldBe(42);
+ deserialized.Type.ShouldBe(NodePacketType.TaskHostYieldResponse);
+ }
}
}
diff --git a/src/Build.UnitTests/BackEnd/TaskHostCallback_Tests.cs b/src/Build.UnitTests/BackEnd/TaskHostCallback_Tests.cs
index 301e8c57a52..f2964ac045c 100644
--- a/src/Build.UnitTests/BackEnd/TaskHostCallback_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskHostCallback_Tests.cs
@@ -278,5 +278,263 @@ public void RequestCores_LogsErrorWhenCallbacksNotSupported()
logger.ErrorCount.ShouldBeGreaterThan(0);
logger.FullLog.ShouldContain("MSB5022");
}
+
+ ///
+ /// Verifies BuildProjectFile callback works when task is explicitly run in TaskHost via TaskHostFactory.
+ /// The child project should build and the task should return success.
+ ///
+ [Fact]
+ public void BuildProjectFile_WorksWithExplicitTaskHostFactory()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ string childProject = env.CreateFile("Child.proj", """
+
+
+
+
+
+ """).Path;
+
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
+
+ {nameof(BuildProjectFileTask)}>
+
+";
+
+ 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");
+ }
+
+ ///
+ /// Verifies BuildProjectFile forwards global properties to the child build.
+ ///
+ [Fact]
+ public void BuildProjectFile_ForwardsGlobalProperties()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ string childProject = env.CreateFile("Child.proj", """
+
+
+
+
+
+ """).Path;
+
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"" Properties=""Configuration=Release"">
+
+ {nameof(BuildProjectFileTask)}>
+
+";
+
+ 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");
+ }
+
+ ///
+ /// Verifies BuildProjectFile returns ITaskItem[] target outputs through the TaskHost callback.
+ ///
+ [Fact]
+ public void BuildProjectFile_ReturnsTargetOutputs()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ string childProject = env.CreateFile("Child.proj", """
+
+
+
+ Value1
+
+
+
+
+
+
+
+ """).Path;
+
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""GetOutputs"">
+
+
+ {nameof(BuildProjectFileTask)}>
+ Count())"" Importance=""high"" />
+
+";
+
+ 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");
+ }
+
+ ///
+ /// Verifies BuildProjectFile returns false when the child project fails.
+ ///
+ [Fact]
+ public void BuildProjectFile_ChildFailure_ReturnsFalse()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ string childProject = env.CreateFile("Child.proj", """
+
+
+
+
+
+ """).Path;
+
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
+
+ {nameof(BuildProjectFileTask)}>
+
+
+";
+
+ 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");
+ }
+
+ ///
+ /// Verifies BuildProjectFile auto-ejection works in multithreaded mode.
+ ///
+ [Fact]
+ public void BuildProjectFile_WorksWhenAutoEjectedInMultiThreadedMode()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+ string testDir = env.CreateFolder().Path;
+
+ string childProject = Path.Combine(testDir, "Child.proj");
+ File.WriteAllText(childProject, """
+
+
+
+
+
+ """);
+
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
+
+ {nameof(BuildProjectFileTask)}>
+
+";
+
+ 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(), null, ["Test"], null));
+
+ buildResult.OverallResult.ShouldBe(BuildResultCode.Success);
+ logger.FullLog.ShouldContain("external task host");
+ logger.FullLog.ShouldContain("ChildBuiltInMT");
+ }
+
+ ///
+ /// Verifies that BuildProjectFile when callbacks are disabled logs error MSB5022.
+ ///
+ [Fact]
+ public void BuildProjectFile_LogsErrorWhenCallbacksNotSupported()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+
+ string childProject = env.CreateFile("Child.proj", """
+
+
+
+
+
+ """).Path;
+
+ // Explicitly do NOT set MSBUILDENABLETASKHOSTCALLBACKS
+ string projectContents = $@"
+
+
+
+ <{nameof(BuildProjectFileTask)} ProjectFile=""{childProject}"" Targets=""Build"">
+
+ {nameof(BuildProjectFileTask)}>
+
+";
+
+ 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"]));
+
+ // MSB5022 error should be logged
+ logger.ErrorCount.ShouldBeGreaterThan(0);
+ logger.FullLog.ShouldContain("MSB5022");
+ // Child should not have been built
+ logger.FullLog.ShouldNotContain("ShouldNotRun");
+ }
}
}
diff --git a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs
index 70a8d38733d..0e03bdc99a8 100644
--- a/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs
+++ b/src/Build.UnitTests/NetTaskHost_E2E_Tests.cs
@@ -227,6 +227,29 @@ public void NetTaskHost_CallbackRequestCoresTest()
testTaskOutput.ShouldContain("CallbackResult: RequestCores(2) =");
}
+ [WindowsFullFrameworkOnlyFact]
+ public void NetTaskHost_CallbackBuildProjectFileTest()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ var coreDirectory = Path.Combine(RunnerUtilities.BootstrapRootPath, "core");
+ env.SetEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR", coreDirectory);
+
+ string testProjectPath = Path.Combine(TestAssetsRootPath, "ExampleNetTask", "TestNetTaskBuildCallback", "TestNetTaskBuildCallback.csproj");
+
+ string testTaskOutput = RunnerUtilities.ExecBootstrapedMSBuild($"{testProjectPath} -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild} -t:TestTask", out bool successTestTask);
+
+ if (!successTestTask)
+ {
+ _output.WriteLine(testTaskOutput);
+ }
+
+ successTestTask.ShouldBeTrue();
+ testTaskOutput.ShouldContain("CallbackResult: BuildProjectFile = True");
+ testTaskOutput.ShouldContain("ChildProject: GetOutputs target executed");
+ }
+
[WindowsFullFrameworkOnlyFact] // This test verifies the fallback behavior with implicit host parameters.
public void NetTaskWithImplicitHostParamsTest_FallbackToDotnet()
{
@@ -274,5 +297,39 @@ public void NetTaskWithImplicitHostParamsTest_AppHost()
testTaskOutput.ShouldContain("The task is executed in process: MSBuild");
testTaskOutput.ShouldContain("/nodereuse:True");
}
+ [WindowsFullFrameworkOnlyFact]
+ public void NetTaskHost_TaskHostProcessReuse_SameProcessForNestedBuild()
+ {
+ using TestEnvironment env = TestEnvironment.Create(_output);
+ env.SetEnvironmentVariable("MSBUILDENABLETASKHOSTCALLBACKS", "1");
+
+ var coreDirectory = Path.Combine(RunnerUtilities.BootstrapRootPath, "core");
+ env.SetEnvironmentVariable("DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR", coreDirectory);
+
+ string testProjectPath = Path.Combine(TestAssetsRootPath, "ExampleNetTask", "TestNetTaskHostReuse", "TestNetTaskHostReuse.csproj");
+
+ string testTaskOutput = RunnerUtilities.ExecBootstrapedMSBuild($"{testProjectPath} -v:n -p:LatestDotNetCoreForMSBuild={RunnerUtilities.LatestDotNetCoreForMSBuild} -t:TestTaskHostReuse", out bool successTestTask);
+
+ _output.WriteLine(testTaskOutput);
+
+ successTestTask.ShouldBeTrue();
+
+ // Both the parent task and child task should report TASKHOST_PID
+ testTaskOutput.ShouldContain("TASKHOST_PID=");
+ testTaskOutput.ShouldContain("PARENT_TASKHOST_PID=");
+ testTaskOutput.ShouldContain("CHILD_TASKHOST_PID=");
+
+ // Extract PIDs from output and verify they match (same process reused)
+ var parentPidMatch = System.Text.RegularExpressions.Regex.Match(testTaskOutput, @"PARENT_TASKHOST_PID=(\d+)");
+ var childPidMatch = System.Text.RegularExpressions.Regex.Match(testTaskOutput, @"CHILD_TASKHOST_PID=(\d+)");
+
+ parentPidMatch.Success.ShouldBeTrue("Should find PARENT_TASKHOST_PID in output");
+ childPidMatch.Success.ShouldBeTrue("Should find CHILD_TASKHOST_PID in output");
+
+ string parentPid = parentPidMatch.Groups[1].Value;
+ string childPid = childPidMatch.Groups[1].Value;
+
+ parentPid.ShouldBe(childPid, $"Parent PID ({parentPid}) and child PID ({childPid}) should match — same TaskHost process should be reused for nested build");
+ }
}
}
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/ExampleTask/ExampleTask.csproj b/src/Build.UnitTests/TestAssets/ExampleNetTask/ExampleTask/ExampleTask.csproj
index b6ef11cdaec..b6bdb6cdd24 100644
--- a/src/Build.UnitTests/TestAssets/ExampleNetTask/ExampleTask/ExampleTask.csproj
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/ExampleTask/ExampleTask.csproj
@@ -17,6 +17,8 @@
+
+
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/ChildProject.proj b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/ChildProject.proj
new file mode 100644
index 00000000000..bf232282a27
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/ChildProject.proj
@@ -0,0 +1,11 @@
+
+
+
+ MetadataValue
+
+
+
+
+
+
+
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/TestNetTaskBuildCallback.csproj b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/TestNetTaskBuildCallback.csproj
new file mode 100644
index 00000000000..bbd9bbb81eb
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/TestNetTaskBuildCallback.csproj
@@ -0,0 +1,30 @@
+
+
+
+ $(LatestDotNetCoreForMSBuild)
+
+
+
+ $([System.IO.Path]::GetFullPath('$([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', '..', '..', '..', '..'))'))
+ $([System.IO.Path]::Combine('$(TestProjectFolder)', '$(TargetFramework)', 'ExampleTask.dll'))
+ $([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', 'ChildProject.proj'))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/global.json b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/global.json
new file mode 100644
index 00000000000..2c63c085104
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskBuildCallback/global.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/ChildProject.proj b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/ChildProject.proj
new file mode 100644
index 00000000000..3519828bd15
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/ChildProject.proj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/TestNetTaskHostReuse.csproj b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/TestNetTaskHostReuse.csproj
new file mode 100644
index 00000000000..512ab717b31
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/TestNetTaskHostReuse.csproj
@@ -0,0 +1,34 @@
+
+
+
+ $(LatestDotNetCoreForMSBuild)
+
+
+
+ $([System.IO.Path]::GetFullPath('$([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', '..', '..', '..', '..'))'))
+ $([System.IO.Path]::Combine('$(TestProjectFolder)', '$(TargetFramework)', 'ExampleTask.dll'))
+ $([System.IO.Path]::Combine('$(MSBuildThisFileDirectory)', 'ChildProject.proj'))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/global.json b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/global.json
new file mode 100644
index 00000000000..cd9fb530953
--- /dev/null
+++ b/src/Build.UnitTests/TestAssets/ExampleNetTask/TestNetTaskHostReuse/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "allowPrerelease": true,
+ "rollForward": "latestMajor"
+ }
+}
diff --git a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
index a7987f124f7..2a05eef0bd3 100644
--- a/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
+++ b/src/Build/BackEnd/Components/Communications/NodeProviderOutOfProcTaskHost.cs
@@ -102,11 +102,12 @@ internal class NodeProviderOutOfProcTaskHost : NodeProviderOutOfProcBase, INodeP
///
/// A mapping of all of the INodePacketHandlers wrapped by this provider.
+ /// When multiple tasks use the same node (nested BuildProjectFile), handlers
+ /// are stacked. The most recent handler receives packets. When it disconnects,
+ /// the previous handler is restored.
/// Keyed by the communication node ID (NodeContext.NodeId) for O(1) packet routing.
- /// Thread-safe to support parallel taskhost creation in /mt mode where multiple thread nodes
- /// can simultaneously create their own taskhosts.
///
- private ConcurrentDictionary _nodeIdToPacketHandler;
+ private ConcurrentDictionary> _nodeIdToPacketHandlerStack;
///
/// Keeps track of the set of node IDs for which we have not yet received shutdown notification.
@@ -241,7 +242,7 @@ public void InitializeComponent(IBuildComponentHost host)
_nodeContexts = new ConcurrentDictionary();
_nodeIdToNodeKey = new ConcurrentDictionary();
_nodeIdToPacketFactory = new ConcurrentDictionary();
- _nodeIdToPacketHandler = new ConcurrentDictionary();
+ _nodeIdToPacketHandlerStack = new ConcurrentDictionary>();
_activeNodes = [];
_nextNodeId = 0;
@@ -251,6 +252,14 @@ public void InitializeComponent(IBuildComponentHost host)
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.LogMessage, LogMessagePacket.FactoryForDeserialization, this);
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostTaskComplete, TaskHostTaskComplete.FactoryForDeserialization, this);
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.NodeShutdown, NodeShutdown.FactoryForDeserialization, this);
+
+ // Register callback request packet types so we can deserialize them when
+ // they arrive from TaskHost processes. These are forwarded to the current
+ // TaskHostTask handler via the handler stack.
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostIsRunningMultipleNodesRequest, TaskHostIsRunningMultipleNodesRequest.FactoryForDeserialization, this);
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostCoresRequest, TaskHostCoresRequest.FactoryForDeserialization, this);
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostBuildRequest, TaskHostBuildRequest.FactoryForDeserialization, this);
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostYieldRequest, TaskHostYieldRequest.FactoryForDeserialization, this);
}
///
@@ -286,20 +295,17 @@ public void UnregisterPacketHandler(NodePacketType packetType)
///
/// Takes a serializer, deserializes the packet and routes it to the appropriate handler.
+ /// Always uses the local packet factory for deserialization, which routes through
+ /// our PacketReceived method (using the handler stack).
///
/// The node from which the packet was received.
/// The packet type.
/// The translator containing the data from which the packet should be reconstructed.
public void DeserializeAndRoutePacket(int nodeId, NodePacketType packetType, ITranslator translator)
{
- if (_nodeIdToPacketFactory.TryGetValue(nodeId, out INodePacketFactory nodePacketFactory))
- {
- nodePacketFactory.DeserializeAndRoutePacket(nodeId, packetType, translator);
- }
- else
- {
- _localPacketFactory.DeserializeAndRoutePacket(nodeId, packetType, translator);
- }
+ // Always route through our local factory which handles deserialization
+ // and routes to our PacketReceived, which uses the handler stack.
+ _localPacketFactory.DeserializeAndRoutePacket(nodeId, packetType, translator);
}
///
@@ -313,20 +319,13 @@ public INodePacket DeserializePacket(NodePacketType packetType, ITranslator tran
}
///
- /// Routes the specified packet
+ /// Routes the specified packet through our PacketReceived method (handler stack).
///
/// The node from which the packet was received.
/// The packet to route.
public void RoutePacket(int nodeId, INodePacket packet)
{
- if (_nodeIdToPacketFactory.TryGetValue(nodeId, out INodePacketFactory nodePacketFactory))
- {
- nodePacketFactory.RoutePacket(nodeId, packet);
- }
- else
- {
- _localPacketFactory.RoutePacket(nodeId, packet);
- }
+ _localPacketFactory.RoutePacket(nodeId, packet);
}
#endregion
@@ -341,27 +340,46 @@ public void RoutePacket(int nodeId, INodePacket packet)
/// The packet.
public void PacketReceived(int node, INodePacket packet)
{
- if (_nodeIdToPacketHandler.TryGetValue(node, out INodePacketHandler packetHandler))
+ // Try to find the handler stack for this node and forward the packet.
+ // Lock on the stack to synchronize with DisconnectFromHost to prevent race conditions
+ // where we get the handler right before it's popped from the stack.
+ if (_nodeIdToPacketHandlerStack.TryGetValue(node, out Stack handlerStack))
{
- packetHandler.PacketReceived(node, packet);
- }
- else
- {
- ErrorUtilities.VerifyThrow(packet.Type == NodePacketType.NodeShutdown, "We should only ever handle packets of type NodeShutdown -- everything else should only come in when there's an active task");
-
- // May also be removed by unnatural termination, so don't assume it's there
- lock (_activeNodes)
+ lock (handlerStack)
{
- if (_activeNodes.Contains(node))
+ if (handlerStack.Count > 0)
{
- _activeNodes.Remove(node);
+ INodePacketHandler packetHandler = handlerStack.Peek();
+ packetHandler.PacketReceived(node, packet);
+ return;
}
+ }
+ }
- if (_activeNodes.Count == 0)
+ // No handler on the stack. This can happen in nested BuildProjectFile scenarios
+ // where late-arriving packets arrive after the handler has disconnected.
+ switch (packet.Type)
+ {
+ case NodePacketType.NodeShutdown:
+ // Expected - node is shutting down and no handler needed
+ lock (_activeNodes)
{
- _noNodesActiveEvent.Set();
+ if (_activeNodes.Contains(node))
+ {
+ _activeNodes.Remove(node);
+ }
+
+ if (_activeNodes.Count == 0)
+ {
+ _noNodesActiveEvent.Set();
+ }
}
- }
+
+ break;
+ default:
+ // Late-arriving packets (log messages, completion) after handler disconnected
+ // can be safely ignored.
+ break;
}
}
@@ -613,9 +631,25 @@ internal bool AcquireAndSetUpHost(
if (nodeCreationSucceeded)
{
NodeContext context = _nodeContexts[nodeKey];
- // Map the transport ID directly to the handlers for O(1) packet routing
- _nodeIdToPacketFactory[context.NodeId] = factory;
- _nodeIdToPacketHandler[context.NodeId] = handler;
+
+ // Only register the factory for the first task on this node.
+ // For nested tasks (BuildProjectFile callbacks), we reuse the existing
+ // factory setup and just push a new handler onto the stack.
+ // This ensures all packets are routed through NodeProviderOutOfProcTaskHost.PacketReceived
+ // which uses the handler stack, rather than going directly to individual TaskHostTask handlers.
+ if (!_nodeIdToPacketFactory.ContainsKey(context.NodeId))
+ {
+ _nodeIdToPacketFactory[context.NodeId] = this;
+ }
+
+ // Push the new handler onto the stack. This supports nested tasks
+ // (e.g., BuildProjectFile callbacks) where multiple TaskHostTask instances
+ // share the same TaskHost process.
+ Stack handlerStack = _nodeIdToPacketHandlerStack.GetOrAdd(context.NodeId, _ => new Stack());
+ lock (handlerStack)
+ {
+ handlerStack.Push(handler);
+ }
// Configure the node.
context.SendData(configuration);
@@ -641,10 +675,27 @@ internal void DisconnectFromHost(TaskHostNodeKey nodeKey)
return;
}
- bool successRemoveFactory = _nodeIdToPacketFactory.TryRemove(context.NodeId, out _);
- bool successRemoveHandler = _nodeIdToPacketHandler.TryRemove(context.NodeId, out _);
+ int nodeId = context.NodeId;
- ErrorUtilities.VerifyThrow(successRemoveFactory && successRemoveHandler, "Why are we trying to disconnect from a context that we already disconnected from? Did we call DisconnectFromHost twice?");
+ // Pop the handler from the stack. If there are still handlers remaining,
+ // the previous handler becomes active again (supporting nested tasks).
+ if (_nodeIdToPacketHandlerStack.TryGetValue(nodeId, out Stack handlerStack))
+ {
+ lock (handlerStack)
+ {
+ if (handlerStack.Count > 0)
+ {
+ handlerStack.Pop();
+ }
+
+ // Only fully disconnect when all handlers are done
+ if (handlerStack.Count == 0)
+ {
+ _nodeIdToPacketFactory.TryRemove(nodeId, out _);
+ _nodeIdToPacketHandlerStack.TryRemove(nodeId, out _);
+ }
+ }
+ }
}
///
diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs
index 4a9dfdd5180..3791fc529f6 100644
--- a/src/Build/Instance/TaskFactories/TaskHostTask.cs
+++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs
@@ -207,6 +207,8 @@ public TaskHostTask(
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.NodeShutdown, NodeShutdown.FactoryForDeserialization, this);
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostIsRunningMultipleNodesRequest, TaskHostIsRunningMultipleNodesRequest.FactoryForDeserialization, this);
(this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostCoresRequest, TaskHostCoresRequest.FactoryForDeserialization, this);
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostBuildRequest, TaskHostBuildRequest.FactoryForDeserialization, this);
+ (this as INodePacketFactory).RegisterPacketHandler(NodePacketType.TaskHostYieldRequest, TaskHostYieldRequest.FactoryForDeserialization, this);
_packetReceivedEvent = new AutoResetEvent(false);
_receivedPackets = new ConcurrentQueue();
@@ -519,6 +521,12 @@ private void HandlePacket(INodePacket packet, out bool taskFinished)
case NodePacketType.TaskHostCoresRequest:
HandleCoresRequest(packet as TaskHostCoresRequest);
break;
+ case NodePacketType.TaskHostBuildRequest:
+ HandleBuildRequest(packet as TaskHostBuildRequest);
+ break;
+ case NodePacketType.TaskHostYieldRequest:
+ HandleYieldRequest(packet as TaskHostYieldRequest);
+ break;
default:
ErrorUtilities.ThrowInternalErrorUnreachable();
break;
@@ -696,6 +704,97 @@ private void HandleCoresRequest(TaskHostCoresRequest request)
_taskHostProvider.SendData(_taskHostNodeKey, response);
}
+ ///
+ /// Handle BuildProjectFile* request from the TaskHost.
+ /// Forwards the call to the in-process IBuildEngine3.BuildProjectFilesInParallel,
+ /// which handles project resolution, scheduler interaction, and target execution.
+ ///
+ private void HandleBuildRequest(TaskHostBuildRequest request)
+ {
+ TaskHostBuildResponse response;
+ try
+ {
+ if (_buildEngine is not IBuildEngine3 engine3)
+ {
+ response = new TaskHostBuildResponse(request.RequestId, false, null);
+ _taskHostProvider.SendData(_taskHostNodeKey, response);
+ return;
+ }
+
+ // Reconstruct IDictionary[] from the serialized Dictionary[]
+ System.Collections.IDictionary[] globalProperties = null;
+ if (request.GlobalProperties is not null)
+ {
+ globalProperties = new System.Collections.IDictionary[request.GlobalProperties.Length];
+ for (int i = 0; i < request.GlobalProperties.Length; i++)
+ {
+ globalProperties[i] = request.GlobalProperties[i];
+ }
+ }
+
+ // Reconstruct IList[] from List[]
+ IList[] removeGlobalProperties = null;
+ if (request.RemoveGlobalProperties is not null)
+ {
+ removeGlobalProperties = new IList[request.RemoveGlobalProperties.Length];
+ for (int i = 0; i < request.RemoveGlobalProperties.Length; i++)
+ {
+ removeGlobalProperties[i] = request.RemoveGlobalProperties[i];
+ }
+ }
+
+ BuildEngineResult result = engine3.BuildProjectFilesInParallel(
+ request.ProjectFileNames,
+ request.TargetNames,
+ globalProperties,
+ removeGlobalProperties,
+ request.ToolsVersions,
+ request.ReturnTargetOutputs);
+
+ response = TaskHostBuildResponse.FromBuildEngineResult(request.RequestId, result);
+ }
+ catch (Exception ex) when (!ExceptionHandling.IsCriticalException(ex))
+ {
+ // Always send a response to prevent the OOP task from hanging.
+ response = new TaskHostBuildResponse(request.RequestId, false, null);
+ }
+
+ _taskHostProvider.SendData(_taskHostNodeKey, response);
+ }
+
+ ///
+ /// Handles Yield/Reacquire requests from the TaskHost.
+ ///
+ /// Yield is fire-and-forget: forwards to , no response sent.
+ /// Reacquire is blocking: forwards to (which may block
+ /// on the scheduler), then sends to unblock the TaskHost.
+ ///
+ ///
+ private void HandleYieldRequest(TaskHostYieldRequest request)
+ {
+ switch (request.Operation)
+ {
+ case YieldOperation.Yield:
+ if (_buildEngine is IBuildEngine3 engine3Yield)
+ {
+ engine3Yield.Yield();
+ }
+
+ // No response — Yield is fire-and-forget.
+ break;
+
+ case YieldOperation.Reacquire:
+ if (_buildEngine is IBuildEngine3 engine3Reacquire)
+ {
+ engine3Reacquire.Reacquire();
+ }
+
+ // Send acknowledgment to unblock the TaskHost.
+ _taskHostProvider.SendData(_taskHostNodeKey, new TaskHostYieldResponse(request.RequestId));
+ break;
+ }
+ }
+
///
/// Since we log that we weren't able to connect to the task host in a couple of different places,
/// extract it out into a separate method.
diff --git a/src/Build/Microsoft.Build.csproj b/src/Build/Microsoft.Build.csproj
index 5343eabdc97..0f3a19db581 100644
--- a/src/Build/Microsoft.Build.csproj
+++ b/src/Build/Microsoft.Build.csproj
@@ -127,6 +127,10 @@
+
+
+
+
diff --git a/src/MSBuild/MSBuild.csproj b/src/MSBuild/MSBuild.csproj
index cf205b2366d..723871cfc99 100644
--- a/src/MSBuild/MSBuild.csproj
+++ b/src/MSBuild/MSBuild.csproj
@@ -118,6 +118,10 @@
+
+
+
+
@@ -147,6 +151,7 @@
+
diff --git a/src/MSBuild/OutOfProcTaskHostNode.cs b/src/MSBuild/OutOfProcTaskHostNode.cs
index 75716b705a7..cd30b3007ad 100644
--- a/src/MSBuild/OutOfProcTaskHostNode.cs
+++ b/src/MSBuild/OutOfProcTaskHostNode.cs
@@ -114,9 +114,19 @@ internal class OutOfProcTaskHostNode :
private NodeEngineShutdownReason _shutdownReason;
///
- /// We set this flag to track a currently executing task
+ /// Count of tasks that are actively executing (not yielded).
+ /// When a task yields, this decrements. When it reacquires, this increments.
+ /// Used to determine if we can accept new TaskHostConfiguration packets.
///
- private bool _isTaskExecuting;
+ private int _activeTaskCount;
+
+#if !CLR2COMPATIBILITY
+ ///
+ /// Number of tasks currently yielded (blocked on a BuildProjectFile callback or explicit Yield).
+ /// A yielded task does NOT prevent new tasks from being scheduled to this TaskHost.
+ ///
+ private int _yieldedTaskCount;
+#endif
///
/// The event which is set when a task has completed.
@@ -124,15 +134,22 @@ internal class OutOfProcTaskHostNode :
private AutoResetEvent _taskCompleteEvent;
///
- /// Packet containing all the information relating to the
- /// completed state of the task.
+ /// Queue of completed task packets waiting to be sent by the main thread.
+ /// Using a queue instead of a single slot prevents lost completions when
+ /// multiple tasks finish concurrently (e.g., nested task reuse scenarios).
///
+#if !CLR2COMPATIBILITY
+ private readonly ConcurrentQueue _taskCompleteQueue = new();
+#else
private TaskHostTaskComplete _taskCompletePacket;
+#endif
///
- /// Object used to synchronize access to taskCompletePacket
+ /// Object used to synchronize access to taskCompletePacket (CLR2 only)
///
+#if CLR2COMPATIBILITY
private LockType _taskCompleteLock = new();
+#endif
///
/// The event which is set when a task is cancelled
@@ -196,9 +213,29 @@ internal class OutOfProcTaskHostNode :
///
/// Pending callback requests awaiting responses from the owning worker node.
/// Key is the request ID, value is the TaskCompletionSource to signal when response arrives.
+ /// This is the fallback for backward compatibility when task context is not available.
///
private readonly ConcurrentDictionary> _pendingCallbackRequests = new();
+ ///
+ /// All active task execution contexts, keyed by task ID.
+ /// Supports concurrent task execution when tasks yield or await callbacks.
+ ///
+ private readonly ConcurrentDictionary _taskContexts
+ = new ConcurrentDictionary();
+
+ ///
+ /// The currently active task context for the calling thread.
+ /// Uses AsyncLocal to support concurrent task threads with proper context isolation.
+ ///
+ private readonly AsyncLocal _currentTaskContext
+ = new AsyncLocal();
+
+ ///
+ /// Counter for generating task IDs when configuration doesn't provide one.
+ ///
+ private int _nextLocalTaskId;
+
///
/// The packet version negotiated with the owning worker node.
/// Used to determine if the worker node supports callback packets.
@@ -218,6 +255,18 @@ internal class OutOfProcTaskHostNode :
private bool CallbacksSupported => _parentPacketVersion >= CallbacksMinPacketVersion || Traits.Instance.EnableTaskHostCallbacks;
#endif
+ ///
+ /// Gets the effective configuration for the current task thread.
+ /// In concurrent-task mode (non-CLR2), uses the per-task context first.
+ /// Falls back to .
+ ///
+ private TaskHostConfiguration EffectiveConfiguration =>
+#if !CLR2COMPATIBILITY
+ GetCurrentConfiguration();
+#else
+ _currentConfiguration;
+#endif
+
///
/// Constructor.
///
@@ -247,6 +296,8 @@ public OutOfProcTaskHostNode()
#if !CLR2COMPATIBILITY
thisINodePacketFactory.RegisterPacketHandler(NodePacketType.TaskHostIsRunningMultipleNodesResponse, TaskHostIsRunningMultipleNodesResponse.FactoryForDeserialization, this);
thisINodePacketFactory.RegisterPacketHandler(NodePacketType.TaskHostCoresResponse, TaskHostCoresResponse.FactoryForDeserialization, this);
+ thisINodePacketFactory.RegisterPacketHandler(NodePacketType.TaskHostBuildResponse, TaskHostBuildResponse.FactoryForDeserialization, this);
+ thisINodePacketFactory.RegisterPacketHandler(NodePacketType.TaskHostYieldResponse, TaskHostYieldResponse.FactoryForDeserialization, this);
#endif
#if !CLR2COMPATIBILITY
@@ -263,8 +314,8 @@ public bool ContinueOnError
{
get
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
- return _currentConfiguration.ContinueOnError;
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
+ return EffectiveConfiguration.ContinueOnError;
}
}
@@ -275,8 +326,8 @@ public int LineNumberOfTaskNode
{
get
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
- return _currentConfiguration.LineNumberOfTask;
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
+ return EffectiveConfiguration.LineNumberOfTask;
}
}
@@ -287,8 +338,8 @@ public int ColumnNumberOfTaskNode
{
get
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
- return _currentConfiguration.ColumnNumberOfTask;
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
+ return EffectiveConfiguration.ColumnNumberOfTask;
}
}
@@ -299,8 +350,8 @@ public string ProjectFileOfTaskNode
{
get
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
- return _currentConfiguration.ProjectFileOfTask;
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
+ return EffectiveConfiguration.ProjectFileOfTask;
}
}
@@ -415,13 +466,11 @@ public void LogCustomEvent(CustomBuildEventArgs e)
}
///
- /// Stub implementation of IBuildEngine.BuildProjectFile. The task host does not support IBuildEngine
- /// callbacks for the purposes of building projects, so error.
+ /// Implementation of IBuildEngine.BuildProjectFile. Delegates to the 5-param overload.
///
public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs)
{
- LogErrorFromResource("BuildEngineCallbacksInTaskHostUnsupported");
- return false;
+ return BuildProjectFile(projectFileName, targetNames, globalProperties, targetOutputs, null);
}
#endregion // IBuildEngine Implementation (Methods)
@@ -429,23 +478,44 @@ public bool BuildProjectFile(string projectFileName, string[] targetNames, IDict
#region IBuildEngine2 Implementation (Methods)
///
- /// Stub implementation of IBuildEngine2.BuildProjectFile. The task host does not support IBuildEngine
- /// callbacks for the purposes of building projects, so error.
+ /// Implementation of IBuildEngine2.BuildProjectFile. Delegates to the 7-param BuildProjectFilesInParallel.
///
public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs, string toolsVersion)
{
- LogErrorFromResource("BuildEngineCallbacksInTaskHostUnsupported");
- return false;
+ return BuildProjectFilesInParallel(
+ [projectFileName],
+ targetNames,
+ [globalProperties],
+ [targetOutputs],
+ [toolsVersion],
+ true,
+ false);
}
///
- /// Stub implementation of IBuildEngine2.BuildProjectFilesInParallel. The task host does not support IBuildEngine
- /// callbacks for the purposes of building projects, so error.
+ /// Implementation of IBuildEngine2.BuildProjectFilesInParallel. Delegates to the 6-param IBuildEngine3 overload.
///
public bool BuildProjectFilesInParallel(string[] projectFileNames, string[] targetNames, IDictionary[] globalProperties, IDictionary[] targetOutputsPerProject, string[] toolsVersion, bool useResultsCache, bool unloadProjectsOnCompletion)
{
- LogErrorFromResource("BuildEngineCallbacksInTaskHostUnsupported");
- return false;
+ bool includeTargetOutputs = targetOutputsPerProject is not null;
+
+ BuildEngineResult result = BuildProjectFilesInParallel(projectFileNames, targetNames, globalProperties, new List[projectFileNames.Length], toolsVersion, includeTargetOutputs);
+
+ if (includeTargetOutputs && result.TargetOutputsPerProject is not null)
+ {
+ for (int i = 0; i < targetOutputsPerProject.Length && i < result.TargetOutputsPerProject.Count; i++)
+ {
+ if (targetOutputsPerProject[i] is not null)
+ {
+ foreach (KeyValuePair output in result.TargetOutputsPerProject[i])
+ {
+ targetOutputsPerProject[i].Add(output.Key, output.Value);
+ }
+ }
+ }
+ }
+
+ return result.Result;
}
#endregion // IBuildEngine2 Implementation (Methods)
@@ -453,31 +523,82 @@ public bool BuildProjectFilesInParallel(string[] projectFileNames, string[] targ
#region IBuildEngine3 Implementation
///
- /// Stub implementation of IBuildEngine3.BuildProjectFilesInParallel. The task host does not support IBuildEngine
- /// callbacks for the purposes of building projects, so error.
+ /// Implementation of IBuildEngine3.BuildProjectFilesInParallel. This is the canonical form that
+ /// sends the request to the owning worker node and waits for the response.
///
public BuildEngineResult BuildProjectFilesInParallel(string[] projectFileNames, string[] targetNames, IDictionary[] globalProperties, IList[] removeGlobalProperties, string[] toolsVersion, bool returnTargetOutputs)
{
+#if CLR2COMPATIBILITY
LogErrorFromResource("BuildEngineCallbacksInTaskHostUnsupported");
return new BuildEngineResult(false, null);
+#else
+ if (!CallbacksSupported)
+ {
+ LogErrorFromResource("BuildEngineCallbacksInTaskHostUnsupported");
+ return new BuildEngineResult(false, null);
+ }
+
+ var request = new TaskHostBuildRequest(
+ projectFileNames,
+ targetNames,
+ TaskHostBuildRequest.ConvertGlobalProperties(globalProperties),
+ TaskHostBuildRequest.ConvertRemoveGlobalProperties(removeGlobalProperties),
+ toolsVersion,
+ returnTargetOutputs);
+
+ // Yield before the callback so the scheduler can reuse this TaskHost for nested tasks.
+ YieldForCallback();
+ try
+ {
+ var response = SendCallbackRequestAndWaitForResponse(request);
+ return response.ToBuildEngineResult();
+ }
+ finally
+ {
+ ReacquireAfterCallback();
+ }
+#endif
}
///
- /// Stub implementation of IBuildEngine3.Yield. The task host does not support yielding, so just go ahead and silently
- /// return, letting the task continue.
+ /// Implementation of IBuildEngine3.Yield. Forwards to the owning worker node
+ /// so the scheduler can assign other work to this node while the task waits.
///
public void Yield()
{
- return;
+#if !CLR2COMPATIBILITY
+ if (!CallbacksSupported)
+ {
+ return;
+ }
+
+ YieldForCallback();
+
+ // Fire-and-forget: send yield notification to parent, no response expected.
+ var request = new TaskHostYieldRequest(YieldOperation.Yield);
+ _nodeEndpoint.SendData(request);
+#endif
}
///
- /// Stub implementation of IBuildEngine3.Reacquire. The task host does not support yielding, so just go ahead and silently
- /// return, letting the task continue.
+ /// Implementation of IBuildEngine3.Reacquire. Sends a reacquire request to the owning worker node
+ /// and blocks until the scheduler allows the task to continue.
///
public void Reacquire()
{
- return;
+#if !CLR2COMPATIBILITY
+ if (!CallbacksSupported)
+ {
+ return;
+ }
+
+ // Send reacquire request and wait for response.
+ // The worker side calls engine3.Reacquire() which may block on the scheduler.
+ var request = new TaskHostYieldRequest(YieldOperation.Reacquire);
+ SendCallbackRequestAndWaitForResponse(request);
+
+ ReacquireAfterCallback();
+#endif
}
#endregion // IBuildEngine3 Implementation
@@ -555,7 +676,7 @@ public void LogTelemetry(string eventName, IDictionary propertie
/// An containing the global properties of the current project.
public IReadOnlyDictionary GetGlobalProperties()
{
- return new Dictionary(_currentConfiguration.GlobalProperties);
+ return new Dictionary(EffectiveConfiguration.GlobalProperties);
}
#endregion
@@ -625,8 +746,8 @@ public override bool IsTaskInputLoggingEnabled
{
get
{
- ErrorUtilities.VerifyThrow(_taskHost._currentConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
- return _taskHost._currentConfiguration.IsTaskInputLoggingEnabled;
+ ErrorUtilities.VerifyThrow(_taskHost.EffectiveConfiguration != null, "We should never have a null configuration during a BuildEngine callback!");
+ return _taskHost.EffectiveConfiguration.IsTaskInputLoggingEnabled;
}
}
@@ -816,6 +937,8 @@ private void HandlePacket(INodePacket packet)
// Callback response packets - route to pending request
case NodePacketType.TaskHostIsRunningMultipleNodesResponse:
case NodePacketType.TaskHostCoresResponse:
+ case NodePacketType.TaskHostBuildResponse:
+ case NodePacketType.TaskHostYieldResponse:
HandleCallbackResponse(packet);
break;
#endif
@@ -835,8 +958,16 @@ private void HandleCallbackResponse(INodePacket packet)
return;
}
- // Request ID not found is expected if the connection was lost and the task thread
- // already cleaned up via the finally block in SendCallbackRequestAndWaitForResponse.
+ // Try per-task context pending requests first, then fall back to global
+ foreach (var kvp in _taskContexts)
+ {
+ if (kvp.Value.PendingCallbackRequests.TryRemove(callbackPacket.RequestId, out TaskCompletionSource tcsFromContext))
+ {
+ tcsFromContext.TrySetResult(packet);
+ return;
+ }
+ }
+
if (_pendingCallbackRequests.TryRemove(callbackPacket.RequestId, out TaskCompletionSource tcs))
{
tcs.TrySetResult(packet);
@@ -872,14 +1003,24 @@ private void HandleCallbackResponse(INodePacket packet)
private TResponse SendCallbackRequestAndWaitForResponse(ITaskHostCallbackPacket request)
where TResponse : class, INodePacket
{
+ // Get context - use per-task pending requests if available
+ var context = GetCurrentTaskContext();
+ var pendingRequests = context?.PendingCallbackRequests ?? _pendingCallbackRequests;
+
+ // IMPORTANT: Request IDs must be globally unique across all task contexts
int requestId = Interlocked.Increment(ref _nextCallbackRequestId);
request.RequestId = requestId;
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _pendingCallbackRequests[requestId] = tcs;
+ pendingRequests[requestId] = tcs;
try
{
+ if (context is not null)
+ {
+ context.State = TaskExecutionState.BlockedOnCallback;
+ }
+
// Send the request packet to the owning worker node
_nodeEndpoint.SendData(request);
@@ -898,8 +1039,146 @@ private TResponse SendCallbackRequestAndWaitForResponse(ITaskHostCall
}
finally
{
- _pendingCallbackRequests.TryRemove(requestId, out _);
+ pendingRequests.TryRemove(requestId, out _);
+
+ if (context is not null)
+ {
+ context.State = TaskExecutionState.Executing;
+ }
+ }
+ }
+
+ ///
+ /// Marks this TaskHost as yielded so the scheduler can reuse it for nested tasks.
+ /// Saves the current operating environment before yielding.
+ ///
+ private void YieldForCallback()
+ {
+ // Save environment state before yielding
+ var context = GetCurrentTaskContext();
+ if (context is not null)
+ {
+ SaveOperatingEnvironment(context);
+ context.State = TaskExecutionState.Yielded;
}
+
+ // Transition from "active" to "yielded" state.
+ // Order matters: increment yielded BEFORE decrementing active so the sum
+ // (_activeTaskCount + _yieldedTaskCount) is never zero during the transition.
+ // This prevents CompleteTask from prematurely nulling _currentConfiguration.
+ Interlocked.Increment(ref _yieldedTaskCount);
+ Interlocked.Decrement(ref _activeTaskCount);
+ }
+
+ ///
+ /// Marks this TaskHost as active again after a callback or Reacquire completes.
+ /// Restores the previously saved operating environment.
+ ///
+ private void ReacquireAfterCallback()
+ {
+ // Transition from "yielded" back to "active" state.
+ // Order matters: increment active BEFORE decrementing yielded so the sum
+ // (_activeTaskCount + _yieldedTaskCount) is never zero during the transition.
+ Interlocked.Increment(ref _activeTaskCount);
+ Interlocked.Decrement(ref _yieldedTaskCount);
+
+ // Restore environment state after reacquiring
+ var context = GetCurrentTaskContext();
+ if (context is not null)
+ {
+ RestoreOperatingEnvironment(context);
+ }
+ }
+
+ ///
+ /// Gets the task execution context for the current thread.
+ ///
+ private TaskExecutionContext GetCurrentTaskContext()
+ {
+ return _currentTaskContext.Value;
+ }
+
+ ///
+ /// Gets the configuration for the currently executing task on this thread.
+ /// Uses the thread-local task context if available, falling back to the global configuration.
+ ///
+ private TaskHostConfiguration GetCurrentConfiguration()
+ {
+ var context = GetCurrentTaskContext();
+ return context?.Configuration ?? _currentConfiguration;
+ }
+
+ ///
+ /// Creates a new task execution context for the given configuration.
+ ///
+ private TaskExecutionContext CreateTaskContext(TaskHostConfiguration configuration)
+ {
+ int taskId = Interlocked.Increment(ref _nextLocalTaskId);
+
+ var context = new TaskExecutionContext(taskId, configuration);
+
+ if (!_taskContexts.TryAdd(taskId, context))
+ {
+ context.Dispose();
+ throw new InvalidOperationException(
+ $"Task ID {taskId} already exists in TaskHost.");
+ }
+
+ return context;
+ }
+
+ ///
+ /// Removes and disposes a task execution context.
+ ///
+ private void RemoveTaskContext(int taskId)
+ {
+ if (_taskContexts.TryRemove(taskId, out var context))
+ {
+ context.Dispose();
+ }
+ }
+
+ ///
+ /// Saves the current operating environment to the task context.
+ /// Called before yielding or blocking on a callback that allows other tasks to run.
+ ///
+ internal void SaveOperatingEnvironment(TaskExecutionContext context)
+ {
+ ErrorUtilities.VerifyThrowArgumentNull(context, nameof(context));
+
+ context.SavedCurrentDirectory = NativeMethodsShared.GetCurrentDirectory();
+ context.SavedEnvironment = new Dictionary(
+ CommunicationsUtilities.GetEnvironmentVariables(),
+ StringComparer.OrdinalIgnoreCase);
+
+ // Save warning settings that may be overwritten by a nested task
+ context.SavedWarningsAsErrors = WarningsAsErrors;
+ context.SavedWarningsNotAsErrors = WarningsNotAsErrors;
+ context.SavedWarningsAsMessages = WarningsAsMessages;
+ }
+
+ ///
+ /// Restores the previously saved operating environment from the task context.
+ ///
+ internal void RestoreOperatingEnvironment(TaskExecutionContext context)
+ {
+ ErrorUtilities.VerifyThrowArgumentNull(context, nameof(context));
+
+ if (context.SavedCurrentDirectory is null || context.SavedEnvironment is null)
+ {
+ return;
+ }
+
+ CommunicationsUtilities.SetEnvironment(context.SavedEnvironment);
+ NativeMethodsShared.SetCurrentDirectory(context.SavedCurrentDirectory);
+
+ // Restore warning settings
+ WarningsAsErrors = context.SavedWarningsAsErrors;
+ WarningsNotAsErrors = context.SavedWarningsNotAsErrors;
+ WarningsAsMessages = context.SavedWarningsAsMessages;
+
+ context.SavedCurrentDirectory = null;
+ context.SavedEnvironment = null;
}
#endif
@@ -909,12 +1188,32 @@ private TResponse SendCallbackRequestAndWaitForResponse(ITaskHostCall
///
private void HandleTaskHostConfiguration(TaskHostConfiguration taskHostConfiguration)
{
- ErrorUtilities.VerifyThrow(!_isTaskExecuting, "Why are we getting a TaskHostConfiguration packet while we're still executing a task?");
+#if !CLR2COMPATIBILITY
+ // Allow new configuration when no task is actively executing.
+ // A task that is yielded (blocked on BuildProjectFile callback) does NOT prevent
+ // a new task from starting - this enables nested build scenarios.
+ ErrorUtilities.VerifyThrow(_activeTaskCount == 0,
+ "Why are we getting a TaskHostConfiguration packet while a task is actively executing? activeTaskCount={0}",
+ _activeTaskCount);
+#else
+ ErrorUtilities.VerifyThrow(_activeTaskCount == 0, "Why are we getting a TaskHostConfiguration packet while we're still executing a task?");
+#endif
_currentConfiguration = taskHostConfiguration;
+#if !CLR2COMPATIBILITY
+ // Create task execution context for this task
+ var context = CreateTaskContext(taskHostConfiguration);
+ context.State = TaskExecutionState.Executing;
+#endif
+
// Kick off the task running thread.
_taskRunnerThread = new Thread(new ParameterizedThreadStart(RunTask));
_taskRunnerThread.Name = "Task runner for task " + taskHostConfiguration.TaskName;
+
+#if !CLR2COMPATIBILITY
+ context.ExecutingThread = _taskRunnerThread;
+#endif
+
_taskRunnerThread.Start(taskHostConfiguration);
}
@@ -923,9 +1222,20 @@ private void HandleTaskHostConfiguration(TaskHostConfiguration taskHostConfigura
///
private void CompleteTask()
{
- ErrorUtilities.VerifyThrow(!_isTaskExecuting, "The task should be done executing before CompleteTask.");
+ // With multiple concurrent tasks (nested BuildProjectFile), we cannot assert
+ // that no task is executing here. The task that completed set _taskCompletePacket
+ // and signaled _taskCompleteEvent, but another task may have reacquired from yield
+ // by the time this method runs.
if (_nodeEndpoint.LinkStatus == LinkStatus.Active)
{
+#if !CLR2COMPATIBILITY
+ // Drain all queued completion packets — multiple tasks may have completed
+ // before the main thread serviced the AutoResetEvent.
+ while (_taskCompleteQueue.TryDequeue(out TaskHostTaskComplete packet))
+ {
+ _nodeEndpoint.SendData(packet);
+ }
+#else
TaskHostTaskComplete taskCompletePacketToSend;
lock (_taskCompleteLock)
@@ -936,9 +1246,19 @@ private void CompleteTask()
}
_nodeEndpoint.SendData(taskCompletePacketToSend);
+#endif
}
+#if !CLR2COMPATIBILITY
+ // Only clear _currentConfiguration when no tasks remain (active or yielded).
+ // A yielded task still needs the config for logging via EffectiveConfiguration.
+ if (_activeTaskCount == 0 && _yieldedTaskCount == 0)
+ {
+ _currentConfiguration = null;
+ }
+#else
_currentConfiguration = null;
+#endif
// If the task has been canceled, the event will still be set.
// If so, now that we've completed the task, we want to shut down
@@ -968,7 +1288,7 @@ private void CancelTask()
{
// Don't bother aborting the task if it has passed the actual user task Execute()
// It means we're already in the process of shutting down - Wait for the taskCompleteEvent to be set instead.
- if (_isTaskExecuting)
+ if (_activeTaskCount > 0)
{
#if FEATURE_THREAD_ABORT
// The thread will be terminated crudely so our environment may be trashed but it's ok since we are
@@ -985,7 +1305,7 @@ private void CancelTask()
///
private void HandleNodeBuildComplete(NodeBuildComplete buildComplete)
{
- ErrorUtilities.VerifyThrow(!_isTaskExecuting, "We should never have a task in the process of executing when we receive NodeBuildComplete.");
+ ErrorUtilities.VerifyThrow(_activeTaskCount == 0, "We should never have a task in the process of executing when we receive NodeBuildComplete.");
// Sidecar TaskHost will persist after the build is done.
if (_nodeReuse)
@@ -1008,6 +1328,28 @@ private NodeEngineShutdownReason HandleShutdown()
// Wait for the RunTask task runner thread before shutting down so that we can cleanly dispose all WaitHandles.
_taskRunnerThread?.Join();
+#if !CLR2COMPATIBILITY
+ // Also join any other task threads that may be blocked (e.g., a yielded task waiting on TCS).
+ // Fail their pending callback requests first so they can unblock.
+ foreach (var kvp in _taskContexts)
+ {
+ Thread thread = kvp.Value.ExecutingThread;
+ if (thread is not null && thread != _taskRunnerThread && thread.IsAlive)
+ {
+ // Fail any pending callbacks so the thread can unblock
+ foreach (var reqKvp in kvp.Value.PendingCallbackRequests)
+ {
+ if (kvp.Value.PendingCallbackRequests.TryRemove(reqKvp.Key, out var tcs))
+ {
+ tcs.TrySetException(new InvalidOperationException("TaskHost shutting down."));
+ }
+ }
+
+ thread.Join(TimeSpan.FromSeconds(5));
+ }
+ }
+#endif
+
using StreamWriter debugWriter = _debugCommunications
? File.CreateText(string.Format(CultureInfo.CurrentCulture, Path.Combine(FileUtilities.TempFileDirectory, @"MSBuild_NodeShutdown_{0}.txt"), EnvironmentUtilities.CurrentProcessId))
: null;
@@ -1082,6 +1424,19 @@ private void OnLinkStatusChanged(INodeEndpoint endpoint, LinkStatus status)
"TaskHost lost connection to owning worker node during callback."));
}
}
+
+ // Also fail per-task context pending requests
+ foreach (var contextKvp in _taskContexts)
+ {
+ foreach (var reqKvp in contextKvp.Value.PendingCallbackRequests)
+ {
+ if (contextKvp.Value.PendingCallbackRequests.TryRemove(reqKvp.Key, out TaskCompletionSource ctxTcs))
+ {
+ ctxTcs.TrySetException(new InvalidOperationException(
+ "TaskHost lost connection to owning worker node during callback."));
+ }
+ }
+ }
#endif
_shutdownEvent.Set();
@@ -1100,9 +1455,28 @@ private void OnLinkStatusChanged(INodeEndpoint endpoint, LinkStatus status)
///
private void RunTask(object state)
{
- _isTaskExecuting = true;
+ Interlocked.Increment(ref _activeTaskCount);
OutOfProcTaskHostTaskResult taskResult = null;
TaskHostConfiguration taskConfiguration = state as TaskHostConfiguration;
+
+#if !CLR2COMPATIBILITY
+ // Set the current task context for this thread
+ TaskExecutionContext taskContext = null;
+ foreach (var kvp in _taskContexts)
+ {
+ if (kvp.Value.Configuration == taskConfiguration)
+ {
+ taskContext = kvp.Value;
+ break;
+ }
+ }
+
+ if (taskContext is not null)
+ {
+ _currentTaskContext.Value = taskContext;
+ }
+#endif
+
IDictionary taskParams = taskConfiguration.TaskParameters;
// We only really know the values of these variables for sure once we see what we received from the owning worker node
@@ -1114,6 +1488,7 @@ private void RunTask(object state)
WarningsAsErrors = taskConfiguration.WarningsAsErrors;
WarningsNotAsErrors = taskConfiguration.WarningsNotAsErrors;
WarningsAsMessages = taskConfiguration.WarningsAsMessages;
+ OutOfProcTaskAppDomainWrapper taskWrapper = null;
try
{
// Change to the startup directory
@@ -1141,9 +1516,10 @@ private void RunTask(object state)
#endif
// We will not create an appdomain now because of a bug
// As a fix, we will create the class directly without wrapping it in a domain
- _taskWrapper = new OutOfProcTaskAppDomainWrapper();
+ taskWrapper = new OutOfProcTaskAppDomainWrapper();
+ _taskWrapper = taskWrapper;
- taskResult = _taskWrapper.ExecuteTask(
+ taskResult = taskWrapper.ExecuteTask(
this as IBuildEngine,
taskName,
taskLocation,
@@ -1173,13 +1549,37 @@ private void RunTask(object state)
{
try
{
- _isTaskExecuting = false;
+#if !CLR2COMPATIBILITY
+ // Reconcile counters: if the task is still in "yielded" state (e.g., it called Yield()
+ // without a matching Reacquire() before exiting), fix up the counters.
+ // The yielded count should be decremented and active count should NOT be decremented again
+ // since YieldForCallback already did that.
+ if (taskContext is not null && taskContext.State == TaskExecutionState.Yielded)
+ {
+ Interlocked.Decrement(ref _yieldedTaskCount);
+ // Don't decrement _activeTaskCount — it was already decremented by YieldForCallback
+ }
+ else
+ {
+ Interlocked.Decrement(ref _activeTaskCount);
+ }
+#else
+ Interlocked.Decrement(ref _activeTaskCount);
+#endif
IDictionary currentEnvironment = CommunicationsUtilities.GetEnvironmentVariables();
currentEnvironment = UpdateEnvironmentForMainNode(currentEnvironment);
taskResult ??= new OutOfProcTaskHostTaskResult(TaskCompleteType.Failure);
+#if !CLR2COMPATIBILITY
+ _taskCompleteQueue.Enqueue(new TaskHostTaskComplete(
+ taskResult,
+#if FEATURE_REPORTFILEACCESSES
+ _fileAccessData,
+#endif
+ currentEnvironment));
+#else
lock (_taskCompleteLock)
{
_taskCompletePacket = new TaskHostTaskComplete(
@@ -1189,6 +1589,7 @@ private void RunTask(object state)
#endif
currentEnvironment);
}
+#endif
#if FEATURE_APPDOMAIN
foreach (TaskParameter param in taskParams.Values)
@@ -1203,6 +1604,15 @@ private void RunTask(object state)
}
catch (Exception e)
{
+#if !CLR2COMPATIBILITY
+ // Create a minimal taskCompletePacket to carry the exception so that the TaskHostTask does not hang while waiting
+ _taskCompleteQueue.Enqueue(new TaskHostTaskComplete(
+ new OutOfProcTaskHostTaskResult(TaskCompleteType.CrashedAfterExecution, e),
+#if FEATURE_REPORTFILEACCESSES
+ _fileAccessData,
+#endif
+ null));
+#else
lock (_taskCompleteLock)
{
// Create a minimal taskCompletePacket to carry the exception so that the TaskHostTask does not hang while waiting
@@ -1213,6 +1623,7 @@ private void RunTask(object state)
#endif
null);
}
+#endif
}
finally
{
@@ -1221,7 +1632,19 @@ private void RunTask(object state)
#endif
// Call CleanupTask to unload any domains and other necessary cleanup in the taskWrapper
- _taskWrapper.CleanupTask();
+ // Use local variable — _taskWrapper may have been overwritten by a nested task.
+ taskWrapper?.CleanupTask();
+
+#if !CLR2COMPATIBILITY
+ // Mark context as completed and clean up
+ if (taskContext is not null)
+ {
+ taskContext.State = TaskExecutionState.Completed;
+ taskContext.CompletedEvent.Set();
+ _currentTaskContext.Value = null;
+ RemoveTaskContext(taskContext.TaskId);
+ }
+#endif
// The task has now fully completed executing
_taskCompleteEvent.Set();
@@ -1417,7 +1840,7 @@ private void SendBuildEvent(BuildEventArgs e)
return;
}
- LogMessagePacketBase logMessage = new(new KeyValuePair(_currentConfiguration.NodeId, e));
+ LogMessagePacketBase logMessage = new(new KeyValuePair(EffectiveConfiguration.NodeId, e));
_nodeEndpoint.SendData(logMessage);
}
}
@@ -1427,13 +1850,13 @@ private void SendBuildEvent(BuildEventArgs e)
///
private void LogMessageFromResource(MessageImportance importance, string messageResource, params object[] messageArgs)
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration when we're trying to log messages!");
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration when we're trying to log messages!");
// Using the CLR 2 build event because this class is shared between MSBuildTaskHost.exe (CLR2) and MSBuild.exe (CLR4+)
BuildMessageEventArgs message = new BuildMessageEventArgs(
ResourceUtilities.FormatString(AssemblyResources.GetString(messageResource), messageArgs),
null,
- _currentConfiguration.TaskName,
+ EffectiveConfiguration.TaskName,
importance);
LogMessageEvent(message);
@@ -1444,7 +1867,7 @@ private void LogMessageFromResource(MessageImportance importance, string message
///
private void LogWarningFromResource(string messageResource, params object[] messageArgs)
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration when we're trying to log warnings!");
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration when we're trying to log warnings!");
// Using the CLR 2 build event because this class is shared between MSBuildTaskHost.exe (CLR2) and MSBuild.exe (CLR4+)
BuildWarningEventArgs warning = new BuildWarningEventArgs(
@@ -1457,7 +1880,7 @@ private void LogWarningFromResource(string messageResource, params object[] mess
0,
ResourceUtilities.FormatString(AssemblyResources.GetString(messageResource), messageArgs),
null,
- _currentConfiguration.TaskName);
+ EffectiveConfiguration.TaskName);
LogWarningEvent(warning);
}
@@ -1467,7 +1890,7 @@ private void LogWarningFromResource(string messageResource, params object[] mess
///
private void LogErrorFromResource(string messageResource)
{
- ErrorUtilities.VerifyThrow(_currentConfiguration != null, "We should never have a null configuration when we're trying to log errors!");
+ ErrorUtilities.VerifyThrow(EffectiveConfiguration != null, "We should never have a null configuration when we're trying to log errors!");
// Using the CLR 2 build event because this class is shared between MSBuildTaskHost.exe (CLR2) and MSBuild.exe (CLR4+)
BuildErrorEventArgs error = new BuildErrorEventArgs(
@@ -1480,7 +1903,7 @@ private void LogErrorFromResource(string messageResource)
0,
AssemblyResources.GetString(messageResource),
null,
- _currentConfiguration.TaskName);
+ EffectiveConfiguration.TaskName);
LogErrorEvent(error);
}
diff --git a/src/MSBuild/TaskExecutionContext.cs b/src/MSBuild/TaskExecutionContext.cs
new file mode 100644
index 00000000000..abcb13b333d
--- /dev/null
+++ b/src/MSBuild/TaskExecutionContext.cs
@@ -0,0 +1,132 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#if !CLR2COMPATIBILITY
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Build.BackEnd;
+
+#nullable disable
+
+namespace Microsoft.Build.CommandLine
+{
+ ///
+ /// Represents the execution context for a single task running in the TaskHost.
+ /// Multiple contexts may exist concurrently when tasks yield or await callbacks
+ /// like BuildProjectFile.
+ ///
+ ///
+ /// When Task A calls BuildProjectFile and blocks, Task B may be dispatched
+ /// to the same TaskHost process. Each task needs its own:
+ /// - Configuration and parameters
+ /// - Pending callback requests (for request/response correlation)
+ /// - Saved environment (for context switching on yield)
+ /// - Completion signaling
+ ///
+ internal sealed class TaskExecutionContext : IDisposable
+ {
+ ///
+ /// Unique identifier for this task execution.
+ ///
+ public int TaskId { get; }
+
+ ///
+ /// The configuration packet that initiated this task execution.
+ ///
+ public TaskHostConfiguration Configuration { get; }
+
+ ///
+ /// The thread executing this task, or null if not yet started.
+ ///
+ public Thread ExecutingThread { get; set; }
+
+ ///
+ /// Current execution state of this task.
+ ///
+ public TaskExecutionState State { get; set; }
+
+ ///
+ /// Saved current directory when task yields or awaits a blocking callback.
+ ///
+ public string SavedCurrentDirectory { get; set; }
+
+ ///
+ /// Saved environment variables when task yields or awaits a blocking callback.
+ ///
+ public IDictionary SavedEnvironment { get; set; }
+
+ ///
+ /// Saved per-task warning/debug settings, captured from OutOfProcTaskHostNode shared fields
+ /// before yielding so they can be restored when the task resumes.
+ ///
+ public ICollection SavedWarningsAsErrors { get; set; }
+
+ /// Saved WarningsNotAsErrors.
+ public ICollection SavedWarningsNotAsErrors { get; set; }
+
+ /// Saved WarningsAsMessages.
+ public ICollection SavedWarningsAsMessages { get; set; }
+
+ ///
+ /// Pending callback requests for THIS task, keyed by request ID.
+ /// Each task has isolated pending requests to prevent cross-contamination
+ /// when multiple tasks are blocked on callbacks simultaneously.
+ ///
+ public ConcurrentDictionary> PendingCallbackRequests { get; }
+ = new ConcurrentDictionary>();
+
+ ///
+ /// Event signaled when this task completes execution.
+ ///
+ public ManualResetEvent CompletedEvent { get; } = new ManualResetEvent(false);
+
+ ///
+ /// Event signaled when this specific task is cancelled.
+ ///
+ public ManualResetEvent CancelledEvent { get; } = new ManualResetEvent(false);
+
+ ///
+ /// Creates a new task execution context.
+ ///
+ public TaskExecutionContext(int taskId, TaskHostConfiguration configuration)
+ {
+ TaskId = taskId;
+ Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ State = TaskExecutionState.Pending;
+ }
+
+ ///
+ /// Disposes resources held by this context.
+ ///
+ public void Dispose()
+ {
+ CompletedEvent?.Dispose();
+ CancelledEvent?.Dispose();
+ }
+ }
+
+ ///
+ /// Execution states for a task running in the TaskHost.
+ ///
+ internal enum TaskExecutionState
+ {
+ /// Task context created but not yet started.
+ Pending,
+ /// Task is actively executing on its thread.
+ Executing,
+ /// Task has called Yield and is waiting for Reacquire.
+ Yielded,
+ /// Task is blocked waiting for a callback response.
+ BlockedOnCallback,
+ /// Task has finished execution.
+ Completed,
+ /// Task was cancelled.
+ Cancelled,
+ }
+}
+
+#endif
diff --git a/src/Shared/TaskHostBuildRequest.cs b/src/Shared/TaskHostBuildRequest.cs
new file mode 100644
index 00000000000..dd3a24e26a0
--- /dev/null
+++ b/src/Shared/TaskHostBuildRequest.cs
@@ -0,0 +1,217 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Microsoft.Build.BackEnd
+{
+ ///
+ /// Packet sent from TaskHost to owning worker node to execute a BuildProjectFile* callback.
+ /// All four BuildProjectFile/BuildProjectFilesInParallel overloads normalize to the
+ /// IBuildEngine3 6-param canonical form carried by this packet.
+ ///
+ internal class TaskHostBuildRequest : INodePacket, ITaskHostCallbackPacket
+ {
+ private int _requestId;
+ private string[]? _projectFileNames;
+ private string[]? _targetNames;
+ private Dictionary?[]? _globalProperties;
+ private List?[]? _removeGlobalProperties;
+ private string[]? _toolsVersions;
+ private bool _returnTargetOutputs;
+
+ public TaskHostBuildRequest()
+ {
+ }
+
+ public TaskHostBuildRequest(
+ string[]? projectFileNames,
+ string[]? targetNames,
+ Dictionary?[]? globalProperties,
+ List?[]? removeGlobalProperties,
+ string[]? toolsVersions,
+ bool returnTargetOutputs)
+ {
+ _projectFileNames = projectFileNames;
+ _targetNames = targetNames;
+ _globalProperties = globalProperties;
+ _removeGlobalProperties = removeGlobalProperties;
+ _toolsVersions = toolsVersions;
+ _returnTargetOutputs = returnTargetOutputs;
+ }
+
+ public NodePacketType Type => NodePacketType.TaskHostBuildRequest;
+
+ public int RequestId
+ {
+ get => _requestId;
+ set => _requestId = value;
+ }
+
+ public string[]? ProjectFileNames => _projectFileNames;
+
+ public string[]? TargetNames => _targetNames;
+
+ public Dictionary?[]? GlobalProperties => _globalProperties;
+
+ public List?[]? RemoveGlobalProperties => _removeGlobalProperties;
+
+ public string[]? ToolsVersions => _toolsVersions;
+
+ public bool ReturnTargetOutputs => _returnTargetOutputs;
+
+ ///
+ /// Converts non-generic IDictionary[] (as used by IBuildEngine interfaces) to
+ /// Dictionary<string, string>[] for serialization.
+ ///
+ internal static Dictionary?[]? ConvertGlobalProperties(IDictionary[]? globalProperties)
+ {
+ if (globalProperties is null)
+ {
+ return null;
+ }
+
+ var result = new Dictionary?[globalProperties.Length];
+ for (int i = 0; i < globalProperties.Length; i++)
+ {
+ if (globalProperties[i] is not null)
+ {
+ result[i] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (DictionaryEntry entry in globalProperties[i])
+ {
+ result[i]![(string)entry.Key] = (string)entry.Value!;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts IList<string>[] to List<string>[] for serialization.
+ ///
+ internal static List?[]? ConvertRemoveGlobalProperties(IList[]? removeGlobalProperties)
+ {
+ if (removeGlobalProperties is null)
+ {
+ return null;
+ }
+
+ var result = new List?[removeGlobalProperties.Length];
+ for (int i = 0; i < removeGlobalProperties.Length; i++)
+ {
+ if (removeGlobalProperties[i] is not null)
+ {
+ result[i] = new List(removeGlobalProperties[i]);
+ }
+ }
+
+ return result;
+ }
+
+ public void Translate(ITranslator translator)
+ {
+ translator.Translate(ref _requestId);
+ TranslateNullableStringArray(translator, ref _projectFileNames);
+ TranslateNullableStringArray(translator, ref _targetNames);
+ translator.Translate(ref _returnTargetOutputs);
+ TranslateNullableStringArray(translator, ref _toolsVersions);
+ TranslateGlobalPropertiesArray(translator);
+ TranslateRemoveGlobalPropertiesArray(translator);
+ }
+
+ ///
+ /// Serializes a string array where individual elements may be null.
+ /// The standard translator.Translate(ref string[]) doesn't handle null elements.
+ ///
+ private static void TranslateNullableStringArray(ITranslator translator, ref string[]? array)
+ {
+ bool hasArray = array is not null;
+ translator.Translate(ref hasArray);
+
+ if (!hasArray)
+ {
+ array = null;
+ return;
+ }
+
+ int length = array?.Length ?? 0;
+ translator.Translate(ref length);
+
+ if (translator.Mode == TranslationDirection.ReadFromStream)
+ {
+ array = new string[length];
+ }
+
+ for (int i = 0; i < length; i++)
+ {
+ string? element = array![i];
+ translator.Translate(ref element);
+ array[i] = element!;
+ }
+ }
+
+ private void TranslateGlobalPropertiesArray(ITranslator translator)
+ {
+ bool hasArray = _globalProperties is not null;
+ translator.Translate(ref hasArray);
+
+ if (!hasArray)
+ {
+ _globalProperties = null;
+ return;
+ }
+
+ int length = _globalProperties?.Length ?? 0;
+ translator.Translate(ref length);
+
+ if (translator.Mode == TranslationDirection.ReadFromStream)
+ {
+ _globalProperties = new Dictionary?[length];
+ }
+
+ for (int i = 0; i < length; i++)
+ {
+ Dictionary? dict = _globalProperties![i];
+ translator.TranslateDictionary(ref dict, StringComparer.OrdinalIgnoreCase);
+ _globalProperties[i] = dict;
+ }
+ }
+
+ private void TranslateRemoveGlobalPropertiesArray(ITranslator translator)
+ {
+ bool hasArray = _removeGlobalProperties is not null;
+ translator.Translate(ref hasArray);
+
+ if (!hasArray)
+ {
+ _removeGlobalProperties = null;
+ return;
+ }
+
+ int length = _removeGlobalProperties?.Length ?? 0;
+ translator.Translate(ref length);
+
+ if (translator.Mode == TranslationDirection.ReadFromStream)
+ {
+ _removeGlobalProperties = new List?[length];
+ }
+
+ for (int i = 0; i < length; i++)
+ {
+ List? list = _removeGlobalProperties![i];
+ translator.Translate(ref list);
+ _removeGlobalProperties[i] = list;
+ }
+ }
+
+ internal static INodePacket FactoryForDeserialization(ITranslator translator)
+ {
+ var packet = new TaskHostBuildRequest();
+ packet.Translate(translator);
+ return packet;
+ }
+ }
+}
diff --git a/src/Shared/TaskHostBuildResponse.cs b/src/Shared/TaskHostBuildResponse.cs
new file mode 100644
index 00000000000..b520bcfebc2
--- /dev/null
+++ b/src/Shared/TaskHostBuildResponse.cs
@@ -0,0 +1,158 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Build.Framework;
+
+namespace Microsoft.Build.BackEnd
+{
+ ///
+ /// Response packet from owning worker node to TaskHost with BuildProjectFile* results.
+ /// Carries the build success/failure and target outputs per project.
+ ///
+ internal class TaskHostBuildResponse : INodePacket, ITaskHostCallbackPacket
+ {
+ private int _requestId;
+ private bool _success;
+
+ ///
+ /// Target outputs per project. Each entry is a dictionary mapping target names to TaskParameter
+ /// wrapping ITaskItem[] outputs. Uses the same TaskParameter serialization as TaskHostTaskComplete.
+ ///
+ private List>? _targetOutputsPerProject;
+
+ public TaskHostBuildResponse()
+ {
+ }
+
+ public TaskHostBuildResponse(int requestId, bool success, List>? targetOutputsPerProject)
+ {
+ _requestId = requestId;
+ _success = success;
+ _targetOutputsPerProject = targetOutputsPerProject;
+ }
+
+ public NodePacketType Type => NodePacketType.TaskHostBuildResponse;
+
+ public int RequestId
+ {
+ get => _requestId;
+ set => _requestId = value;
+ }
+
+ public bool Success => _success;
+
+ public List>? TargetOutputsPerProject => _targetOutputsPerProject;
+
+ ///
+ /// Reconstructs a from this response packet.
+ /// Converts values back to [] arrays.
+ ///
+ public BuildEngineResult ToBuildEngineResult()
+ {
+ List>? result = null;
+
+ if (_targetOutputsPerProject is not null)
+ {
+ result = new List>(_targetOutputsPerProject.Count);
+
+ foreach (Dictionary projectOutputs in _targetOutputsPerProject)
+ {
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (projectOutputs is not null)
+ {
+ foreach (KeyValuePair entry in projectOutputs)
+ {
+ dict[entry.Key] = (ITaskItem[]?)entry.Value?.WrappedParameter ?? [];
+ }
+ }
+
+ result.Add(dict);
+ }
+ }
+
+ return new BuildEngineResult(_success, result!);
+ }
+
+ ///
+ /// Creates a response from a .
+ /// Wraps [] arrays in for serialization.
+ ///
+ internal static TaskHostBuildResponse FromBuildEngineResult(int requestId, BuildEngineResult engineResult)
+ {
+ List>? outputs = null;
+
+ if (engineResult.TargetOutputsPerProject is not null && engineResult.TargetOutputsPerProject.Count > 0)
+ {
+ outputs = new List>(engineResult.TargetOutputsPerProject.Count);
+
+ foreach (IDictionary projectOutputs in engineResult.TargetOutputsPerProject)
+ {
+ var dict = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (projectOutputs is not null)
+ {
+ foreach (KeyValuePair entry in projectOutputs)
+ {
+ dict[entry.Key] = new TaskParameter(entry.Value);
+ }
+ }
+
+ outputs.Add(dict);
+ }
+ }
+
+ return new TaskHostBuildResponse(requestId, engineResult.Result, outputs);
+ }
+
+ public void Translate(ITranslator translator)
+ {
+ translator.Translate(ref _requestId);
+ translator.Translate(ref _success);
+ TranslateTargetOutputs(translator);
+ }
+
+ private void TranslateTargetOutputs(ITranslator translator)
+ {
+ bool hasOutputs = _targetOutputsPerProject is not null;
+ translator.Translate(ref hasOutputs);
+
+ if (!hasOutputs)
+ {
+ _targetOutputsPerProject = null;
+ return;
+ }
+
+ int count = _targetOutputsPerProject?.Count ?? 0;
+ translator.Translate(ref count);
+
+ if (translator.Mode == TranslationDirection.ReadFromStream)
+ {
+ _targetOutputsPerProject = new List>(count);
+ for (int i = 0; i < count; i++)
+ {
+ Dictionary? dict = null;
+ translator.TranslateDictionary(ref dict, StringComparer.OrdinalIgnoreCase, TaskParameter.FactoryForDeserialization);
+ _targetOutputsPerProject.Add(dict!);
+ }
+ }
+ else
+ {
+ for (int i = 0; i < count; i++)
+ {
+ Dictionary? dict = _targetOutputsPerProject![i];
+ translator.TranslateDictionary(ref dict, StringComparer.OrdinalIgnoreCase, TaskParameter.FactoryForDeserialization);
+ }
+ }
+ }
+
+ internal static INodePacket FactoryForDeserialization(ITranslator translator)
+ {
+ var packet = new TaskHostBuildResponse();
+ packet.Translate(translator);
+ return packet;
+ }
+ }
+}
diff --git a/src/Shared/TaskHostYieldRequest.cs b/src/Shared/TaskHostYieldRequest.cs
new file mode 100644
index 00000000000..0b4b01b4469
--- /dev/null
+++ b/src/Shared/TaskHostYieldRequest.cs
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Build.BackEnd
+{
+ ///
+ /// Specifies which yield operation is being requested.
+ ///
+ internal enum YieldOperation : byte
+ {
+ ///
+ /// The task is yielding the node, allowing other work to be scheduled.
+ /// Fire-and-forget: no response is sent.
+ ///
+ Yield = 0,
+
+ ///
+ /// The task is reacquiring the node after a yield.
+ /// Blocking: the TaskHost waits for a before continuing.
+ ///
+ Reacquire = 1,
+ }
+
+ ///
+ /// Packet sent from TaskHost to owning worker node for Yield/Reacquire operations.
+ ///
+ /// Yield is fire-and-forget (no response expected).
+ /// Reacquire blocks until a is received.
+ ///
+ ///
+ internal class TaskHostYieldRequest : INodePacket, ITaskHostCallbackPacket
+ {
+ private int _requestId;
+ private YieldOperation _operation;
+
+ public TaskHostYieldRequest()
+ {
+ }
+
+ public TaskHostYieldRequest(YieldOperation operation)
+ {
+ _operation = operation;
+ }
+
+ public NodePacketType Type => NodePacketType.TaskHostYieldRequest;
+
+ public int RequestId
+ {
+ get => _requestId;
+ set => _requestId = value;
+ }
+
+ public YieldOperation Operation => _operation;
+
+ public void Translate(ITranslator translator)
+ {
+ translator.Translate(ref _requestId);
+
+ byte op = (byte)_operation;
+ translator.Translate(ref op);
+ _operation = (YieldOperation)op;
+ }
+
+ internal static INodePacket FactoryForDeserialization(ITranslator translator)
+ {
+ var packet = new TaskHostYieldRequest();
+ packet.Translate(translator);
+ return packet;
+ }
+ }
+}
diff --git a/src/Shared/TaskHostYieldResponse.cs b/src/Shared/TaskHostYieldResponse.cs
new file mode 100644
index 00000000000..d4e6a4871fe
--- /dev/null
+++ b/src/Shared/TaskHostYieldResponse.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Build.BackEnd
+{
+ ///
+ /// Response from worker node to TaskHost acknowledging a Reacquire request.
+ /// Sent only for ; Yield is fire-and-forget.
+ ///
+ internal class TaskHostYieldResponse : INodePacket, ITaskHostCallbackPacket
+ {
+ private int _requestId;
+
+ public TaskHostYieldResponse()
+ {
+ }
+
+ public TaskHostYieldResponse(int requestId)
+ {
+ _requestId = requestId;
+ }
+
+ public NodePacketType Type => NodePacketType.TaskHostYieldResponse;
+
+ public int RequestId
+ {
+ get => _requestId;
+ set => _requestId = value;
+ }
+
+ public void Translate(ITranslator translator)
+ {
+ translator.Translate(ref _requestId);
+ }
+
+ internal static INodePacket FactoryForDeserialization(ITranslator translator)
+ {
+ var packet = new TaskHostYieldResponse();
+ packet.Translate(translator);
+ return packet;
+ }
+ }
+}