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/GivenAProduceContentAssetsMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAProduceContentAssetsMultiThreading.cs
new file mode 100644
index 000000000000..b706b7749088
--- /dev/null
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAProduceContentAssetsMultiThreading.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.
+
+using FluentAssertions;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Xunit;
+
+namespace Microsoft.NET.Build.Tasks.UnitTests
+{
+ [Collection("CWD-Dependent")]
+
+ public class GivenAProduceContentAssetsMultiThreading
+ {
+ [Fact]
+ public void ContentPreprocessorOutputDirectory_IsResolvedRelativeToProjectDirectory()
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-mt-{Guid.NewGuid():N}"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ // Create a relative output directory under projectDir
+ var ppOutputDir = Path.Combine(projectDir, "obj", "pp");
+ Directory.CreateDirectory(ppOutputDir);
+
+ var contentFile = new MockTaskItem("path/to/content.cs", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ ContentFileDependencies = new ITaskItem[] { contentFile },
+ ContentPreprocessorOutputDirectory = Path.Combine("obj", "pp"),
+ ProjectLanguage = "C#",
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue("task should succeed with relative ContentPreprocessorOutputDirectory resolved via TaskEnvironment");
+ }
+ finally
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ [Fact]
+ public void ItProducesSameResultsRegardlessOfCwd()
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-parity-{Guid.NewGuid():N}"));
+ var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"produce-decoy-{Guid.NewGuid():N}"));
+ Directory.CreateDirectory(projectDir);
+ Directory.CreateDirectory(otherDir);
+ var savedCwd = Directory.GetCurrentDirectory();
+ try
+ {
+ var contentFile1 = new MockTaskItem("path/to/content.cs", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+ var contentFile2 = new MockTaskItem("path/to/content.cs", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var taskEnv = TaskEnvironmentHelper.CreateForTest(projectDir);
+
+ // --- Multiprocess mode: CWD == projectDir ---
+ Directory.SetCurrentDirectory(projectDir);
+ var engine1 = new MockBuildEngine();
+ var task1 = new ProduceContentAssets
+ {
+ BuildEngine = engine1,
+ ContentFileDependencies = new ITaskItem[] { contentFile1 },
+ ProjectLanguage = "C#",
+ TaskEnvironment = taskEnv,
+ };
+ var result1 = task1.Execute();
+
+ // --- Multithreaded mode: CWD == otherDir ---
+ Directory.SetCurrentDirectory(otherDir);
+ var engine2 = new MockBuildEngine();
+ var task2 = new ProduceContentAssets
+ {
+ BuildEngine = engine2,
+ ContentFileDependencies = new ITaskItem[] { contentFile2 },
+ ProjectLanguage = "C#",
+ TaskEnvironment = taskEnv,
+ };
+ var result2 = task2.Execute();
+
+ result1.Should().Be(result2,
+ "task should return the same result regardless of CWD");
+ engine1.Errors.Count.Should().Be(engine2.Errors.Count,
+ "error count should be the same in both environments");
+ task1.ProcessedContentItems.Length.Should().Be(task2.ProcessedContentItems.Length,
+ "ProcessedContentItems count should be identical regardless of CWD");
+ task1.CopyLocalItems.Length.Should().Be(task2.CopyLocalItems.Length,
+ "CopyLocalItems count should be identical regardless of CWD");
+ task1.FileWrites.Length.Should().Be(task2.FileWrites.Length,
+ "FileWrites count should be identical regardless of CWD");
+ }
+ 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/GivenAProduceContentsAssetsTask.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAProduceContentsAssetsTask.cs
index bfbbde77794b..6b3785b11a80 100644
--- a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAProduceContentsAssetsTask.cs
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAProduceContentsAssetsTask.cs
@@ -46,6 +46,8 @@ public void ItProcessesContentFiles()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -99,6 +101,8 @@ public void ItOutputsFileWritesForProcessedContent()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -159,6 +163,8 @@ public void ItOutputsCopyLocalItems()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -235,6 +241,8 @@ public void ItOutputsContentItemsWithActiveBuildAction()
ContentPreprocessorOutputDirectory = ContentOutputDirectory,
ProjectLanguage = null,
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -318,6 +326,8 @@ public void ItCanOutputOnlyPreprocessedItems()
ProduceOnlyPreprocessorFiles = true,
ProjectLanguage = null,
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -373,6 +383,8 @@ public void ItIgnoresProjectLanguageIfCodeLanguageIsOnlyAny()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -415,6 +427,8 @@ public void ItProcessesOnlyProjectLanguageIfPresent()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -459,6 +473,8 @@ public void ItProcessesOnlyAnyItemsIfProjectLanguageNotPresent()
ContentPreprocessorOutputDirectory = null,
ProjectLanguage = "C#",
};
+ task.BuildEngine = new MockBuildEngine();
+ task.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(Directory.GetCurrentDirectory());
task.Execute().Should().BeTrue();
// Asserts
@@ -482,8 +498,8 @@ public void ItProcessesOnlyAnyItemsIfProjectLanguageNotPresent()
#region Sample Test Data
- private static readonly string ContentOutputDirectory = Path.Combine("bin", "obj");
- private static readonly string PackageRootDirectory = Path.Combine("root", "packages");
+ private static readonly string ContentOutputDirectory = Path.GetFullPath(Path.Combine("bin", "obj"));
+ private static readonly string PackageRootDirectory = Path.GetFullPath(Path.Combine("root", "packages"));
private static ITaskItem[] GetPreprocessorValueItems(Dictionary values)
=> values.Select(kvp => new MockTaskItem(
diff --git a/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAResolveCopyLocalAssetsMultiThreading.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAResolveCopyLocalAssetsMultiThreading.cs
new file mode 100644
index 000000000000..30aa7b2878e0
--- /dev/null
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAResolveCopyLocalAssetsMultiThreading.cs
@@ -0,0 +1,123 @@
+// 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
+{
+ [Collection("CWD-Dependent")]
+
+ public class GivenAResolveCopyLocalAssetsMultiThreading
+ {
+ 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 AssetsFilePath_IsResolvedRelativeToProjectDirectory()
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-mt-{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 task = new ResolveCopyLocalAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ AssetsFilePath = Path.Combine("obj", "project.assets.json"),
+ TargetFramework = ".NETCoreApp,Version=v8.0",
+ RuntimeIdentifier = "",
+ IsSelfContained = false,
+ ResolveRuntimeTargets = false,
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue("task should succeed when assets file is found via TaskEnvironment");
+ task.ResolvedAssets.Should().BeEmpty("no packages in the lock file means no assets");
+ }
+ finally
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ [Fact]
+ public void ItProducesSameResultsInMultiProcessAndMultiThreadedEnvironments()
+ {
+ var projectDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-parity-{Guid.NewGuid():N}"));
+ var otherDir = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"copyloc-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");
+ var taskEnv = TaskEnvironmentHelper.CreateForTest(projectDir);
+
+ // --- Multiprocess mode: CWD == projectDir ---
+ Directory.SetCurrentDirectory(projectDir);
+ var engine1 = new MockBuildEngine();
+ var task1 = new ResolveCopyLocalAssets
+ {
+ BuildEngine = engine1,
+ AssetsFilePath = assetsRelPath,
+ TargetFramework = ".NETCoreApp,Version=v8.0",
+ RuntimeIdentifier = "",
+ IsSelfContained = false,
+ ResolveRuntimeTargets = false,
+ TaskEnvironment = taskEnv,
+ };
+ var result1 = task1.Execute();
+
+ // --- Multithreaded mode: CWD == otherDir ---
+ Directory.SetCurrentDirectory(otherDir);
+ var engine2 = new MockBuildEngine();
+ var task2 = new ResolveCopyLocalAssets
+ {
+ BuildEngine = engine2,
+ AssetsFilePath = assetsRelPath,
+ TargetFramework = ".NETCoreApp,Version=v8.0",
+ RuntimeIdentifier = "",
+ IsSelfContained = false,
+ ResolveRuntimeTargets = false,
+ TaskEnvironment = taskEnv,
+ };
+ var result2 = task2.Execute();
+
+ result1.Should().Be(result2,
+ "task should return the same result regardless of CWD");
+ engine1.Errors.Count.Should().Be(engine2.Errors.Count,
+ "error count should be identical in both environments");
+ task1.ResolvedAssets.Length.Should().Be(task2.ResolvedAssets.Length,
+ "ResolvedAssets count should be identical regardless of CWD");
+ }
+ 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/GivenAttributeOnlyTasksGroup6.cs b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup6.cs
new file mode 100644
index 000000000000..0fe452b0d8cb
--- /dev/null
+++ b/src/Tasks/Microsoft.NET.Build.Tasks.UnitTests/GivenAttributeOnlyTasksGroup6.cs
@@ -0,0 +1,708 @@
+// 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.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.NET.Build.Tasks.UnitTests
+{
+ ///
+ /// Behavioral and attribute-presence tests for tasks in merge-group-6.
+ /// GetDefaultPlatformTargetForNetFramework, GetEmbeddedApphostPaths, and
+ /// GetNuGetShortFolderName are attribute-only. ProduceContentAssets and
+ /// ResolveCopyLocalAssets were migrated to Pattern B (IMultiThreadableTask).
+ ///
+ public class GivenAttributeOnlyTasksGroup6
+ {
+ #region GetDefaultPlatformTargetForNetFramework
+
+ [Fact]
+ public void GetDefaultPlatformTargetForNetFramework_ReturnsAnyCPU_WhenNoNativeAssets()
+ {
+ var task = new GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = Array.Empty(),
+ NativeCopyLocalItems = Array.Empty()
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.DefaultPlatformTarget.Should().Be("AnyCPU");
+ }
+
+ [Fact]
+ public void GetDefaultPlatformTargetForNetFramework_ReturnsAnyCPU_WhenNullNativeAssets()
+ {
+ var task = new GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = null,
+ NativeCopyLocalItems = null
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.DefaultPlatformTarget.Should().Be("AnyCPU");
+ }
+
+ [Fact]
+ public void GetDefaultPlatformTargetForNetFramework_ReturnsX86_WhenPlatformsPackagePresent()
+ {
+ var platformsPkg = new TaskItem("Microsoft.NETCore.Platforms");
+ var nativeItem = new MockTaskItem("native.dll", new Dictionary
+ {
+ { "PathInPackage", "runtimes/win-x64/native/native.dll" }
+ });
+
+ var task = new GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = new ITaskItem[] { platformsPkg },
+ NativeCopyLocalItems = new ITaskItem[] { nativeItem }
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.DefaultPlatformTarget.Should().Be("x86",
+ "presence of NETCore.Platforms package with native assets implies x86");
+ }
+
+ [Fact]
+ public void GetDefaultPlatformTargetForNetFramework_ReturnsX86_WhenWin7X86NativeAsset()
+ {
+ var nativeItem = new MockTaskItem("native.dll", new Dictionary
+ {
+ { "PathInPackage", "runtimes/win7-x86/native/native.dll" }
+ });
+
+ var task = new GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = Array.Empty(),
+ NativeCopyLocalItems = new ITaskItem[] { nativeItem }
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.DefaultPlatformTarget.Should().Be("x86",
+ "win7-x86 native assets without Platforms package should still return x86");
+ }
+
+ [Fact]
+ public void GetDefaultPlatformTargetForNetFramework_ReturnsAnyCPU_WhenNonX86NativeAsset()
+ {
+ var nativeItem = new MockTaskItem("native.dll", new Dictionary
+ {
+ { "PathInPackage", "runtimes/win-x64/native/native.dll" }
+ });
+
+ var task = new GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = Array.Empty(),
+ NativeCopyLocalItems = new ITaskItem[] { nativeItem }
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.DefaultPlatformTarget.Should().Be("AnyCPU",
+ "non-win7-x86 native assets without Platforms package should return AnyCPU");
+ }
+
+ #endregion
+
+ #region GetEmbeddedApphostPaths
+
+ [Fact]
+ public void GetEmbeddedApphostPaths_ProducesPathsForEachRid()
+ {
+ var rids = new[]
+ {
+ new TaskItem("win-x64"),
+ new TaskItem("linux-x64"),
+ new TaskItem("osx-x64")
+ };
+
+ var task = new GetEmbeddedApphostPaths
+ {
+ BuildEngine = new MockBuildEngine(),
+ ToolCommandName = "mytool",
+ PackagedShimOutputDirectory = "shims",
+ ShimRuntimeIdentifiers = rids
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.EmbeddedApphostPaths.Should().HaveCount(3);
+ }
+
+ [Fact]
+ public void GetEmbeddedApphostPaths_WindowsRid_HasExeExtension()
+ {
+ var rids = new[] { new TaskItem("win-x64") };
+
+ var task = new GetEmbeddedApphostPaths
+ {
+ BuildEngine = new MockBuildEngine(),
+ ToolCommandName = "mytool",
+ PackagedShimOutputDirectory = "shims",
+ ShimRuntimeIdentifiers = rids
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.EmbeddedApphostPaths.Should().HaveCount(1);
+ task.EmbeddedApphostPaths[0].ItemSpec.Should().EndWith(".exe");
+ task.EmbeddedApphostPaths[0].GetMetadata("ShimRuntimeIdentifier").Should().Be("win-x64");
+ }
+
+ [Fact]
+ public void GetEmbeddedApphostPaths_LinuxRid_HasNoExtension()
+ {
+ var rids = new[] { new TaskItem("linux-x64") };
+
+ var task = new GetEmbeddedApphostPaths
+ {
+ BuildEngine = new MockBuildEngine(),
+ ToolCommandName = "mytool",
+ PackagedShimOutputDirectory = "shims",
+ ShimRuntimeIdentifiers = rids
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.EmbeddedApphostPaths[0].ItemSpec.Should().NotEndWith(".exe");
+ task.EmbeddedApphostPaths[0].ItemSpec.Should().EndWith("mytool");
+ }
+
+ [Fact]
+ public void GetEmbeddedApphostPaths_CombinesOutputDirRidAndToolName()
+ {
+ var rids = new[] { new TaskItem("win-x64") };
+
+ var task = new GetEmbeddedApphostPaths
+ {
+ BuildEngine = new MockBuildEngine(),
+ ToolCommandName = "mytool",
+ PackagedShimOutputDirectory = "output",
+ ShimRuntimeIdentifiers = rids
+ };
+
+ task.Execute();
+
+ var path = task.EmbeddedApphostPaths[0].ItemSpec;
+ path.Should().Contain("output");
+ path.Should().Contain("win-x64");
+ path.Should().Contain("mytool");
+ }
+
+ #endregion
+
+ #region GetNuGetShortFolderName
+
+ [Fact]
+ public void GetNuGetShortFolderName_ReturnsNet80()
+ {
+ var task = new GetNuGetShortFolderName
+ {
+ BuildEngine = new MockBuildEngine(),
+ TargetFrameworkMoniker = ".NETCoreApp,Version=v8.0"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.NuGetShortFolderName.Should().Be("net8.0");
+ }
+
+ [Fact]
+ public void GetNuGetShortFolderName_ReturnsNetStandard20()
+ {
+ var task = new GetNuGetShortFolderName
+ {
+ BuildEngine = new MockBuildEngine(),
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.NuGetShortFolderName.Should().Be("netstandard2.0");
+ }
+
+ [Fact]
+ public void GetNuGetShortFolderName_ReturnsNet472()
+ {
+ var task = new GetNuGetShortFolderName
+ {
+ BuildEngine = new MockBuildEngine(),
+ TargetFrameworkMoniker = ".NETFramework,Version=v4.7.2"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.NuGetShortFolderName.Should().Be("net472");
+ }
+
+ [Fact]
+ public void GetNuGetShortFolderName_WithPlatformMoniker_IncludesPlatform()
+ {
+ var task = new GetNuGetShortFolderName
+ {
+ BuildEngine = new MockBuildEngine(),
+ TargetFrameworkMoniker = ".NETCoreApp,Version=v8.0",
+ TargetPlatformMoniker = "Windows,Version=10.0.19041.0"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.NuGetShortFolderName.Should().Contain("windows");
+ }
+
+ #endregion
+
+ #region ProduceContentAssets
+
+ [Fact]
+ public void ProduceContentAssets_WithCompileAsset_ProducesContentItem()
+ {
+ var contentFile = new MockTaskItem("path/to/content.cs", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
+ ContentFileDependencies = new ITaskItem[] { contentFile },
+ ProjectLanguage = "C#"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.ProcessedContentItems.Should().HaveCount(1);
+ task.ProcessedContentItems[0].GetMetadata("ProcessedItemType").Should().Be("Compile");
+ }
+
+ [Fact]
+ public void ProduceContentAssets_WithNoneBuildAction_ProducesNoContentItem()
+ {
+ var contentFile = new MockTaskItem("path/to/readme.txt", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "None" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
+ ContentFileDependencies = new ITaskItem[] { contentFile },
+ ProjectLanguage = "C#"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.ProcessedContentItems.Should().BeEmpty(
+ "items with BuildAction=None should not produce content items");
+ }
+
+ [Fact]
+ public void ProduceContentAssets_WithCopyToOutput_ProducesCopyLocalItem()
+ {
+ var contentFile = new MockTaskItem("path/to/data.json", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Content" },
+ { "CodeLanguage", "any" },
+ { "CopyToOutput", "true" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "data.json" }
+ });
+
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
+ ContentFileDependencies = new ITaskItem[] { contentFile },
+ ProjectLanguage = "C#"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.CopyLocalItems.Should().HaveCount(1);
+ task.CopyLocalItems[0].GetMetadata("TargetPath").Should().Be("data.json");
+ }
+
+ [Fact]
+ public void ProduceContentAssets_FiltersLanguageSpecificAssets()
+ {
+ var csharpFile = new MockTaskItem("path/to/code.cs", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "cs" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var vbFile = new MockTaskItem("path/to/code.vb", new Dictionary
+ {
+ { "NuGetPackageId", "MyPackage" },
+ { "NuGetPackageVersion", "1.0.0" },
+ { "BuildAction", "Compile" },
+ { "CodeLanguage", "vb" },
+ { "CopyToOutput", "false" },
+ { "PPOutputPath", "" },
+ { "OutputPath", "" }
+ });
+
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
+ ContentFileDependencies = new ITaskItem[] { csharpFile, vbFile },
+ ProjectLanguage = "C#"
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.ProcessedContentItems.Should().HaveCount(1,
+ "only C# language-specific content should be included for a C# project");
+ }
+
+ [Fact]
+ public void ProduceContentAssets_EmptyDependencies_Succeeds()
+ {
+ var task = new ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ ContentFileDependencies = Array.Empty(),
+ ProjectLanguage = "C#",
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest()
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.ProcessedContentItems.Should().BeEmpty();
+ task.CopyLocalItems.Should().BeEmpty();
+ task.FileWrites.Should().BeEmpty();
+ }
+
+ #endregion
+
+ #region ResolveCopyLocalAssets
+
+ [Fact]
+ public void ResolveCopyLocalAssets_WithMinimalAssetsFile_Succeeds()
+ {
+ var projectDir = Path.Combine(Path.GetTempPath(), $"resolve-copy-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(projectDir);
+ 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 task = new ResolveCopyLocalAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir),
+ AssetsFilePath = assetsPath,
+ TargetFramework = ".NETCoreApp,Version=v8.0",
+ RuntimeIdentifier = "",
+ IsSelfContained = false,
+ ResolveRuntimeTargets = false
+ };
+
+ var result = task.Execute();
+
+ result.Should().BeTrue();
+ task.ResolvedAssets.Should().BeEmpty("no packages in the lock file means no assets");
+ }
+ finally
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ #endregion
+
+ #region Concurrent Execution
+
+ [Theory]
+ [InlineData(4)]
+ [InlineData(16)]
+ public async System.Threading.Tasks.Task GetDefaultPlatformTargetForNetFramework_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 GetDefaultPlatformTargetForNetFramework
+ {
+ BuildEngine = new MockBuildEngine(),
+ PackageDependencies = Array.Empty(),
+ NativeCopyLocalItems = Array.Empty()
+ };
+
+ startGate.Wait();
+ task.Execute();
+
+ if (task.DefaultPlatformTarget != "AnyCPU")
+ {
+ errors.Add($"Thread {idx}: Expected 'AnyCPU' but got '{task.DefaultPlatformTarget}'");
+ }
+ }
+ 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 GetEmbeddedApphostPaths_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 GetEmbeddedApphostPaths
+ {
+ BuildEngine = new MockBuildEngine(),
+ ToolCommandName = "mytool",
+ PackagedShimOutputDirectory = "shims",
+ ShimRuntimeIdentifiers = new ITaskItem[] { new TaskItem("win-x64") }
+ };
+
+ startGate.Wait();
+ task.Execute();
+
+ if (task.EmbeddedApphostPaths == null || task.EmbeddedApphostPaths.Length != 1)
+ {
+ errors.Add($"Thread {idx}: Expected 1 path but got {task.EmbeddedApphostPaths?.Length}");
+ }
+ }
+ 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 GetNuGetShortFolderName_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 GetNuGetShortFolderName
+ {
+ BuildEngine = new MockBuildEngine(),
+ TargetFrameworkMoniker = ".NETCoreApp,Version=v8.0"
+ };
+
+ startGate.Wait();
+ task.Execute();
+
+ if (task.NuGetShortFolderName != "net8.0")
+ {
+ errors.Add($"Thread {idx}: Expected 'net8.0' but got '{task.NuGetShortFolderName}'");
+ }
+ }
+ 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 ProduceContentAssets_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 ProduceContentAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ ContentFileDependencies = Array.Empty(),
+ ProjectLanguage = "C#",
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest()
+ };
+
+ 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 ResolveCopyLocalAssets_ConcurrentExecution(int parallelism)
+ {
+ var projectDir = Path.Combine(Path.GetTempPath(), $"resolve-copy-concurrent-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(projectDir);
+ 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 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 ResolveCopyLocalAssets
+ {
+ BuildEngine = new MockBuildEngine(),
+ AssetsFilePath = assetsPath,
+ TargetFramework = ".NETCoreApp,Version=v8.0",
+ RuntimeIdentifier = "",
+ IsSelfContained = false,
+ ResolveRuntimeTargets = false,
+ TaskEnvironment = TaskEnvironmentHelper.CreateForTest(projectDir)
+ };
+
+ 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();
+ }
+ finally
+ {
+ Directory.Delete(projectDir, true);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ProduceContentAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ProduceContentAssets.cs
index 387b5e836c16..ada31abb422d 100644
--- a/src/Tasks/Microsoft.NET.Build.Tasks/ProduceContentAssets.cs
+++ b/src/Tasks/Microsoft.NET.Build.Tasks/ProduceContentAssets.cs
@@ -14,13 +14,25 @@ namespace Microsoft.NET.Build.Tasks
/// other filtering on content assets, including whether they match the active
/// project language.
///
- public sealed class ProduceContentAssets : TaskBase
+ [MSBuildMultiThreadableTask]
+ public sealed class ProduceContentAssets : TaskBase, IMultiThreadableTask
{
private readonly List _contentItems = new();
private readonly List _fileWrites = new();
private readonly List _copyLocalItems = new();
private IContentAssetPreprocessor _assetPreprocessor;
+#if NETFRAMEWORK
+ private TaskEnvironment _taskEnvironment;
+ public TaskEnvironment TaskEnvironment
+ {
+ get => _taskEnvironment ??= TaskEnvironmentDefaults.Create();
+ set => _taskEnvironment = value;
+ }
+#else
+ public TaskEnvironment TaskEnvironment { get; set; }
+#endif
+
#region Output Items
///
@@ -141,7 +153,7 @@ protected override void ExecuteCore()
Log.LogWarning(Strings.DuplicatePreprocessorToken, duplicatedPreprocessorKey, preprocessorValues[duplicatedPreprocessorKey]);
}
- AssetPreprocessor.ConfigurePreprocessor(ContentPreprocessorOutputDirectory, preprocessorValues);
+ AssetPreprocessor.ConfigurePreprocessor(TaskEnvironment.GetAbsolutePath(ContentPreprocessorOutputDirectory), preprocessorValues);
}
var contentFileDeps = ContentFileDependencies ?? Enumerable.Empty();
@@ -193,7 +205,7 @@ private bool IsPreprocessorFile(ITaskItem contentFile) =>
private void ProduceContentAsset(ITaskItem contentFile)
{
- string resolvedPath = contentFile.ItemSpec;
+ string resolvedPath = TaskEnvironment.GetAbsolutePath(contentFile.ItemSpec);
string pathToFinalAsset = resolvedPath;
string ppOutputPath = contentFile.GetMetadata(MetadataKeys.PPOutputPath);
string packageName = contentFile.GetMetadata(MetadataKeys.NuGetPackageId);
diff --git a/src/Tasks/Microsoft.NET.Build.Tasks/ResolveCopyLocalAssets.cs b/src/Tasks/Microsoft.NET.Build.Tasks/ResolveCopyLocalAssets.cs
index 2533f79e9bf8..e1c14660cbd8 100644
--- a/src/Tasks/Microsoft.NET.Build.Tasks/ResolveCopyLocalAssets.cs
+++ b/src/Tasks/Microsoft.NET.Build.Tasks/ResolveCopyLocalAssets.cs
@@ -13,10 +13,22 @@ namespace Microsoft.NET.Build.Tasks
///
/// Resolves the assets from the package dependencies that should be copied to output/publish directories.
///
- public class ResolveCopyLocalAssets : TaskBase
+ [MSBuildMultiThreadableTask]
+ public class ResolveCopyLocalAssets : TaskBase, IMultiThreadableTask
{
private readonly List _resolvedAssets = 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]
@@ -44,7 +56,7 @@ public class ResolveCopyLocalAssets : TaskBase
protected override void ExecuteCore()
{
var lockFileCache = new LockFileCache(this);
- LockFile lockFile = lockFileCache.GetLockFile(AssetsFilePath);
+ LockFile lockFile = lockFileCache.GetLockFile(TaskEnvironment.GetAbsolutePath(AssetsFilePath));
HashSet packagestoBeFiltered = null;
if (RuntimeStorePackages != null && RuntimeStorePackages.Length > 0)