From e49fbd6969087a3424ec1f6786f9e37308508c43 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Sun, 22 Feb 2026 15:21:42 +0100 Subject: [PATCH 01/13] Add multithreading tests for 5 tasks (Group 7) Tasks: SelectRuntimeIdentifierSpecificItems, SetGeneratedAppConfigMetadata, ValidateExecutableReferences, FilterResolvedFiles, RemoveDuplicatePackageReferences. FilterResolvedFiles includes absolutization of AssetsFilePath. Others are attribute-only with test additions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...GivenAFilterResolvedFilesMultiThreading.cs | 59 +++ .../GivenAttributeOnlyTasksGroup7.cs | 385 ++++++++++++++++++ .../FilterResolvedFiles.cs | 7 +- .../SelectRuntimeIdentifierSpecificItems.cs | 1 + .../SetGeneratedAppConfigMetadata.cs | 1 + .../ValidateExecutableReferences.cs | 1 + 6 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs create mode 100644 src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs new file mode 100644 index 000000000000..8f9adaf17794 --- /dev/null +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.Build.Framework; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + public class GivenAFilterResolvedFilesMultiThreading + { + [Fact] + public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-mt-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + try + { + // Create a minimal lock file at a relative path + var assetsDir = Path.Combine(projectDir, "obj"); + Directory.CreateDirectory(assetsDir); + File.WriteAllText(Path.Combine(assetsDir, "project.assets.json"), + """ + { + "version": 3, + "targets": { ".NETCoreApp,Version=v8.0": {} }, + "libraries": {}, + "projectFileDependencyGroups": { ".NETCoreApp,Version=v8.0": [] }, + "project": { + "version": "1.0.0", + "frameworks": { "net8.0": {} } + } + } + """); + + var task = new FilterResolvedFiles + { + BuildEngine = new MockBuildEngine(), + AssetsFilePath = "obj\\project.assets.json", + ResolvedFiles = Array.Empty(), + PackagesToPrune = Array.Empty(), + TargetFramework = ".NETCoreApp,Version=v8.0", + }; + + // Set TaskEnvironment via reflection + var teProp = task.GetType().GetProperty("TaskEnvironment"); + teProp.Should().NotBeNull("task must have a TaskEnvironment property after migration"); + teProp!.SetValue(task, TaskEnvironmentHelper.CreateForTest(projectDir)); + + var result = task.Execute(); + result.Should().BeTrue("task should succeed when assets file is found via TaskEnvironment"); + } + finally + { + Directory.Delete(projectDir, true); + } + } + } +} diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs new file mode 100644 index 000000000000..3ee7ae5e202b --- /dev/null +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -0,0 +1,385 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Xunit; + +namespace Microsoft.NET.Build.Tasks.UnitTests +{ + /// + /// Behavioral tests for attribute-only tasks in merge-group-7. + /// These tasks received only the [MSBuildMultiThreadableTask] attribute — no source + /// code changes — so we verify they still produce correct results. + /// + public class GivenAttributeOnlyTasksGroup7 + { + #region SelectRuntimeIdentifierSpecificItems (parity test) + + [Fact] + public void SelectRuntimeIdentifierSpecificItems_ProducesSameResultsRegardlessOfCwd() + { + // This parity test verifies the task behaves identically + // when CWD is projectDir vs a different directory. + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"select-rid-parity-{Guid.NewGuid():N}")); + var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"select-rid-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + var runtimeGraphJson = @"{ + ""runtimes"": { + ""linux"": {}, + ""linux-x64"": { ""#import"": [""linux""] }, + ""win"": {}, + ""win-x64"": { ""#import"": [""win""] } + } + }"; + var graphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(graphPath, runtimeGraphJson); + + var items = new[] + { + CreateItemWithRid("Item1", "linux-x64"), + CreateItemWithRid("Item2", "win-x64"), + CreateItemWithRid("Item3", "linux") + }; + + // --- Run with CWD = projectDir --- + Directory.SetCurrentDirectory(projectDir); + var (cwdResult, cwdSelected) = RunSelectRidTask("linux-x64", items, graphPath); + + // --- Run with CWD = otherDir --- + Directory.SetCurrentDirectory(otherDir); + var (otherResult, otherSelected) = RunSelectRidTask("linux-x64", items, graphPath); + + cwdResult.Should().Be(otherResult, "task should return same result regardless of CWD"); + cwdSelected.Length.Should().Be(otherSelected.Length, "same number of items should be selected"); + + // Both should select linux-x64 and linux, but NOT win-x64 + cwdSelected.Should().HaveCount(2); + cwdSelected.Should().Contain(i => i.ItemSpec == "Item1"); // linux-x64 + cwdSelected.Should().Contain(i => i.ItemSpec == "Item3"); // linux + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + + private static (bool result, ITaskItem[] selected) RunSelectRidTask( + string targetRid, ITaskItem[] items, string graphPath) + { + var task = new SelectRuntimeIdentifierSpecificItems + { + BuildEngine = new MockBuildEngine(), + TargetRuntimeIdentifier = targetRid, + Items = items, + RuntimeIdentifierGraphPath = graphPath + }; + var result = task.Execute(); + return (result, task.SelectedItems ?? Array.Empty()); + } + + private static TaskItem CreateItemWithRid(string itemSpec, string rid) + { + var item = new TaskItem(itemSpec); + item.SetMetadata("RuntimeIdentifier", rid); + return item; + } + + #endregion + + #region SetGeneratedAppConfigMetadata + + [Fact] + public void SetGeneratedAppConfigMetadata_WithNoSourceAppConfig_SetsTargetPath() + { + var task = new SetGeneratedAppConfigMetadata + { + BuildEngine = new MockBuildEngine(), + GeneratedAppConfigFile = "obj/myapp.exe.config", + TargetName = "myapp.exe.config" + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.OutputAppConfigFileWithMetadata.Should().NotBeNull(); + task.OutputAppConfigFileWithMetadata.ItemSpec.Should().Be("obj/myapp.exe.config"); + task.OutputAppConfigFileWithMetadata.GetMetadata("TargetPath").Should().Be("myapp.exe.config"); + } + + [Fact] + public void SetGeneratedAppConfigMetadata_WithSourceAppConfig_CopiesMetadata() + { + var sourceConfig = new MockTaskItem("app.config", new Dictionary + { + { "Link", "linked/app.config" }, + { "TargetPath", "myapp.exe.config" } + }); + + var task = new SetGeneratedAppConfigMetadata + { + BuildEngine = new MockBuildEngine(), + AppConfigFile = sourceConfig, + GeneratedAppConfigFile = "obj/myapp.exe.config", + TargetName = "myapp.exe.config" + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.OutputAppConfigFileWithMetadata.Should().NotBeNull(); + task.OutputAppConfigFileWithMetadata.ItemSpec.Should().Be("obj/myapp.exe.config"); + // Source metadata should be copied to the output + task.OutputAppConfigFileWithMetadata.GetMetadata("TargetPath").Should().Be("myapp.exe.config"); + } + + [Fact] + public void SetGeneratedAppConfigMetadata_OutputItemSpecIsGeneratedPath() + { + var task = new SetGeneratedAppConfigMetadata + { + BuildEngine = new MockBuildEngine(), + GeneratedAppConfigFile = "generated/config.xml", + TargetName = "output.exe.config" + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.OutputAppConfigFileWithMetadata.ItemSpec.Should().Be("generated/config.xml", + "the output item should use the generated file path as its ItemSpec"); + } + + #endregion + + #region ValidateExecutableReferences + + [Fact] + public void ValidateExecutableReferences_NonExecutableProject_SkipsValidation() + { + var task = new ValidateExecutableReferences + { + BuildEngine = new MockBuildEngine(), + IsExecutable = false, + SelfContained = true, + ReferencedProjects = new ITaskItem[] + { + new TaskItem("SomeProject.csproj") + } + }; + + var result = task.Execute(); + + result.Should().BeTrue("non-executable projects should skip validation entirely"); + } + + [Fact] + public void ValidateExecutableReferences_NoReferencedProjects_Succeeds() + { + var task = new ValidateExecutableReferences + { + BuildEngine = new MockBuildEngine(), + IsExecutable = true, + SelfContained = false, + ReferencedProjects = Array.Empty() + }; + + var result = task.Execute(); + + result.Should().BeTrue("no references means nothing to validate"); + } + + [Fact] + public void ValidateExecutableReferences_ProjectWithoutNearestTfm_SkipsProject() + { + // Projects without NearestTargetFramework metadata (e.g., C++ projects) + // should be silently skipped + var project = new MockTaskItem("NativeProject.vcxproj", new Dictionary()); + + var task = new ValidateExecutableReferences + { + BuildEngine = new MockBuildEngine(), + IsExecutable = true, + SelfContained = false, + ReferencedProjects = new ITaskItem[] { project } + }; + + var result = task.Execute(); + + result.Should().BeTrue("projects without NearestTargetFramework should be skipped"); + } + + #endregion + + #region RemoveDuplicatePackageReferences + + [Fact] + public void RemoveDuplicatePackageReferences_RemovesDuplicates() + { + var packages = new ITaskItem[] + { + new MockTaskItem("MyPackage", new Dictionary { { "Version", "1.0.0" } }), + new MockTaskItem("MyPackage", new Dictionary { { "Version", "1.0.0" } }), + new MockTaskItem("OtherPackage", new Dictionary { { "Version", "2.0.0" } }) + }; + + var task = new RemoveDuplicatePackageReferences + { + BuildEngine = new MockBuildEngine(), + InputPackageReferences = packages + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.UniquePackageReferences.Should().HaveCount(2); + task.UniquePackageReferences.Should().Contain(i => i.ItemSpec == "MyPackage"); + task.UniquePackageReferences.Should().Contain(i => i.ItemSpec == "OtherPackage"); + } + + [Fact] + public void RemoveDuplicatePackageReferences_PreservesVersionMetadata() + { + var packages = new ITaskItem[] + { + new MockTaskItem("MyPackage", new Dictionary { { "Version", "3.5.1" } }) + }; + + var task = new RemoveDuplicatePackageReferences + { + BuildEngine = new MockBuildEngine(), + InputPackageReferences = packages + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.UniquePackageReferences.Should().HaveCount(1); + task.UniquePackageReferences[0].GetMetadata("Version").Should().Be("3.5.1"); + } + + [Fact] + public void RemoveDuplicatePackageReferences_DifferentVersionsAreNotDuplicates() + { + var packages = new ITaskItem[] + { + new MockTaskItem("MyPackage", new Dictionary { { "Version", "1.0.0" } }), + new MockTaskItem("MyPackage", new Dictionary { { "Version", "2.0.0" } }) + }; + + var task = new RemoveDuplicatePackageReferences + { + BuildEngine = new MockBuildEngine(), + InputPackageReferences = packages + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.UniquePackageReferences.Should().HaveCount(2, + "same package with different versions are distinct identities"); + } + + [Fact] + public void RemoveDuplicatePackageReferences_SinglePackage_PassesThrough() + { + var packages = new ITaskItem[] + { + new MockTaskItem("SinglePackage", new Dictionary { { "Version", "1.0.0" } }) + }; + + var task = new RemoveDuplicatePackageReferences + { + BuildEngine = new MockBuildEngine(), + InputPackageReferences = packages + }; + + var result = task.Execute(); + + result.Should().BeTrue(); + task.UniquePackageReferences.Should().HaveCount(1); + task.UniquePackageReferences[0].ItemSpec.Should().Be("SinglePackage"); + } + + #endregion + + #region FilterResolvedFiles (dual-mode parity test) + + [Fact] + public void FilterResolvedFiles_ProducesSameResultsRegardlessOfCwd() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-parity-{Guid.NewGuid():N}")); + var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + var assetsContent = @"{ + ""version"": 3, + ""targets"": { "".NETCoreApp,Version=v8.0"": {} }, + ""libraries"": {}, + ""packageFolders"": {}, + ""projectFileDependencyGroups"": { "".NETCoreApp,Version=v8.0"": [] }, + ""project"": { ""version"": ""1.0.0"", ""frameworks"": { ""net8.0"": {} } } + }"; + var assetsPath = Path.Combine(projectDir, "project.assets.json"); + File.WriteAllText(assetsPath, assetsContent); + + var resolvedFiles = new ITaskItem[] + { + new MockTaskItem("MyLib.dll", new Dictionary + { + { "NuGetPackageId", "MyPackage" }, + { "NuGetPackageVersion", "1.0.0" } + }) + }; + var packagesToPrune = Array.Empty(); + + // --- CWD = projectDir --- + Directory.SetCurrentDirectory(projectDir); + var (cwdResult, cwdEngine) = RunFilterTask(assetsPath, resolvedFiles, packagesToPrune); + + // --- CWD = otherDir --- + Directory.SetCurrentDirectory(otherDir); + var (otherResult, otherEngine) = RunFilterTask(assetsPath, resolvedFiles, packagesToPrune); + + cwdResult.Should().Be(otherResult, + "FilterResolvedFiles should return same success/failure in both environments"); + cwdEngine.Errors.Count.Should().Be(otherEngine.Errors.Count, + "error count should be the same in both environments"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } + + private static (bool result, MockBuildEngine engine) RunFilterTask( + string assetsPath, ITaskItem[] resolvedFiles, ITaskItem[] packagesToPrune) + { + var engine = new MockBuildEngine(); + var task = new FilterResolvedFiles + { + BuildEngine = engine, + AssetsFilePath = assetsPath, + ResolvedFiles = resolvedFiles, + PackagesToPrune = packagesToPrune, + TargetFramework = ".NETCoreApp,Version=v8.0" + }; + var result = task.Execute(); + return (result, engine); + } + + #endregion + } +} diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs index a58f1e59c578..99221f3cf3a3 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs @@ -13,11 +13,14 @@ namespace Microsoft.NET.Build.Tasks /// /// Filters out the assemblies from the list based on a given package closure. /// - public class FilterResolvedFiles : TaskBase + [MSBuildMultiThreadableTask] + public class FilterResolvedFiles : TaskBase, IMultiThreadableTask { private readonly List _assembliesToPublish = new(); private readonly List _packagesResolved = new(); + public TaskEnvironment TaskEnvironment { get; set; } + public string AssetsFilePath { get; set; } [Required] @@ -52,7 +55,7 @@ public ITaskItem[] PublishedPackages protected override void ExecuteCore() { var lockFileCache = new LockFileCache(this); - LockFile lockFile = lockFileCache.GetLockFile(AssetsFilePath); + LockFile lockFile = lockFileCache.GetLockFile(TaskEnvironment.GetAbsolutePath(AssetsFilePath)); ProjectContext projectContext = lockFile.CreateProjectContext( TargetFramework, diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs index f3995157dfb9..912b2261240e 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs @@ -11,6 +11,7 @@ namespace Microsoft.NET.Build.Tasks; /// This task filters an Item list by those items that contain a specific Metadata that is /// compatible with a specified Runtime Identifier, according to a given RuntimeIdentifierGraph file. /// +[MSBuildMultiThreadableTask] public class SelectRuntimeIdentifierSpecificItems : TaskBase { /// diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/SetGeneratedAppConfigMetadata.cs b/src/Tasks/Microsoft.NET.Build.Tasks/SetGeneratedAppConfigMetadata.cs index 2760c0909ba0..084548a03f0b 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/SetGeneratedAppConfigMetadata.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/SetGeneratedAppConfigMetadata.cs @@ -8,6 +8,7 @@ namespace Microsoft.NET.Build.Tasks { + [MSBuildMultiThreadableTask] public sealed class SetGeneratedAppConfigMetadata : TaskBase { /// diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ValidateExecutableReferences.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ValidateExecutableReferences.cs index 09fa432633fa..8ad75727e58c 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/ValidateExecutableReferences.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/ValidateExecutableReferences.cs @@ -9,6 +9,7 @@ namespace Microsoft.NET.Build.Tasks { + [MSBuildMultiThreadableTask] public class ValidateExecutableReferences : TaskBase { public bool SelfContained { get; set; } From fe19a6f4387e7a1899dd3be8c6d3146d78c4b6b8 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Sun, 22 Feb 2026 17:20:37 +0100 Subject: [PATCH 02/13] Add attribute-presence tests and strengthen FilterResolvedFiles tests (Group 7) Add [MSBuildMultiThreadableTask] attribute verification for 4 attribute-only tasks plus FilterResolvedFiles. Replace reflection-based TaskEnvironment assignment with direct property access. Add parity test for FilterResolvedFiles verifying CWD-independent behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...GivenAFilterResolvedFilesMultiThreading.cs | 100 ++++++++++++++---- .../GivenAttributeOnlyTasksGroup7.cs | 33 ++++++ 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs index 8f9adaf17794..98139f08444d 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs @@ -3,12 +3,34 @@ using FluentAssertions; using Microsoft.Build.Framework; +using System.Reflection; using Xunit; namespace Microsoft.NET.Build.Tasks.UnitTests { public class GivenAFilterResolvedFilesMultiThreading { + private const string AssetsJson = """ + { + "version": 3, + "targets": { ".NETCoreApp,Version=v8.0": {} }, + "libraries": {}, + "packageFolders": {}, + "projectFileDependencyGroups": { ".NETCoreApp,Version=v8.0": [] }, + "project": { + "version": "1.0.0", + "frameworks": { "net8.0": {} } + } + } + """; + + [Fact] + public void FilterResolvedFiles_HasMultiThreadableAttribute() + { + typeof(FilterResolvedFiles).GetCustomAttribute() + .Should().NotBeNull("task must be decorated with [MSBuildMultiThreadableTask]"); + } + [Fact] public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() { @@ -16,22 +38,9 @@ public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() Directory.CreateDirectory(projectDir); try { - // Create a minimal lock file at a relative path var assetsDir = Path.Combine(projectDir, "obj"); Directory.CreateDirectory(assetsDir); - File.WriteAllText(Path.Combine(assetsDir, "project.assets.json"), - """ - { - "version": 3, - "targets": { ".NETCoreApp,Version=v8.0": {} }, - "libraries": {}, - "projectFileDependencyGroups": { ".NETCoreApp,Version=v8.0": [] }, - "project": { - "version": "1.0.0", - "frameworks": { "net8.0": {} } - } - } - """); + File.WriteAllText(Path.Combine(assetsDir, "project.assets.json"), AssetsJson); var task = new FilterResolvedFiles { @@ -40,13 +49,9 @@ public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() ResolvedFiles = Array.Empty(), PackagesToPrune = Array.Empty(), TargetFramework = ".NETCoreApp,Version=v8.0", + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), }; - // Set TaskEnvironment via reflection - var teProp = task.GetType().GetProperty("TaskEnvironment"); - teProp.Should().NotBeNull("task must have a TaskEnvironment property after migration"); - teProp!.SetValue(task, TaskEnvironmentHelper.CreateForTest(projectDir)); - var result = task.Execute(); result.Should().BeTrue("task should succeed when assets file is found via TaskEnvironment"); } @@ -55,5 +60,62 @@ public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() Directory.Delete(projectDir, true); } } + + private static (bool result, MockBuildEngine engine) RunTask( + string assetsRelPath, string projectDir) + { + var engine = new MockBuildEngine(); + var task = new FilterResolvedFiles + { + BuildEngine = engine, + AssetsFilePath = assetsRelPath, + ResolvedFiles = Array.Empty(), + PackagesToPrune = Array.Empty(), + TargetFramework = ".NETCoreApp,Version=v8.0", + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + + var result = task.Execute(); + return (result, engine); + } + + [Fact] + public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments() + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-parity-{Guid.NewGuid():N}")); + var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-decoy-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + Directory.CreateDirectory(otherDir); + var savedCwd = Directory.GetCurrentDirectory(); + try + { + var objDir = Path.Combine(projectDir, "obj"); + Directory.CreateDirectory(objDir); + File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson); + + var assetsRelPath = Path.Combine("obj", "project.assets.json"); + + // --- Multiprocess mode: CWD == projectDir --- + Directory.SetCurrentDirectory(projectDir); + var (mpResult, mpEngine) = RunTask(assetsRelPath, projectDir); + + // --- Multithreaded mode: CWD == otherDir --- + Directory.SetCurrentDirectory(otherDir); + var (mtResult, mtEngine) = RunTask(assetsRelPath, projectDir); + + mpResult.Should().Be(mtResult, + "task should return the same success/failure in both environments"); + mpEngine.Errors.Count.Should().Be(mtEngine.Errors.Count, + "error count should be the same in both environments"); + mpEngine.Warnings.Count.Should().Be(mtEngine.Warnings.Count, + "warning count should be the same in both environments"); + } + finally + { + Directory.SetCurrentDirectory(savedCwd); + Directory.Delete(projectDir, true); + if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); + } + } } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index 3ee7ae5e202b..e3a090dfe13b 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using System.Reflection; using Xunit; namespace Microsoft.NET.Build.Tasks.UnitTests @@ -15,6 +16,38 @@ namespace Microsoft.NET.Build.Tasks.UnitTests /// public class GivenAttributeOnlyTasksGroup7 { + #region Attribute Presence + + [Fact] + public void SelectRuntimeIdentifierSpecificItems_HasMultiThreadableAttribute() + { + typeof(SelectRuntimeIdentifierSpecificItems).GetCustomAttribute() + .Should().NotBeNull("task must be decorated with [MSBuildMultiThreadableTask]"); + } + + [Fact] + public void SetGeneratedAppConfigMetadata_HasMultiThreadableAttribute() + { + typeof(SetGeneratedAppConfigMetadata).GetCustomAttribute() + .Should().NotBeNull("task must be decorated with [MSBuildMultiThreadableTask]"); + } + + [Fact] + public void ValidateExecutableReferences_HasMultiThreadableAttribute() + { + typeof(ValidateExecutableReferences).GetCustomAttribute() + .Should().NotBeNull("task must be decorated with [MSBuildMultiThreadableTask]"); + } + + [Fact] + public void RemoveDuplicatePackageReferences_HasMultiThreadableAttribute() + { + typeof(RemoveDuplicatePackageReferences).GetCustomAttribute() + .Should().NotBeNull("task must be decorated with [MSBuildMultiThreadableTask]"); + } + + #endregion + #region SelectRuntimeIdentifierSpecificItems (parity test) [Fact] From 9d4c2d2d138c752717d35c9d851793e5f602813a Mon Sep 17 00:00:00 2001 From: SimaTian Date: Sun, 22 Feb 2026 18:06:08 +0100 Subject: [PATCH 03/13] Remove duplicate FilterResolvedFiles parity test from attribute-only file The test was misplaced (FilterResolvedFiles is Pattern B, not attribute-only) and had a latent NullReferenceException bug due to missing TaskEnvironment assignment. The correct version exists in GivenAFilterResolvedFilesMultiThreading.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GivenAttributeOnlyTasksGroup7.cs | 74 +------------------ 1 file changed, 3 insertions(+), 71 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index e3a090dfe13b..086383a14e54 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -343,76 +343,8 @@ public void RemoveDuplicatePackageReferences_SinglePackage_PassesThrough() #endregion - #region FilterResolvedFiles (dual-mode parity test) - - [Fact] - public void FilterResolvedFiles_ProducesSameResultsRegardlessOfCwd() - { - var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-parity-{Guid.NewGuid():N}")); - var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-decoy-{Guid.NewGuid():N}")); - Directory.CreateDirectory(projectDir); - Directory.CreateDirectory(otherDir); - var savedCwd = Directory.GetCurrentDirectory(); - try - { - var assetsContent = @"{ - ""version"": 3, - ""targets"": { "".NETCoreApp,Version=v8.0"": {} }, - ""libraries"": {}, - ""packageFolders"": {}, - ""projectFileDependencyGroups"": { "".NETCoreApp,Version=v8.0"": [] }, - ""project"": { ""version"": ""1.0.0"", ""frameworks"": { ""net8.0"": {} } } - }"; - var assetsPath = Path.Combine(projectDir, "project.assets.json"); - File.WriteAllText(assetsPath, assetsContent); - - var resolvedFiles = new ITaskItem[] - { - new MockTaskItem("MyLib.dll", new Dictionary - { - { "NuGetPackageId", "MyPackage" }, - { "NuGetPackageVersion", "1.0.0" } - }) - }; - var packagesToPrune = Array.Empty(); - - // --- CWD = projectDir --- - Directory.SetCurrentDirectory(projectDir); - var (cwdResult, cwdEngine) = RunFilterTask(assetsPath, resolvedFiles, packagesToPrune); - - // --- CWD = otherDir --- - Directory.SetCurrentDirectory(otherDir); - var (otherResult, otherEngine) = RunFilterTask(assetsPath, resolvedFiles, packagesToPrune); - - cwdResult.Should().Be(otherResult, - "FilterResolvedFiles should return same success/failure in both environments"); - cwdEngine.Errors.Count.Should().Be(otherEngine.Errors.Count, - "error count should be the same in both environments"); - } - finally - { - Directory.SetCurrentDirectory(savedCwd); - Directory.Delete(projectDir, true); - if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); - } - } - - private static (bool result, MockBuildEngine engine) RunFilterTask( - string assetsPath, ITaskItem[] resolvedFiles, ITaskItem[] packagesToPrune) - { - var engine = new MockBuildEngine(); - var task = new FilterResolvedFiles - { - BuildEngine = engine, - AssetsFilePath = assetsPath, - ResolvedFiles = resolvedFiles, - PackagesToPrune = packagesToPrune, - TargetFramework = ".NETCoreApp,Version=v8.0" - }; - var result = task.Execute(); - return (result, engine); - } - - #endregion + // FilterResolvedFiles parity test removed — it belongs in GivenAFilterResolvedFilesMultiThreading.cs + // (FilterResolvedFiles is Pattern B, not attribute-only). The duplicate here also had a bug: + // it created FilterResolvedFiles without setting TaskEnvironment, causing NullReferenceException. } } From f298249b3e5681210c558316438d251fbfad5fe8 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Sun, 22 Feb 2026 18:18:54 +0100 Subject: [PATCH 04/13] Add concurrent execution tests for Group 7 tasks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...GivenAFilterResolvedFilesMultiThreading.cs | 44 +++++++ .../GivenAttributeOnlyTasksGroup7.cs | 114 ++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs index 98139f08444d..a29d3f5f5868 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs @@ -3,7 +3,10 @@ using FluentAssertions; using Microsoft.Build.Framework; +using System.Collections.Concurrent; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Microsoft.NET.Build.Tasks.UnitTests @@ -117,5 +120,46 @@ public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments() if (Directory.Exists(otherDir)) Directory.Delete(otherDir, true); } } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void FilterResolvedFiles_ConcurrentExecution(int parallelism) + { + var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-concurrent-{Guid.NewGuid():N}")); + Directory.CreateDirectory(projectDir); + try + { + var objDir = Path.Combine(projectDir, "obj"); + Directory.CreateDirectory(objDir); + File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson); + + var errors = new ConcurrentBag(); + var barrier = new Barrier(parallelism); + Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + { + try + { + var task = new FilterResolvedFiles + { + BuildEngine = new MockBuildEngine(), + AssetsFilePath = Path.Combine("obj", "project.assets.json"), + ResolvedFiles = Array.Empty(), + PackagesToPrune = Array.Empty(), + TargetFramework = ".NETCoreApp,Version=v8.0", + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + barrier.SignalAndWait(); + task.Execute(); + } + catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + }); + errors.Should().BeEmpty(); + } + finally + { + Directory.Delete(projectDir, true); + } + } } } diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index 086383a14e54..96f296c4ed8a 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -4,7 +4,10 @@ using FluentAssertions; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using System.Collections.Concurrent; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Microsoft.NET.Build.Tasks.UnitTests @@ -346,5 +349,116 @@ public void RemoveDuplicatePackageReferences_SinglePackage_PassesThrough() // FilterResolvedFiles parity test removed — it belongs in GivenAFilterResolvedFilesMultiThreading.cs // (FilterResolvedFiles is Pattern B, not attribute-only). The duplicate here also had a bug: // it created FilterResolvedFiles without setting TaskEnvironment, causing NullReferenceException. + + #region Concurrent Execution + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void SelectRuntimeIdentifierSpecificItems_ConcurrentExecution(int parallelism) + { + var errors = new ConcurrentBag(); + var barrier = new Barrier(parallelism); + Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + { + try + { + var task = new SelectRuntimeIdentifierSpecificItems + { + BuildEngine = new MockBuildEngine(), + TargetRuntimeIdentifier = "linux-x64", + Items = new ITaskItem[] + { + CreateItemWithRid($"Item{i}", "linux-x64") + }, + RuntimeIdentifierGraphPath = "" + }; + barrier.SignalAndWait(); + task.Execute(); + } + catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + }); + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void SetGeneratedAppConfigMetadata_ConcurrentExecution(int parallelism) + { + var errors = new ConcurrentBag(); + var barrier = new Barrier(parallelism); + Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + { + try + { + var task = new SetGeneratedAppConfigMetadata + { + BuildEngine = new MockBuildEngine(), + GeneratedAppConfigFile = $"obj/app{i}.exe.config", + TargetName = $"app{i}.exe.config" + }; + barrier.SignalAndWait(); + task.Execute(); + } + catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + }); + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void ValidateExecutableReferences_ConcurrentExecution(int parallelism) + { + var errors = new ConcurrentBag(); + var barrier = new Barrier(parallelism); + Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + { + try + { + var task = new ValidateExecutableReferences + { + BuildEngine = new MockBuildEngine(), + IsExecutable = false, + SelfContained = false, + ReferencedProjects = Array.Empty() + }; + barrier.SignalAndWait(); + task.Execute(); + } + catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + }); + errors.Should().BeEmpty(); + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void RemoveDuplicatePackageReferences_ConcurrentExecution(int parallelism) + { + var errors = new ConcurrentBag(); + var barrier = new Barrier(parallelism); + Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + { + try + { + var task = new RemoveDuplicatePackageReferences + { + BuildEngine = new MockBuildEngine(), + InputPackageReferences = new ITaskItem[] + { + new MockTaskItem($"Package{i}", new Dictionary { { "Version", "1.0.0" } }) + } + }; + barrier.SignalAndWait(); + task.Execute(); + } + catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + }); + errors.Should().BeEmpty(); + } + + #endregion } } From 53d6b12d94a67d2c59067934f2f039bc1a585abe Mon Sep 17 00:00:00 2001 From: SimaTian Date: Sun, 22 Feb 2026 20:44:11 +0100 Subject: [PATCH 05/13] Upgrade SelectRuntimeIdentifierSpecificItems to Pattern B (Group 7) Task calls RuntimeGraphCache.GetRuntimeGraph(RuntimeIdentifierGraphPath) which does file I/O (JsonRuntimeFormat.ReadRuntimeGraph). The path must be absolutized via TaskEnvironment for multithreading safety. Changes: - Add IMultiThreadableTask interface and TaskEnvironment property - Absolutize RuntimeIdentifierGraphPath before passing to RuntimeGraphCache - Update tests to provide TaskEnvironment via TaskEnvironmentHelper.CreateForTest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GivenAttributeOnlyTasksGroup7.cs | 8 +++++--- .../SelectRuntimeIdentifierSpecificItems.cs | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index 96f296c4ed8a..72193b0f4958 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -85,11 +85,11 @@ public void SelectRuntimeIdentifierSpecificItems_ProducesSameResultsRegardlessOf // --- Run with CWD = projectDir --- Directory.SetCurrentDirectory(projectDir); - var (cwdResult, cwdSelected) = RunSelectRidTask("linux-x64", items, graphPath); + var (cwdResult, cwdSelected) = RunSelectRidTask("linux-x64", items, graphPath, projectDir); // --- Run with CWD = otherDir --- Directory.SetCurrentDirectory(otherDir); - var (otherResult, otherSelected) = RunSelectRidTask("linux-x64", items, graphPath); + var (otherResult, otherSelected) = RunSelectRidTask("linux-x64", items, graphPath, projectDir); cwdResult.Should().Be(otherResult, "task should return same result regardless of CWD"); cwdSelected.Length.Should().Be(otherSelected.Length, "same number of items should be selected"); @@ -108,11 +108,12 @@ public void SelectRuntimeIdentifierSpecificItems_ProducesSameResultsRegardlessOf } private static (bool result, ITaskItem[] selected) RunSelectRidTask( - string targetRid, ITaskItem[] items, string graphPath) + string targetRid, ITaskItem[] items, string graphPath, string projectDir) { var task = new SelectRuntimeIdentifierSpecificItems { BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), TargetRuntimeIdentifier = targetRid, Items = items, RuntimeIdentifierGraphPath = graphPath @@ -366,6 +367,7 @@ public void SelectRuntimeIdentifierSpecificItems_ConcurrentExecution(int paralle var task = new SelectRuntimeIdentifierSpecificItems { BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Path.GetTempPath()), TargetRuntimeIdentifier = "linux-x64", Items = new ITaskItem[] { diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs index 912b2261240e..e026865b5c87 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs @@ -12,8 +12,10 @@ namespace Microsoft.NET.Build.Tasks; /// compatible with a specified Runtime Identifier, according to a given RuntimeIdentifierGraph file. /// [MSBuildMultiThreadableTask] -public class SelectRuntimeIdentifierSpecificItems : TaskBase +public class SelectRuntimeIdentifierSpecificItems : TaskBase, IMultiThreadableTask { + public TaskEnvironment TaskEnvironment { get; set; } = null!; + /// /// The target runtime identifier to check compatibility against. /// @@ -53,7 +55,7 @@ protected override void ExecuteCore() string ridMetadata = RuntimeIdentifierItemMetadata ?? "RuntimeIdentifier"; - RuntimeGraph runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(RuntimeIdentifierGraphPath); + RuntimeGraph runtimeGraph = new RuntimeGraphCache(this).GetRuntimeGraph(TaskEnvironment.GetAbsolutePath(RuntimeIdentifierGraphPath)); var selectedItems = new List(); From b82ca91042857c1eae580c4a5b7acd5b2c561245 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Mon, 23 Feb 2026 10:28:35 +0100 Subject: [PATCH 06/13] Address PR review: ManualResetEventSlim + Path.Combine + RuntimeGraph fix - Replace Barrier+Parallel.For with ManualResetEventSlim+Task.Run - Use async test methods with Task.WhenAll - Replace Windows path separator with Path.Combine - Fix empty RuntimeIdentifierGraphPath in concurrent test - Assert Execute() return value in concurrent tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...GivenAFilterResolvedFilesMultiThreading.cs | 45 +++++---- .../GivenAttributeOnlyTasksGroup7.cs | 98 ++++++++++++++----- 2 files changed, 98 insertions(+), 45 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs index a29d3f5f5868..f351f41e21b0 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs @@ -48,7 +48,7 @@ public void AssetsFilePath_IsResolvedRelativeToProjectDirectory() var task = new FilterResolvedFiles { BuildEngine = new MockBuildEngine(), - AssetsFilePath = "obj\\project.assets.json", + AssetsFilePath = Path.Combine("obj", "project.assets.json"), ResolvedFiles = Array.Empty(), PackagesToPrune = Array.Empty(), TargetFramework = ".NETCoreApp,Version=v8.0", @@ -124,7 +124,7 @@ public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments() [Theory] [InlineData(4)] [InlineData(16)] - public void FilterResolvedFiles_ConcurrentExecution(int parallelism) + public async System.Threading.Tasks.Task FilterResolvedFiles_ConcurrentExecution(int parallelism) { var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-concurrent-{Guid.NewGuid():N}")); Directory.CreateDirectory(projectDir); @@ -135,25 +135,34 @@ public void FilterResolvedFiles_ConcurrentExecution(int parallelism) File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson); var errors = new ConcurrentBag(); - var barrier = new Barrier(parallelism); - Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) { - try + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => { - var task = new FilterResolvedFiles + try { - BuildEngine = new MockBuildEngine(), - AssetsFilePath = Path.Combine("obj", "project.assets.json"), - ResolvedFiles = Array.Empty(), - PackagesToPrune = Array.Empty(), - TargetFramework = ".NETCoreApp,Version=v8.0", - TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), - }; - barrier.SignalAndWait(); - task.Execute(); - } - catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } - }); + var task = new FilterResolvedFiles + { + BuildEngine = new MockBuildEngine(), + AssetsFilePath = Path.Combine("obj", "project.assets.json"), + ResolvedFiles = Array.Empty(), + PackagesToPrune = Array.Empty(), + TargetFramework = ".NETCoreApp,Version=v8.0", + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), + }; + startGate.Wait(); + var result = task.Execute(); + if (!result) errors.Add($"Thread {idx}: Execute returned false"); + } + catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); } + }); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + errors.Should().BeEmpty(); } finally diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index 72193b0f4958..2945e9ec6cd6 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -356,66 +356,98 @@ public void RemoveDuplicatePackageReferences_SinglePackage_PassesThrough() [Theory] [InlineData(4)] [InlineData(16)] - public void SelectRuntimeIdentifierSpecificItems_ConcurrentExecution(int parallelism) + public async System.Threading.Tasks.Task SelectRuntimeIdentifierSpecificItems_ConcurrentExecution(int parallelism) { + var projectDir = Path.Combine(Path.GetTempPath(), $"select-rid-concurrent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(projectDir); + var runtimeGraphPath = Path.Combine(projectDir, "runtime.json"); + File.WriteAllText(runtimeGraphPath, @"{ ""runtimes"": { ""linux-x64"": { ""#import"": [""linux"", ""unix""] } } }"); + try + { var errors = new ConcurrentBag(); - var barrier = new Barrier(parallelism); - Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => { try { var task = new SelectRuntimeIdentifierSpecificItems { BuildEngine = new MockBuildEngine(), - TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Path.GetTempPath()), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir), TargetRuntimeIdentifier = "linux-x64", Items = new ITaskItem[] { - CreateItemWithRid($"Item{i}", "linux-x64") + CreateItemWithRid($"Item{idx}", "linux-x64") }, - RuntimeIdentifierGraphPath = "" + RuntimeIdentifierGraphPath = "runtime.json" }; - barrier.SignalAndWait(); - task.Execute(); + startGate.Wait(); + var result = task.Execute(); + if (!result) errors.Add($"Thread {idx}: Execute returned false"); } - catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); } }); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + errors.Should().BeEmpty(); + } + finally + { + Directory.Delete(projectDir, true); + } } [Theory] [InlineData(4)] [InlineData(16)] - public void SetGeneratedAppConfigMetadata_ConcurrentExecution(int parallelism) + public async System.Threading.Tasks.Task SetGeneratedAppConfigMetadata_ConcurrentExecution(int parallelism) { var errors = new ConcurrentBag(); - var barrier = new Barrier(parallelism); - Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => { try { var task = new SetGeneratedAppConfigMetadata { BuildEngine = new MockBuildEngine(), - GeneratedAppConfigFile = $"obj/app{i}.exe.config", - TargetName = $"app{i}.exe.config" + GeneratedAppConfigFile = $"obj/app{idx}.exe.config", + TargetName = $"app{idx}.exe.config" }; - barrier.SignalAndWait(); + startGate.Wait(); task.Execute(); } - catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); } }); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + errors.Should().BeEmpty(); } [Theory] [InlineData(4)] [InlineData(16)] - public void ValidateExecutableReferences_ConcurrentExecution(int parallelism) + public async System.Threading.Tasks.Task ValidateExecutableReferences_ConcurrentExecution(int parallelism) { var errors = new ConcurrentBag(); - var barrier = new Barrier(parallelism); - Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => { try { @@ -426,22 +458,30 @@ public void ValidateExecutableReferences_ConcurrentExecution(int parallelism) SelfContained = false, ReferencedProjects = Array.Empty() }; - barrier.SignalAndWait(); + startGate.Wait(); task.Execute(); } - catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); } }); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + errors.Should().BeEmpty(); } [Theory] [InlineData(4)] [InlineData(16)] - public void RemoveDuplicatePackageReferences_ConcurrentExecution(int parallelism) + public async System.Threading.Tasks.Task RemoveDuplicatePackageReferences_ConcurrentExecution(int parallelism) { var errors = new ConcurrentBag(); - var barrier = new Barrier(parallelism); - Parallel.For(0, parallelism, new ParallelOptions { MaxDegreeOfParallelism = parallelism }, i => + using var startGate = new ManualResetEventSlim(false); + var tasks = new System.Threading.Tasks.Task[parallelism]; + for (int i = 0; i < parallelism; i++) + { + int idx = i; + tasks[idx] = System.Threading.Tasks.Task.Run(() => { try { @@ -450,14 +490,18 @@ public void RemoveDuplicatePackageReferences_ConcurrentExecution(int parallelism BuildEngine = new MockBuildEngine(), InputPackageReferences = new ITaskItem[] { - new MockTaskItem($"Package{i}", new Dictionary { { "Version", "1.0.0" } }) + new MockTaskItem($"Package{idx}", new Dictionary { { "Version", "1.0.0" } }) } }; - barrier.SignalAndWait(); + startGate.Wait(); task.Execute(); } - catch (Exception ex) { errors.Add($"Thread {i}: {ex.Message}"); } + catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); } }); + } + startGate.Set(); + await System.Threading.Tasks.Task.WhenAll(tasks); + errors.Should().BeEmpty(); } From aae3335cca91ffe9165571c4fea96c6e1577296a Mon Sep 17 00:00:00 2001 From: SimaTian Date: Mon, 23 Feb 2026 17:12:27 +0100 Subject: [PATCH 07/13] Fix CWD race: add [Collection] to serialize CWD-mutating tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GivenAFilterResolvedFilesMultiThreading.cs | 2 ++ .../GivenAttributeOnlyTasksGroup7.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs index f351f41e21b0..3d49577935d4 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs @@ -11,6 +11,8 @@ namespace Microsoft.NET.Build.Tasks.UnitTests { + [Collection("CWD-Dependent")] + public class GivenAFilterResolvedFilesMultiThreading { private const string AssetsJson = """ diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs index 2945e9ec6cd6..439bbcca5979 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs @@ -17,6 +17,8 @@ namespace Microsoft.NET.Build.Tasks.UnitTests /// These tasks received only the [MSBuildMultiThreadableTask] attribute — no source /// code changes — so we verify they still produce correct results. /// + [Collection("CWD-Dependent")] + public class GivenAttributeOnlyTasksGroup7 { #region Attribute Presence From aae3ef348837152ee7444a19161098a3437e3c78 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Tue, 24 Feb 2026 11:40:43 +0100 Subject: [PATCH 08/13] Add TaskEnvironment lazy-init for merge-group-7 tasks - Add TaskEnvironmentDefaults.cs for NETFRAMEWORK lazy-init fallback - Apply lazy-init pattern to FilterResolvedFiles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tasks/Common/TaskEnvironmentDefaults.cs | 26 +++++++++++++++++++ .../FilterResolvedFiles.cs | 9 +++++++ 2 files changed, 35 insertions(+) create mode 100644 src/Tasks/Common/TaskEnvironmentDefaults.cs diff --git a/src/Tasks/Common/TaskEnvironmentDefaults.cs b/src/Tasks/Common/TaskEnvironmentDefaults.cs new file mode 100644 index 000000000000..7ef5666a4175 --- /dev/null +++ b/src/Tasks/Common/TaskEnvironmentDefaults.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Provides a default TaskEnvironment for single-threaded MSBuild execution. +// When MSBuild supports IMultiThreadableTask, it sets TaskEnvironment directly. +// This fallback ensures tasks work with older MSBuild versions that do not set it. + +#if NETFRAMEWORK + +using System; + +namespace Microsoft.Build.Framework +{ + internal static class TaskEnvironmentDefaults + { + /// + /// Creates a default TaskEnvironment backed by the current process environment. + /// Uses Environment.CurrentDirectory as the project directory, which in single-threaded + /// MSBuild is set to the project directory before task execution. + /// + internal static TaskEnvironment Create() => + new TaskEnvironment(new ProcessTaskEnvironmentDriver(Environment.CurrentDirectory)); + } +} + +#endif diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs index 99221f3cf3a3..5c7fdbde6475 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs @@ -19,7 +19,16 @@ public class FilterResolvedFiles : TaskBase, IMultiThreadableTask private readonly List _assembliesToPublish = new(); private readonly List _packagesResolved = new(); +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else public TaskEnvironment TaskEnvironment { get; set; } +#endif public string AssetsFilePath { get; set; } From d11c4220d87eb24571738b4b21cb1413011da623 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Tue, 24 Feb 2026 16:10:51 +0100 Subject: [PATCH 09/13] Fix AbsolutePath combining constructor: normalize '..' segments The combining constructor AbsolutePath(string, AbsolutePath) used Path.Combine without Path.GetFullPath, leaving '..' segments unresolved. This caused output paths like 'dir\..\ClassLib\...' instead of 'ClassLib\...', breaking string-based path comparisons in downstream MSBuild targets and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tasks/Common/AbsolutePath.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tasks/Common/AbsolutePath.cs b/src/Tasks/Common/AbsolutePath.cs index 5e4b1803170c..c83348745f9b 100644 --- a/src/Tasks/Common/AbsolutePath.cs +++ b/src/Tasks/Common/AbsolutePath.cs @@ -125,7 +125,7 @@ public AbsolutePath(string path, AbsolutePath basePath) throw new ArgumentException("Path must not be null or empty.", nameof(path)); } - Value = Path.Combine(basePath.Value, path); + Value = Path.GetFullPath(Path.Combine(basePath.Value, path)); OriginalValue = path; } From c93afc45636427287cf653bf457a884040312b71 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Wed, 25 Feb 2026 13:20:58 +0100 Subject: [PATCH 10/13] Add UseShellExecute=false in ProcessTaskEnvironmentDriver On .NET Framework, ProcessStartInfo defaults to UseShellExecute=true, which prevents EnvironmentVariables from being applied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tasks/Common/ProcessTaskEnvironmentDriver.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tasks/Common/ProcessTaskEnvironmentDriver.cs b/src/Tasks/Common/ProcessTaskEnvironmentDriver.cs index cb9e4b35fdd2..b41598f528f3 100644 --- a/src/Tasks/Common/ProcessTaskEnvironmentDriver.cs +++ b/src/Tasks/Common/ProcessTaskEnvironmentDriver.cs @@ -103,6 +103,7 @@ public ProcessStartInfo GetProcessStartInfo() var startInfo = new ProcessStartInfo { WorkingDirectory = _projectDirectory.Value, + UseShellExecute = false, }; // Populate environment from the scoped environment dictionary From 228c57a1b0ac4eac838da8f54fa6bf11906b2470 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Wed, 25 Feb 2026 13:21:35 +0100 Subject: [PATCH 11/13] Revert AbsolutePath: remove Path.GetFullPath wrapping Normalization is caller's responsibility, matching real MSBuild polyfill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Tasks/Common/AbsolutePath.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tasks/Common/AbsolutePath.cs b/src/Tasks/Common/AbsolutePath.cs index c83348745f9b..5e4b1803170c 100644 --- a/src/Tasks/Common/AbsolutePath.cs +++ b/src/Tasks/Common/AbsolutePath.cs @@ -125,7 +125,7 @@ public AbsolutePath(string path, AbsolutePath basePath) throw new ArgumentException("Path must not be null or empty.", nameof(path)); } - Value = Path.GetFullPath(Path.Combine(basePath.Value, path)); + Value = Path.Combine(basePath.Value, path); OriginalValue = path; } From a8f097c6e1ec0004ab98a3b2e2ab0beb8c7045a2 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Wed, 25 Feb 2026 14:46:00 +0100 Subject: [PATCH 12/13] Fix CWD-dependent Strings.resx loading in GivenThatWeHaveErrorCodes Use assembly-relative path instead of bare filename to avoid FileNotFound when parallel tests change the process CWD via TaskTestEnvironment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GivenThatWeHaveErrorCodes.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenThatWeHaveErrorCodes.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenThatWeHaveErrorCodes.cs index dc383cfdeaa0..1076e8169314 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenThatWeHaveErrorCodes.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenThatWeHaveErrorCodes.cs @@ -5,6 +5,7 @@ using System.Collections; using System.Globalization; +using System.Reflection; using System.Text.RegularExpressions; using FluentAssertions; using Xunit; @@ -95,7 +96,8 @@ public void ThereAreNoGapsDuplicatesOrIncorrectlyFormattedCodes() [Fact] public void ResxIsCommentedWithCorrectStrBegin() { - var doc = XDocument.Load("Strings.resx"); + var resxPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Strings.resx"); + var doc = XDocument.Load(resxPath); var ns = doc.Root.Name.Namespace; foreach (var data in doc.Root.Elements(ns + "data")) From a1ac0c48df141fd48cc5b179cbea74fcd31e4838 Mon Sep 17 00:00:00 2001 From: SimaTian Date: Wed, 25 Feb 2026 16:06:36 +0100 Subject: [PATCH 13/13] Fix NRE in SelectRuntimeIdentifierSpecificItems: add NETFRAMEWORK lazy-init and set TaskEnvironment in tests - Replace '= null!' with #if NETFRAMEWORK lazy-init pattern for TaskEnvironment property, matching all other Pattern B tasks - Set TaskEnvironment in all 5 test instantiations to prevent NRE on .NET Core where there is no lazy-init fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GivenASelectRuntimeIdentifierSpecificItems.cs | 15 ++++++++++----- .../SelectRuntimeIdentifierSpecificItems.cs | 9 +++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenASelectRuntimeIdentifierSpecificItems.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenASelectRuntimeIdentifierSpecificItems.cs index c6dea601a9ff..0bdc931d5706 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenASelectRuntimeIdentifierSpecificItems.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenASelectRuntimeIdentifierSpecificItems.cs @@ -28,7 +28,8 @@ public void ItSelectsCompatibleItems() TargetRuntimeIdentifier = "ubuntu.18.04-x64", Items = items, RuntimeIdentifierGraphPath = testRuntimeGraphPath, - BuildEngine = new MockBuildEngine() + BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; // Act @@ -59,7 +60,8 @@ public void ItSelectsItemsWithExactMatch() TargetRuntimeIdentifier = "win-x64", Items = items, RuntimeIdentifierGraphPath = testRuntimeGraphPath, - BuildEngine = new MockBuildEngine() + BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; // Act @@ -88,7 +90,8 @@ public void ItSkipsItemsWithoutRuntimeIdentifierMetadata() TargetRuntimeIdentifier = "linux-x64", Items = items, RuntimeIdentifierGraphPath = testRuntimeGraphPath, - BuildEngine = new MockBuildEngine() + BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; // Act @@ -114,7 +117,8 @@ public void ItUsesCustomRuntimeIdentifierMetadata() Items = new[] { item }, RuntimeIdentifierItemMetadata = "CustomRID", RuntimeIdentifierGraphPath = testRuntimeGraphPath, - BuildEngine = new MockBuildEngine() + BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; // Act @@ -137,7 +141,8 @@ public void ItReturnsEmptyArrayWhenNoItemsProvided() TargetRuntimeIdentifier = "linux-x64", Items = new ITaskItem[0], RuntimeIdentifierGraphPath = testRuntimeGraphPath, - BuildEngine = new MockBuildEngine() + BuildEngine = new MockBuildEngine(), + TaskEnvironment = TaskEnvironmentHelper.CreateForTest() }; // Act diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs index e026865b5c87..f5568d959ab7 100644 --- a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs +++ b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs @@ -14,7 +14,16 @@ namespace Microsoft.NET.Build.Tasks; [MSBuildMultiThreadableTask] public class SelectRuntimeIdentifierSpecificItems : TaskBase, IMultiThreadableTask { +#if NETFRAMEWORK + private TaskEnvironment _taskEnvironment; + public TaskEnvironment TaskEnvironment + { + get => _taskEnvironment ??= TaskEnvironmentDefaults.Create(); + set => _taskEnvironment = value; + } +#else public TaskEnvironment TaskEnvironment { get; set; } = null!; +#endif /// /// The target runtime identifier to check compatibility against.