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
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.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs
new file mode 100644
index 000000000000..3d49577935d4
--- /dev/null
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAFilterResolvedFilesMultiThreading.cs
@@ -0,0 +1,176 @@
+// 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 System.Collections.Concurrent;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.NET.Build.Tasks.UnitTests
+{
+ [Collection("CWD-Dependent")]
+
+ 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()
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"filter-mt-{Guid.NewGuid():N}"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ var assetsDir = Path.Combine(projectDir, "obj");
+ Directory.CreateDirectory(assetsDir);
+ File.WriteAllText(Path.Combine(assetsDir, "project.assets.json"), AssetsJson);
+
+ 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),
+ };
+
+ var result = task.Execute();
+ result.Should().BeTrue("task should succeed when assets file is found via TaskEnvironment");
+ }
+ finally
+ {
+ 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);
+ }
+ }
+
+ [Theory]
+ [InlineData(4)]
+ [InlineData(16)]
+ 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);
+ try
+ {
+ var objDir = Path.Combine(projectDir, "obj");
+ Directory.CreateDirectory(objDir);
+ File.WriteAllText(Path.Combine(objDir, "project.assets.json"), AssetsJson);
+
+ var errors = new ConcurrentBag();
+ 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 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
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+ }
+}
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.UnitTests/GivenAttributeOnlyTasksGroup7.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs
new file mode 100644
index 000000000000..439bbcca5979
--- /dev/null
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup7.cs
@@ -0,0 +1,512 @@
+// 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 System.Collections.Concurrent;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+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.
+ ///
+ [Collection("CWD-Dependent")]
+
+ 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]
+ 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, projectDir);
+
+ // --- Run with CWD = otherDir ---
+ Directory.SetCurrentDirectory(otherDir);
+ 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");
+
+ // 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, string projectDir)
+ {
+ var task = new SelectRuntimeIdentifierSpecificItems
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
+ 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
+
+ // 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 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();
+ 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(projectDir),
+ TargetRuntimeIdentifier = "linux-x64",
+ Items = new ITaskItem[]
+ {
+ CreateItemWithRid($"Item{idx}", "linux-x64")
+ },
+ RuntimeIdentifierGraphPath = "runtime.json"
+ };
+ 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
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ [Theory]
+ [InlineData(4)]
+ [InlineData(16)]
+ public async System.Threading.Tasks.Task SetGeneratedAppConfigMetadata_ConcurrentExecution(int parallelism)
+ {
+ var errors = new ConcurrentBag();
+ 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{idx}.exe.config",
+ TargetName = $"app{idx}.exe.config"
+ };
+ startGate.Wait();
+ task.Execute();
+ }
+ 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 async System.Threading.Tasks.Task ValidateExecutableReferences_ConcurrentExecution(int parallelism)
+ {
+ var errors = new ConcurrentBag();
+ 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 ValidateExecutableReferences
+ {
+ BuildEngine = new MockBuildEngine(),
+ IsExecutable = false,
+ SelfContained = false,
+ ReferencedProjects = Array.Empty()
+ };
+ startGate.Wait();
+ task.Execute();
+ }
+ 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 async System.Threading.Tasks.Task RemoveDuplicatePackageReferences_ConcurrentExecution(int parallelism)
+ {
+ var errors = new ConcurrentBag();
+ 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 RemoveDuplicatePackageReferences
+ {
+ BuildEngine = new MockBuildEngine(),
+ InputPackageReferences = new ITaskItem[]
+ {
+ new MockTaskItem($"Package{idx}", new Dictionary { { "Version", "1.0.0" } })
+ }
+ };
+ startGate.Wait();
+ task.Execute();
+ }
+ catch (Exception ex) { errors.Add($"Thread {idx}: {ex.Message}"); }
+ });
+ }
+ startGate.Set();
+ await System.Threading.Tasks.Task.WhenAll(tasks);
+
+ errors.Should().BeEmpty();
+ }
+
+ #endregion
+ }
+}
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"))
diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs
index a58f1e59c578..5c7fdbde6475 100644
--- a/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs
+++ b/src/Tasks/Microsoft.NET.Build.Tasks/FilterResolvedFiles.cs
@@ -13,11 +13,23 @@ 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();
+#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; }
[Required]
@@ -52,7 +64,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..f5568d959ab7 100644
--- a/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs
+++ b/src/Tasks/Microsoft.NET.Build.Tasks/SelectRuntimeIdentifierSpecificItems.cs
@@ -11,8 +11,20 @@ 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.
///
-public class SelectRuntimeIdentifierSpecificItems : TaskBase
+[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.
///
@@ -52,7 +64,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();
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; }