From 244ce414c490438858ef37417d01381c729d6e3d Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:48:13 +0200 Subject: [PATCH 1/9] Make MSBuild tasks safe for multi-threaded execution MSBuild 18.x can execute tasks on multiple threads inside a single node (`-mt`), but only for tasks that opt in via [MSBuildMultiThreadableTask]. Everything else is routed to an out-of-proc sidecar TaskHost, which is far more expensive than the in-proc path. Measuring a runtime inner-repo build in the VMR with `-mt -nodeReuse:true` against a baseline showed only a 3.6% wall-clock improvement, because none of Arcade's tasks were annotated: TaskHost task invocations went from 849 to 14,125 and total task time went *up* by 98 seconds, cancelling out most of the gain from threading. This annotates 133 of Arcade's 136 concrete tasks and makes the supporting code multi-threading safe: - Update the MSBuild package dependencies from 17.12.50 to 18.8.2, which is the first version exposing both [MSBuildMultiThreadableTask]/IMultiThreadableTask and ToolTask.TaskEnvironment. - Reference Microsoft.Build.TaskAuthoring.Analyzer from eng/BuildTask.targets so new violations are caught at build time. eng/MultiThreadableTaskAnalyzer.globalconfig scopes it to tasks that have already opted in, and disables the API-shape suggestions (MSBuildTask0006-0008, 0011) which would be binary-breaking for task parameters set from targets across the ecosystem. - Replace ambient process state with the injected TaskEnvironment: relative paths now resolve against the project directory instead of the process-wide current directory, environment variables are read per-project, and child processes are started from TaskEnvironment.GetProcessStartInfo(). - Thread AbsolutePath through the shared path helpers in Packaging, GenFacades, Feed, NuGetRepack, SharedFramework.Sdk, PackageTesting and XliffTasks. - Fix a real data race: TargetFrameworkResolver.CreateOrGet used an unsynchronized static Dictionary cache. It is now a ConcurrentDictionary. - Stop sharing a static Newtonsoft JsonSerializer instance in PackageIndex. - Delete the internal BuildTask/ILog base class. It only wrapped Microsoft.Build.Utilities.Task and TaskLoggingHelper, and standing between tasks and the MSBuild base class meant every derived task needed its own TaskEnvironment plumbing. Tasks now derive from Microsoft.Build.Utilities.Task directly and use Log/TaskLoggingHelper. SignToolTask, CreateVisualStudioWorkload and CreateVisualStudioWorkloadSet are deliberately left unannotated. Their helper chains (BatchSignUtil, ZipData, Configuration, VerifySignatures, WorkloadPackageBase, MsiBase, SwixProject, ...) still resolve paths against the process-wide current directory, and converting them is a much larger change. Each carries a TODO pointing at the tracking issue. Both run once per build, so keeping them TaskHost-routed costs effectively nothing. Contributes to https://github.com/dotnet/arcade/issues/17378 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + eng/BuildTask.targets | 13 ++ eng/MultiThreadableTaskAnalyzer.globalconfig | 17 ++ eng/Version.Details.props | 10 +- eng/Version.Details.xml | 20 ++- src/Common/Internal/BuildTask.cs | 153 ------------------ .../src/CalculateAssemblyAndFileVersions.cs | 1 + .../src/CheckRequiredDotNetVersion.cs | 24 ++- .../src/CompareVersions.cs | 1 + .../src/DownloadFile.cs | 16 +- .../src/ExtractNgenMethodList.cs | 14 +- .../src/GenerateChecksums.cs | 12 +- .../src/GenerateResxSource.cs | 10 +- ...erateSourcePackageSourceLinkTargetsFile.cs | 10 +- .../src/GetAssemblyFullName.cs | 1 + .../src/GetLicenseFilePath.cs | 8 +- .../src/GroupItemsBy.cs | 1 + .../src/InstallDotNetCore.cs | 47 +++--- .../src/LocateDotNet.cs | 30 ++-- .../src/SaveItems.cs | 13 +- .../src/SetCorFlags.cs | 8 +- .../src/SingleError.cs | 1 + src/Microsoft.DotNet.Arcade.Sdk/src/Unsign.cs | 8 +- .../src/ValidateLicense.cs | 10 +- .../src/AzureStorageAssetPublisher.cs | 2 +- .../src/AzureStorageExtensions.cs | 4 +- .../src/BlobFeedAction.cs | 2 +- .../src/ConfigureInputFeed.cs | 10 +- .../src/CreateAzureDevOpsFeed.cs | 1 + .../src/LaunchDebugger.cs | 2 + .../src/PublishArtifactsInManifest.cs | 15 +- .../src/PublishArtifactsInManifestV3.cs | 3 + .../src/PublishArtifactsInManifestV4.cs | 3 + .../src/PublishBuildToMaestro.cs | 25 +-- .../src/PublishSignedAssets.cs | 15 +- .../src/PushToBuildStorage.cs | 1 + .../src/common/AzureStorageUtils.cs | 2 +- .../common/CreateAzureContainerIfNotExists.cs | 2 + .../src/common/CreateNewAzureContainer.cs | 2 + .../src/common/UploadToAzure.cs | 12 +- .../CatalogTests.cs | 3 +- .../CatalogBuilder.cs | 5 +- .../CatalogEntry.cs | 3 +- .../GenerateFileCatalog.cs | 19 ++- ...osoft.DotNet.Build.Tasks.Installers.csproj | 1 - .../src/CreateChangelogFile.cs | 8 +- .../src/CreateControlFile.cs | 8 +- .../src/CreateDebPackage.cs | 12 +- .../src/CreateLightCommandPackageDrop.cs | 11 +- .../src/CreateMD5SumsFile.cs | 10 +- .../src/CreateRpmPackage.cs | 16 +- .../src/CreateWixBuildWixpack.cs | 92 ++++++----- .../src/CreateWixCommandPackageDropBase.cs | 4 +- .../src/ExecWithRetries.cs | 5 +- .../src/GenerateCurrentVersion.cs | 3 +- .../src/GenerateGuidFromName.cs | 3 +- .../src/GenerateMacOSDistributionFile.cs | 10 +- .../src/GenerateMsiVersion.cs | 3 +- .../src/StabilizeWixFileId.cs | 10 +- .../src/ApplyBaseLine.cs | 8 +- .../src/ApplyMetaPackages.cs | 8 +- .../src/ApplyPreReleaseSuffix.cs | 8 +- .../src/CreateTrimDependencyGroups.cs | 8 +- .../src/FilterUnknownPackages.cs | 8 +- .../src/GenerateNuSpec.cs | 18 ++- .../src/GeneratePackageReport.cs | 10 +- .../src/GenerateRuntimeDependencies.cs | 16 +- .../GetApplicableAssetsFromPackageReports.cs | 8 +- .../src/GetApplicableAssetsFromPackages.cs | 8 +- .../src/GetAssemblyReferences.cs | 10 +- .../src/GetInboxFrameworks.cs | 10 +- .../src/GetLastStablePackage.cs | 8 +- .../src/GetLayoutFiles.cs | 16 +- .../src/GetMinimumNETStandard.cs | 3 +- .../src/GetPackageDescription.cs | 24 ++- .../src/GetPackageDestination.cs | 3 +- .../src/GetPackageFromModule.cs | 8 +- .../src/GetPackageVersion.cs | 3 +- .../src/GetRuntimeJsonValues.cs | 10 +- .../src/GetRuntimeTargets.cs | 10 +- .../GetSupportedPackagesFromPackageReports.cs | 8 +- .../src/HarvestPackage.cs | 40 +++-- ...rosoft.DotNet.Build.Tasks.Packaging.csproj | 1 - .../src/NuGetAssetResolver.cs | 6 +- .../src/NuGetPack.cs | 26 +-- .../src/NuGetUtility.cs | 11 +- .../src/PackageIndex.cs | 35 ++-- .../src/PackageItem.cs | 6 +- .../src/PackageMetadata.cs | 3 +- .../src/PackageReport.cs | 4 +- .../src/PromoteDependencies.cs | 12 +- .../src/SplitDependenciesBySupport.cs | 3 +- .../src/SplitReferences.cs | 8 +- .../src/UpdatePackageIndex.cs | 24 +-- .../src/ValidateFrameworkPackage.cs | 5 +- ...alidateHarvestVersionIsLatestForRelease.cs | 16 +- .../src/ValidatePackage.cs | 4 +- .../src/ValidationTask.cs | 9 +- .../src/VersionUtility.cs | 3 +- .../tests/CreateTrimDependencyGroupsTests.cs | 7 +- .../tests/Log.cs | 6 +- .../tests/PackageIndexTests.cs | 5 +- .../tests/RuntimeGraphTests.cs | 3 +- .../tests/TestBuildEngine.cs | 7 +- ...teHarvestVersionIsLatestForReleaseTests.cs | 2 +- .../src/ChooseBestP2PTargetFrameworkTask.cs | 5 +- .../src/ChooseBestTargetFrameworksTask.cs | 3 +- ....DotNet.Build.Tasks.TargetFramework.csproj | 4 - .../src/TargetFrameworkResolver.cs | 35 ++-- .../src/GenerateFileFromTemplate.cs | 20 ++- .../OptProf/FindLatestDrop.cs | 8 +- .../OptProf/GenerateTrainingInputFiles.cs | 22 +-- .../OptProf/GenerateTrainingPropsFile.cs | 10 +- .../GetRunSettingsSessionConfiguration.cs | 10 +- .../Vsix/FinalizeInsertionVsixFile.cs | 12 +- .../Vsix/GetPkgDefAssemblyDependencyGuid.cs | 1 + .../src/CreateVisualStudioWorkload.wix.cs | 16 +- .../src/CreateVisualStudioWorkloadSet.wix.cs | 7 + ...rosoft.DotNet.Build.Tasks.Workloads.csproj | 1 - .../src/Wix/HarvesterToolTask.cs | 1 + .../src/Wix/WixToolTask.cs | 1 + .../src/Wix/WixToolTaskBase.cs | 2 +- .../Microsoft.DotNet.CMake.Sdk.csproj | 4 - .../src/CreateCMakeFileApiQuery.cs | 13 +- .../src/GetCMakeArtifactsFromFileApi.cs | 41 ++--- .../src/CreateAkaMSLinks.cs | 1 + .../src/DeleteAkaMSLinks.cs | 1 + src/Microsoft.DotNet.GenAPI/GenAPITask.cs | 69 ++++++-- .../Microsoft.DotNet.GenAPI.csproj | 4 - .../ClearAssemblyReferenceVersions.cs | 8 +- .../GenPartialFacadeSource.cs | 17 +- .../GenPartialFacadeSourceGenerator.cs | 24 +-- .../Microsoft.DotNet.GenFacades.csproj | 4 - .../NotSupportedAssemblyGenerator.cs | 21 ++- .../RoslynBuildTask.cs | 2 +- .../SourceGenerator.cs | 10 +- src/Microsoft.DotNet.GenFacades/TypeParser.cs | 8 +- .../Sdk/AzureDevOpsTask.cs | 13 +- .../Sdk/CancelHelixJob.cs | 1 + .../Sdk/CheckAzurePipelinesTestResults.cs | 1 + .../Sdk/CheckHelixJobStatus.cs | 1 + .../CreateFailedTestsForFailedWorkItems.cs | 1 + .../Sdk/CreateMTPWorkItems.cs | 1 + .../Sdk/CreateXHarnessAndroidWorkItems.cs | 1 + .../Sdk/CreateXHarnessAppleWorkItems.cs | 1 + .../Sdk/CreateXUnitWorkItems.cs | 1 + .../Sdk/DownloadFromResultsContainer.cs | 10 +- .../Sdk/FindDotNetCliPackage.cs | 3 +- .../Sdk/GetHelixWorkItems.cs | 1 + .../Sdk/InstallDotNetTool.cs | 1 + .../Sdk/SendHelixJob.cs | 12 +- .../Sdk/StartAzurePipelinesTestRun.cs | 1 + .../Sdk/StopAzurePipelinesTestRun.cs | 1 + .../Sdk/WaitForHelixJobCompletion.cs | 1 + .../tasks/src/NuGetVersionUpdater.cs | 30 ++-- .../tasks/src/ReplacePackageParts.cs | 22 ++- .../tasks/src/UpdatePackageVersionTask.cs | 15 +- .../tests/VersionUpdaterTests.cs | 19 +-- ...CompatibilePackageTargetFrameworksTests.cs | 8 +- .../GetCompatiblePackageTargetFrameworks.cs | 12 +- .../Microsoft.DotNet.PackageTesting.csproj | 4 - .../VerifyClosure.cs | 17 +- .../VerifyTypes.cs | 11 +- ...icrosoft.DotNet.SharedFramework.Sdk.csproj | 1 - .../src/CreateFrameworkListFile.cs | 15 +- .../src/FileUtilities.cs | 4 +- ...ratePlatformManifestEntriesFromFileList.cs | 11 +- ...ratePlatformManifestEntriesFromTemplate.cs | 11 +- .../src/GenerateSharedFrameworkDepsFile.cs | 17 +- .../src/ValidateFileVersions.cs | 11 +- .../SignCheckTask.cs | 14 +- .../src/SignToolTask.cs | 23 ++- .../tasks/src/ReadNuGetPackageInfos.cs | 9 +- .../src/UsageReport/WritePackageUsageData.cs | 70 ++++++-- .../src/UsageReport/WriteUsageReports.cs | 22 +-- .../tasks/src/WriteBuildOutputProps.cs | 16 +- .../GenerateSwaggerCode.cs | 8 +- .../Model/Document.cs | 6 +- .../Tasks/EnsureAllResourcesTranslated.cs | 3 +- .../Tasks/GatherTranslatedSource.cs | 5 +- .../Tasks/GatherXlf.cs | 1 + .../Tasks/SortXlf.cs | 7 +- .../Tasks/TransformTemplates.cs | 15 +- .../Tasks/TranslateSource.cs | 11 +- .../Tasks/UpdateXlf.cs | 15 +- .../Tasks/XlfTask.cs | 10 +- 186 files changed, 1271 insertions(+), 823 deletions(-) create mode 100644 eng/MultiThreadableTaskAnalyzer.globalconfig delete mode 100644 src/Common/Internal/BuildTask.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9b0ce007bc5..3532955597e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/eng/BuildTask.targets b/eng/BuildTask.targets index 49ac256b029..09019d1a110 100644 --- a/eng/BuildTask.targets +++ b/eng/BuildTask.targets @@ -38,6 +38,19 @@ + + + + + + + diff --git a/eng/MultiThreadableTaskAnalyzer.globalconfig b/eng/MultiThreadableTaskAnalyzer.globalconfig new file mode 100644 index 00000000000..7a66c6d58fa --- /dev/null +++ b/eng/MultiThreadableTaskAnalyzer.globalconfig @@ -0,0 +1,17 @@ +# Configuration for Microsoft.Build.TaskAuthoring.Analyzer (referenced from eng/BuildTask.targets). +# +# Arcade's tasks are being migrated to MSBuild's multithreaded execution model incrementally +# (see https://github.com/dotnet/arcade/issues/17378). Scoping the analyzer to tasks that have +# already opted in via [MSBuildMultiThreadableTask] keeps it as a regression guard for migrated +# tasks without drowning the build in diagnostics for the ones still to be migrated. +is_global = true + +msbuild_task_analyzer.scope = multithreadable_only + +# API-shape suggestions (typed path parameters, ITaskItem, constructor injection). Useful when +# authoring a new task, but not worth churning the existing task surface over, and several of them +# would be binary-breaking for tasks whose parameters are set from targets across the ecosystem. +dotnet_diagnostic.MSBuildTask0006.severity = none +dotnet_diagnostic.MSBuildTask0007.severity = none +dotnet_diagnostic.MSBuildTask0008.severity = none +dotnet_diagnostic.MSBuildTask0011.severity = none diff --git a/eng/Version.Details.props b/eng/Version.Details.props index c04c0dd321a..c607cfbbbca 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -20,10 +20,11 @@ This file should be imported by eng/Versions.props 1.1.0-beta.26407.1 - 17.12.50 - 17.12.50 - 17.12.50 - 17.12.50 + 18.8.2 + 18.8.2 + 18.8.2 + 18.8.2 + 18.11.0-1.26420.118 4.8.0 4.8.0 @@ -81,6 +82,7 @@ This file should be imported by eng/Versions.props $(MicrosoftBuildFrameworkPackageVersion) $(MicrosoftBuildTasksCorePackageVersion) $(MicrosoftBuildUtilitiesCorePackageVersion) + $(MicrosoftBuildTaskAuthoringAnalyzerPackageVersion) $(MicrosoftCodeAnalysisCSharpPackageVersion) $(MicrosoftNetCompilersToolsetPackageVersion) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index e5f772e4356..edb63e62635 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -129,21 +129,25 @@ eb583d0664328fb0d370586c4d4e974212687cb0 - + https://github.com/dotnet/msbuild - d1cce8d7cc03c23a4f1bad8e9240714fd9d199a3 + ce25c01082c9c46cd02ad1ff3ff8f16fe5cc2f44 - + https://github.com/dotnet/msbuild - d1cce8d7cc03c23a4f1bad8e9240714fd9d199a3 + ce25c01082c9c46cd02ad1ff3ff8f16fe5cc2f44 - + https://github.com/dotnet/msbuild - d1cce8d7cc03c23a4f1bad8e9240714fd9d199a3 + ce25c01082c9c46cd02ad1ff3ff8f16fe5cc2f44 - + https://github.com/dotnet/msbuild - d1cce8d7cc03c23a4f1bad8e9240714fd9d199a3 + ce25c01082c9c46cd02ad1ff3ff8f16fe5cc2f44 + + + https://github.com/dotnet/msbuild + ce25c01082c9c46cd02ad1ff3ff8f16fe5cc2f44 diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs index c323d13fe34..1f3d814807d 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs @@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Build.Tasks.Workloads.Wix /// /// A tool task to invoke the WiX harvesting tool (heat.exe). /// + [MSBuildMultiThreadableTask] public class HarvesterToolTask : WixToolTaskBase { private static readonly Dictionary s_SuppressionArguments = new() diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs index dbcb3bca411..9bee4606fb8 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs @@ -13,6 +13,7 @@ namespace Microsoft.DotNet.Build.Tasks.Workloads.Wix /// /// Tool task for invoking the WiX CLI (version 5 and above). This commands is responsible for compiling and linking. /// + [MSBuildMultiThreadableTask] public class WixToolTask : WixToolTaskBase { private List _sourceFiles = new(); diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTaskBase.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTaskBase.cs index a6219925652..ec10992c4ba 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTaskBase.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTaskBase.cs @@ -39,7 +39,7 @@ protected WixToolTaskBase(IBuildEngine engine, string toolPath) { BuildEngine = engine ?? throw new ArgumentNullException(nameof(engine)); - if (!File.Exists(toolPath)) + if (!File.Exists(TaskEnvironment.GetAbsolutePath(toolPath))) { throw new FileNotFoundException("The specified tool executable was not found.", toolPath); } diff --git a/src/Microsoft.DotNet.CMake.Sdk/Microsoft.DotNet.CMake.Sdk.csproj b/src/Microsoft.DotNet.CMake.Sdk/Microsoft.DotNet.CMake.Sdk.csproj index add99f50b33..de7f997fcd7 100644 --- a/src/Microsoft.DotNet.CMake.Sdk/Microsoft.DotNet.CMake.Sdk.csproj +++ b/src/Microsoft.DotNet.CMake.Sdk/Microsoft.DotNet.CMake.Sdk.csproj @@ -14,8 +14,4 @@ - - - - diff --git a/src/Microsoft.DotNet.CMake.Sdk/src/CreateCMakeFileApiQuery.cs b/src/Microsoft.DotNet.CMake.Sdk/src/CreateCMakeFileApiQuery.cs index 56e6fe8abbc..d97465e849b 100644 --- a/src/Microsoft.DotNet.CMake.Sdk/src/CreateCMakeFileApiQuery.cs +++ b/src/Microsoft.DotNet.CMake.Sdk/src/CreateCMakeFileApiQuery.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using System; using System.IO; @@ -12,8 +11,12 @@ namespace Microsoft.DotNet.CMake.Sdk /// /// Creates a CMake File API query file to request codemodel information. /// - public class CreateCMakeFileApiQuery : BuildTask + [MSBuildMultiThreadableTask] + public class CreateCMakeFileApiQuery : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// The CMake build output directory where the query should be created. /// @@ -26,14 +29,14 @@ public override bool Execute() { // Create a client stateless query file with client name "Microsoft.DotNet.CMake.Sdk" string queryDir = Path.Combine(CMakeOutputDir, ".cmake", "api", "v1", "query", "client-Microsoft.DotNet.CMake.Sdk"); - Directory.CreateDirectory(queryDir); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(queryDir)); string queryFile = Path.Combine(queryDir, "codemodel-v2"); // Create an empty file to request codemodel-v2 information - File.WriteAllText(queryFile, string.Empty); + File.WriteAllText(TaskEnvironment.GetAbsolutePath(queryFile), string.Empty); - Log.LogMessage(LogImportance.Low, "Created CMake File API query at: {0}", queryFile); + Log.LogMessage(MessageImportance.Low, "Created CMake File API query at: {0}", queryFile); return true; } diff --git a/src/Microsoft.DotNet.CMake.Sdk/src/GetCMakeArtifactsFromFileApi.cs b/src/Microsoft.DotNet.CMake.Sdk/src/GetCMakeArtifactsFromFileApi.cs index 8baeea06470..0442607b20a 100644 --- a/src/Microsoft.DotNet.CMake.Sdk/src/GetCMakeArtifactsFromFileApi.cs +++ b/src/Microsoft.DotNet.CMake.Sdk/src/GetCMakeArtifactsFromFileApi.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using System; using System.Collections.Generic; using System.IO; @@ -15,8 +14,12 @@ namespace Microsoft.DotNet.CMake.Sdk /// /// Reads CMake File API response to find artifacts for a specific source directory. /// - public class GetCMakeArtifactsFromFileApi : BuildTask + [MSBuildMultiThreadableTask] + public class GetCMakeArtifactsFromFileApi : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// The CMake build output directory containing the File API response. /// @@ -47,14 +50,14 @@ public override bool Execute() { string replyDir = Path.Combine(CMakeOutputDir, ".cmake", "api", "v1", "reply"); - if (!Directory.Exists(replyDir)) + if (!Directory.Exists(TaskEnvironment.GetAbsolutePath(replyDir))) { Log.LogError("CMake File API reply directory does not exist: {0}", replyDir); return false; } // Find the latest index file - var indexFiles = Directory.GetFiles(replyDir, "index-*.json"); + var indexFiles = Directory.GetFiles(TaskEnvironment.GetAbsolutePath(replyDir), "index-*.json"); if (indexFiles.Length == 0) { Log.LogError("No CMake File API index files found."); @@ -62,9 +65,9 @@ public override bool Execute() } string indexFile = indexFiles.OrderByDescending(f => f).First(); - Log.LogMessage(LogImportance.Low, "Reading CMake File API index: {0}", indexFile); + Log.LogMessage(MessageImportance.Low, "Reading CMake File API index: {0}", indexFile); - string indexJson = File.ReadAllText(indexFile); + string indexJson = File.ReadAllText(TaskEnvironment.GetAbsolutePath(indexFile)); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, @@ -80,15 +83,15 @@ public override bool Execute() } string codeModelFile = Path.Combine(replyDir, index.Reply.ClientReply.CodemodelV2.JsonFile); - if (!File.Exists(codeModelFile)) + if (!File.Exists(TaskEnvironment.GetAbsolutePath(codeModelFile))) { Log.LogError("Codemodel file not found: {0}", codeModelFile); return false; } - Log.LogMessage(LogImportance.Low, "Reading codemodel: {0}", codeModelFile); + Log.LogMessage(MessageImportance.Low, "Reading codemodel: {0}", codeModelFile); - string codeModelJson = File.ReadAllText(codeModelFile); + string codeModelJson = File.ReadAllText(TaskEnvironment.GetAbsolutePath(codeModelFile)); var codeModel = JsonSerializer.Deserialize(codeModelJson, options); if (codeModel == null) @@ -101,7 +104,7 @@ public override bool Execute() string sourceRoot = codeModel.Paths?.Source?.Replace('\\', '/').TrimEnd('/') ?? ""; // Normalize source directory for comparison - string normalizedSourceDir = Path.GetFullPath(SourceDirectory).Replace('\\', '/').TrimEnd('/'); + string normalizedSourceDir = TaskEnvironment.GetAbsolutePath(SourceDirectory).Value.Replace('\\', '/').TrimEnd('/'); // Find the configuration using LINQ var config = codeModel.Configurations?.FirstOrDefault(c => @@ -113,7 +116,7 @@ public override bool Execute() return false; } - Log.LogMessage(LogImportance.Low, "Found configuration: {0}", Configuration); + Log.LogMessage(MessageImportance.Low, "Found configuration: {0}", Configuration); if (config.Directories == null || config.Targets == null) { @@ -130,7 +133,7 @@ public override bool Execute() if (!Path.IsPathRooted(dirSource)) { dirSource = Path.Combine(sourceRoot, dirSource); - dirSource = Path.GetFullPath(dirSource).Replace('\\', '/').TrimEnd('/'); + dirSource = TaskEnvironment.GetAbsolutePath(dirSource).Value.Replace('\\', '/').TrimEnd('/'); } return string.Equals(dirSource, normalizedSourceDir, StringComparison.OrdinalIgnoreCase); @@ -142,7 +145,7 @@ public override bool Execute() return false; } - Log.LogMessage(LogImportance.Low, "Found matching directory: {0}", SourceDirectory); + Log.LogMessage(MessageImportance.Low, "Found matching directory: {0}", SourceDirectory); // Get artifacts var artifacts = new List(); @@ -163,15 +166,15 @@ public override bool Execute() } string targetFile = Path.Combine(replyDir, target.JsonFile); - if (!File.Exists(targetFile)) + if (!File.Exists(TaskEnvironment.GetAbsolutePath(targetFile))) { continue; } - Log.LogMessage(LogImportance.Low, "Reading target file: {0}", targetFile); + Log.LogMessage(MessageImportance.Low, "Reading target file: {0}", targetFile); // Read target details - string targetJson = File.ReadAllText(targetFile); + string targetJson = File.ReadAllText(TaskEnvironment.GetAbsolutePath(targetFile)); var targetDetails = JsonSerializer.Deserialize(targetJson, options); // Get artifacts @@ -182,12 +185,12 @@ public override bool Execute() if (!string.IsNullOrEmpty(artifact.Path)) { string fullPath = Path.Combine(CMakeOutputDir, artifact.Path); - fullPath = Path.GetFullPath(fullPath); + fullPath = TaskEnvironment.GetAbsolutePath(fullPath); var item = new TaskItem(fullPath); artifacts.Add(item); - Log.LogMessage(LogImportance.Low, "Found artifact: {0}", fullPath); + Log.LogMessage(MessageImportance.Low, "Found artifact: {0}", fullPath); } } } @@ -200,7 +203,7 @@ public override bool Execute() } Artifacts = artifacts.ToArray(); - Log.LogMessage(LogImportance.Normal, "Found {0} artifact(s) for source directory '{1}' in configuration '{2}'", Artifacts.Length, SourceDirectory, Configuration); + Log.LogMessage(MessageImportance.Normal, "Found {0} artifact(s) for source directory '{1}' in configuration '{2}'", Artifacts.Length, SourceDirectory, Configuration); return true; } diff --git a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/CreateAkaMSLinks.cs b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/CreateAkaMSLinks.cs index ebae7fc0958..ae5a7c68d24 100644 --- a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/CreateAkaMSLinks.cs +++ b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/CreateAkaMSLinks.cs @@ -12,6 +12,7 @@ namespace Microsoft.DotNet.Deployment.Tasks.Links /// /// Creates or updates, in bulk, a set of aka.ms (redirection) links /// + [MSBuildMultiThreadableTask] public class CreateAkaMSLinks : AkaMSLinksBase { /// diff --git a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/DeleteAkaMSLinks.cs b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/DeleteAkaMSLinks.cs index b123f3326ab..67c0d95b032 100644 --- a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/DeleteAkaMSLinks.cs +++ b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/DeleteAkaMSLinks.cs @@ -8,6 +8,7 @@ namespace Microsoft.DotNet.Deployment.Tasks.Links { + [MSBuildMultiThreadableTask] public class DeleteAkaMSLinks : AkaMSLinksBase { /// diff --git a/src/Microsoft.DotNet.GenAPI/GenAPITask.cs b/src/Microsoft.DotNet.GenAPI/GenAPITask.cs index 8df763b2cf6..507ed99004a 100644 --- a/src/Microsoft.DotNet.GenAPI/GenAPITask.cs +++ b/src/Microsoft.DotNet.GenAPI/GenAPITask.cs @@ -7,7 +7,6 @@ using System.IO; using System.Linq; using Microsoft.Build.Framework; -using Microsoft.DotNet.Build.Tasks; using Microsoft.Cci; using Microsoft.Cci.Extensions; using Microsoft.Cci.Extensions.CSharp; @@ -15,10 +14,13 @@ using Microsoft.Cci.Writers; using Microsoft.Cci.Writers.CSharp; using Microsoft.Cci.Writers.Syntax; +using System.Text; +using Microsoft.Build.Utilities; namespace Microsoft.DotNet.GenAPI { - public class GenAPITask : BuildTask + [MSBuildMultiThreadableTask] + public class GenAPITask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private const string InternalsVisibleTypeName = "System.Runtime.CompilerServices.InternalsVisibleToAttribute"; private const string DefaultFileHeader = @@ -36,6 +38,9 @@ public class GenAPITask : BuildTask private SyntaxWriterType _syntaxWriterType; private DocIdKinds _docIdKinds = Cci.Writers.DocIdKinds.All; + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Path for an specific assembly or a directory to get all assemblies. /// @@ -196,7 +201,7 @@ public override bool Execute() } string headerText = GetHeaderText(HeaderFile, _writerType, _syntaxWriterType); - bool loopPerAssembly = Directory.Exists(OutputPath); + bool loopPerAssembly = !string.IsNullOrEmpty(OutputPath) && Directory.Exists(TaskEnvironment.GetAbsolutePath(OutputPath)); if (loopPerAssembly) { @@ -259,11 +264,11 @@ public override bool Execute() return !Log.HasLoggedErrors; } - private static string GetHeaderText(string headerFile, WriterType writerType, SyntaxWriterType syntaxWriterType) + private string GetHeaderText(string headerFile, WriterType writerType, SyntaxWriterType syntaxWriterType) { if (!string.IsNullOrEmpty(headerFile)) { - return File.ReadAllText(headerFile); + return File.ReadAllText(TaskEnvironment.GetAbsolutePath(headerFile)); } string defaultHeader = string.Empty; @@ -279,18 +284,60 @@ private static string GetHeaderText(string headerFile, WriterType writerType, Sy return defaultHeader; } - private static TextWriter GetOutput(string outFilePath, string filename = "") + private TextWriter GetOutput(string outFilePath, string filename = "") { - // If this is a null, empty, whitespace, or a directory use console + // If this is a null, empty, whitespace, or a directory write to the build log if (string.IsNullOrWhiteSpace(outFilePath)) - return Console.Out; + return new LogTextWriter(Log); + + if (Directory.Exists(TaskEnvironment.GetAbsolutePath(outFilePath)) && !string.IsNullOrEmpty(filename)) + { + return File.CreateText(TaskEnvironment.GetAbsolutePath(Path.Combine(outFilePath, filename))); + } + + return File.CreateText(TaskEnvironment.GetAbsolutePath(outFilePath)); + } + + /// + /// Forwards writes to the build log so that generated output is captured by MSBuild's + /// loggers rather than written to the process-wide console. + /// + private sealed class LogTextWriter : TextWriter + { + private readonly TaskLoggingHelper _log; + private readonly StringBuilder _line = new StringBuilder(); + + public LogTextWriter(TaskLoggingHelper log) => _log = log; + + public override Encoding Encoding => Encoding.UTF8; - if (Directory.Exists(outFilePath) && !string.IsNullOrEmpty(filename)) + public override void Write(char value) { - return File.CreateText(Path.Combine(outFilePath, filename)); + if (value == '\n') + { + Flush(); + } + else if (value != '\r') + { + _line.Append(value); + } + } + + public override void Flush() + { + _log.LogMessage(MessageImportance.High, _line.ToString()); + _line.Clear(); } - return File.CreateText(outFilePath); + protected override void Dispose(bool disposing) + { + if (disposing && _line.Length > 0) + { + Flush(); + } + + base.Dispose(disposing); + } } private static string GetFilename(IAssembly assembly, WriterType writer, SyntaxWriterType syntax) diff --git a/src/Microsoft.DotNet.GenAPI/Microsoft.DotNet.GenAPI.csproj b/src/Microsoft.DotNet.GenAPI/Microsoft.DotNet.GenAPI.csproj index 4cef61a930c..0efb0ad179a 100644 --- a/src/Microsoft.DotNet.GenAPI/Microsoft.DotNet.GenAPI.csproj +++ b/src/Microsoft.DotNet.GenAPI/Microsoft.DotNet.GenAPI.csproj @@ -10,10 +10,6 @@ $(NoWarn);0436 - - - - diff --git a/src/Microsoft.DotNet.GenFacades/ClearAssemblyReferenceVersions.cs b/src/Microsoft.DotNet.GenFacades/ClearAssemblyReferenceVersions.cs index 73fe72c35c4..a883a2cfc4b 100644 --- a/src/Microsoft.DotNet.GenFacades/ClearAssemblyReferenceVersions.cs +++ b/src/Microsoft.DotNet.GenFacades/ClearAssemblyReferenceVersions.cs @@ -15,8 +15,12 @@ namespace Microsoft.DotNet.GenFacades /// /// Rewrites an Assembly's references to be version 0.0.0.0. /// - public class ClearAssemblyReferenceVersions : BuildTask + [MSBuildMultiThreadableTask] + public class ClearAssemblyReferenceVersions : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Assembly to rewrite. /// @@ -27,7 +31,7 @@ public override bool Execute() { try { - using (FileStream stream = File.Open(Assembly, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) + using (FileStream stream = File.Open(TaskEnvironment.GetAbsolutePath(Assembly), FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) using (PEReader peReader = new PEReader(stream)) { using (BinaryWriter writer = new BinaryWriter(stream)) diff --git a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs index a4b613890bb..6e2814b4a07 100644 --- a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs +++ b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs @@ -9,8 +9,15 @@ namespace Microsoft.DotNet.GenFacades { - public class GenPartialFacadeSource : RoslynBuildTask + // TODO: Not opted into multithreading. RoslynBuildTask.Execute subscribes every instance to the + // process-wide AssemblyLoadContext.Resolving event, so with differing RoslynAssembliesPath values + // one instance can satisfy another instance's resolution. The TaskEnvironment below is still used + // for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + public class GenPartialFacadeSource : RoslynBuildTask, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] ReferencePaths { get; set; } @@ -40,12 +47,12 @@ public override bool ExecuteCore() try { result = GenPartialFacadeSourceGenerator.Execute( - ReferencePaths?.Select(item => item.ItemSpec).ToArray(), - ReferenceAssembly, - CompileFiles?.Select(item => item.ItemSpec).ToArray(), + ReferencePaths?.Select(item => TaskEnvironment.GetAbsolutePath(item.ItemSpec)).ToArray(), + TaskEnvironment.GetAbsolutePath(ReferenceAssembly), + CompileFiles?.Select(item => TaskEnvironment.GetAbsolutePath(item.ItemSpec)).ToArray(), DefineConstants, LangVersion, - OutputSourcePath, + TaskEnvironment.GetAbsolutePath(OutputSourcePath), Log, IgnoreMissingTypes, IgnoreMissingTypesList, diff --git a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSourceGenerator.cs b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSourceGenerator.cs index 1ec90b35153..069567a41c5 100644 --- a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSourceGenerator.cs +++ b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSourceGenerator.cs @@ -17,13 +17,13 @@ namespace Microsoft.DotNet.GenFacades public class GenPartialFacadeSourceGenerator { public static bool Execute( - string[] seeds, - string contractAssembly, - string[] compileFiles, + Microsoft.Build.Framework.AbsolutePath[] seeds, + Microsoft.Build.Framework.AbsolutePath contractAssembly, + Microsoft.Build.Framework.AbsolutePath[] compileFiles, string defineConstants, string langVersion, - string outputSourcePath, - ILog logger, + Microsoft.Build.Framework.AbsolutePath outputSourcePath, + TaskLoggingHelper logger, bool ignoreMissingTypes = false, string[] ignoreMissingTypesList = null, string[] OmitTypes = null, @@ -34,8 +34,8 @@ public static bool Execute( IEnumerable referenceTypes = GetPublicVisibleTypes(contractAssembly, includeTypeForwards: true); // Normalizing and Removing Relative Segments from the seed paths. - string[] distinctSeeds = seeds.Select(seed => Path.GetFullPath(seed)).Distinct().ToArray(); - string[] seedNames = distinctSeeds.Select(seed => Path.GetFileName(seed)).ToArray(); + Microsoft.Build.Framework.AbsolutePath[] distinctSeeds = seeds.Distinct().ToArray(); + string[] seedNames = distinctSeeds.Select(seed => Path.GetFileName(seed.Value)).ToArray(); if (distinctSeeds.Count() != seedNames.Distinct(StringComparer.InvariantCultureIgnoreCase).Count()) { @@ -61,7 +61,7 @@ private static IEnumerable ParseDefineConstants(string defineConstants) return defineConstants?.Split(';', ',').Where(t => !string.IsNullOrEmpty(t)).ToArray(); } - private static Dictionary ParseSeedTypePreferences(ITaskItem[] preferences, ILog logger) + private static Dictionary ParseSeedTypePreferences(ITaskItem[] preferences, TaskLoggingHelper logger) { var dictionary = new Dictionary(StringComparer.Ordinal); @@ -93,7 +93,7 @@ private static Dictionary ParseSeedTypePreferences(ITaskItem[] p return dictionary; } - private static IEnumerable GetPublicVisibleTypes(string assembly, bool includeTypeForwards = false) + private static IEnumerable GetPublicVisibleTypes(Microsoft.Build.Framework.AbsolutePath assembly, bool includeTypeForwards = false) { using (var peReader = new PEReader(new FileStream(assembly, FileMode.Open, FileAccess.Read, FileShare.Delete | FileShare.Read))) { @@ -140,15 +140,15 @@ private static bool IsPublic(TypeDefinition typeDefination) return (typeDefination.Attributes & TypeAttributes.Public) != 0; } - private static IReadOnlyDictionary> GenerateTypeTable(IEnumerable seedAssemblies) + private static IReadOnlyDictionary> GenerateTypeTable(IEnumerable seedAssemblies) { var typeTable = new Dictionary>(); - foreach(string assembly in seedAssemblies) + foreach(Microsoft.Build.Framework.AbsolutePath assembly in seedAssemblies) { IEnumerable types = GetPublicVisibleTypes(assembly); foreach (string type in types) { - AddTypeToTable(typeTable, type, Path.GetFileName(assembly)); + AddTypeToTable(typeTable, type, Path.GetFileName(assembly.Value)); } } return typeTable; diff --git a/src/Microsoft.DotNet.GenFacades/Microsoft.DotNet.GenFacades.csproj b/src/Microsoft.DotNet.GenFacades/Microsoft.DotNet.GenFacades.csproj index 447848b9d93..2b7d3c57495 100644 --- a/src/Microsoft.DotNet.GenFacades/Microsoft.DotNet.GenFacades.csproj +++ b/src/Microsoft.DotNet.GenFacades/Microsoft.DotNet.GenFacades.csproj @@ -12,8 +12,4 @@ - - - - diff --git a/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs b/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs index 467858088e8..34da03ab50e 100644 --- a/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs +++ b/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs @@ -16,8 +16,17 @@ namespace Microsoft.DotNet.GenFacades /// /// The class generates an NotSupportedAssembly from the reference sources. /// - public class NotSupportedAssemblyGenerator : RoslynBuildTask + /// + /// TODO: Not opted into multithreading. RoslynBuildTask.Execute subscribes every instance to the + /// process-wide AssemblyLoadContext.Resolving event, so with differing RoslynAssembliesPath values + /// one instance can satisfy another instance's resolution. The TaskEnvironment below is still used + /// for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + /// + public class NotSupportedAssemblyGenerator : RoslynBuildTask, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] SourceFiles { get; set; } @@ -44,9 +53,9 @@ public override bool ExecuteCore() private void GenerateNotSupportedAssemblyFiles(IEnumerable sourceFiles) { string[] apiExclusions = null; - if (!string.IsNullOrEmpty(ApiExclusionListPath) && File.Exists(ApiExclusionListPath)) + if (!string.IsNullOrEmpty(ApiExclusionListPath) && File.Exists(TaskEnvironment.GetAbsolutePath(ApiExclusionListPath))) { - apiExclusions = File.ReadAllLines(ApiExclusionListPath); + apiExclusions = File.ReadAllLines(TaskEnvironment.GetAbsolutePath(ApiExclusionListPath)); } foreach (ITaskItem item in sourceFiles) @@ -54,7 +63,7 @@ private void GenerateNotSupportedAssemblyFiles(IEnumerable sourceFile string sourceFile = item.ItemSpec; string outputPath = item.GetMetadata("OutputPath"); - if (!File.Exists(sourceFile)) + if (!File.Exists(TaskEnvironment.GetAbsolutePath(sourceFile))) { Log.LogError($"File {sourceFile} was not found."); continue; @@ -76,7 +85,7 @@ private void GenerateNotSupportedAssemblyForSourceFile(string sourceFile, string Log.LogError($"Invalid LangVersion value '{LangVersion}'"); return; } - syntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText(sourceFile), new CSharpParseOptions(languageVersion)); + syntaxTree = CSharpSyntaxTree.ParseText(File.ReadAllText(TaskEnvironment.GetAbsolutePath(sourceFile)), new CSharpParseOptions(languageVersion)); } catch(Exception ex) { @@ -87,7 +96,7 @@ private void GenerateNotSupportedAssemblyForSourceFile(string sourceFile, string var rewriter = new NotSupportedAssemblyRewriter(Message, apiExclusions); SyntaxNode root = rewriter.Visit(syntaxTree.GetRoot()); string text = root.GetText().ToString(); - File.WriteAllText(outputPath, text); + File.WriteAllText(TaskEnvironment.GetAbsolutePath(outputPath), text); } } diff --git a/src/Microsoft.DotNet.GenFacades/RoslynBuildTask.cs b/src/Microsoft.DotNet.GenFacades/RoslynBuildTask.cs index c96e5c37b26..d0acb736f8c 100644 --- a/src/Microsoft.DotNet.GenFacades/RoslynBuildTask.cs +++ b/src/Microsoft.DotNet.GenFacades/RoslynBuildTask.cs @@ -10,7 +10,7 @@ namespace Microsoft.DotNet.Build.Tasks { - public abstract partial class RoslynBuildTask : BuildTask + public abstract partial class RoslynBuildTask : Microsoft.Build.Utilities.Task { [Required] public string RoslynAssembliesPath { get; set; } diff --git a/src/Microsoft.DotNet.GenFacades/SourceGenerator.cs b/src/Microsoft.DotNet.GenFacades/SourceGenerator.cs index d42f64b96fc..1c4e5e6599b 100644 --- a/src/Microsoft.DotNet.GenFacades/SourceGenerator.cs +++ b/src/Microsoft.DotNet.GenFacades/SourceGenerator.cs @@ -15,17 +15,17 @@ internal class SourceGenerator private readonly IReadOnlyDictionary _seedTypePreferences; private readonly IEnumerable _referenceTypes; private readonly IReadOnlyDictionary> _seedTypes; - private readonly string _outputSourcePath; + private readonly Microsoft.Build.Framework.AbsolutePath _outputSourcePath; private readonly HashSet _ignoreMissingTypesList = new HashSet(); - private readonly ILog _logger; + private readonly TaskLoggingHelper _logger; public SourceGenerator( IEnumerable referenceTypes, IReadOnlyDictionary> seedTypes, IReadOnlyDictionary seedTypePreferences, - string outputSourcePath, + Microsoft.Build.Framework.AbsolutePath outputSourcePath, string[] ignoreMissingTypesList, - ILog logger + TaskLoggingHelper logger ) { _referenceTypes = referenceTypes; @@ -39,7 +39,7 @@ ILog logger } public bool GenerateSource( - IEnumerable compileFiles, + IEnumerable compileFiles, IEnumerable constants, string langVersion, bool ignoreMissingTypes) diff --git a/src/Microsoft.DotNet.GenFacades/TypeParser.cs b/src/Microsoft.DotNet.GenFacades/TypeParser.cs index c6bd6426f24..0e1c4c55875 100644 --- a/src/Microsoft.DotNet.GenFacades/TypeParser.cs +++ b/src/Microsoft.DotNet.GenFacades/TypeParser.cs @@ -13,7 +13,7 @@ namespace Microsoft.DotNet.GenFacades { internal class TypeParser { - public static HashSet GetAllPublicTypes(IEnumerable files, IEnumerable constants, string langVersion) + public static HashSet GetAllPublicTypes(IEnumerable files, IEnumerable constants, string langVersion) { HashSet types = new HashSet(); @@ -130,13 +130,13 @@ private static string GetNamespaceName(NamespaceDeclarationSyntax namespaceSynta return namespaceSyntax.Name.ToFullString().Trim(); } - private static IEnumerable GetSourceTrees(IEnumerable sourceFiles, IEnumerable constants, LanguageVersion languageVersion) + private static IEnumerable GetSourceTrees(IEnumerable sourceFiles, IEnumerable constants, LanguageVersion languageVersion) { CSharpParseOptions options = new CSharpParseOptions(languageVersion: languageVersion, preprocessorSymbols: constants); List result = new List(); - foreach (string sourceFile in sourceFiles) + foreach (Microsoft.Build.Framework.AbsolutePath sourceFile in sourceFiles) { - if (string.IsNullOrEmpty(sourceFile)) + if (string.IsNullOrEmpty(sourceFile.Value)) { continue; } diff --git a/src/Microsoft.DotNet.Helix/Sdk/AzureDevOpsTask.cs b/src/Microsoft.DotNet.Helix/Sdk/AzureDevOpsTask.cs index e6e2b759a9c..64cb88e6ba0 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/AzureDevOpsTask.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/AzureDevOpsTask.cs @@ -19,13 +19,16 @@ namespace Microsoft.DotNet.Helix.AzureDevOps { - public abstract class AzureDevOpsTask : BaseTask + public abstract class AzureDevOpsTask : BaseTask, IMultiThreadableTask { - private bool InAzurePipeline => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BUILD_BUILDNUMBER")); + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + + private bool InAzurePipeline => !string.IsNullOrEmpty(TaskEnvironment.GetEnvironmentVariable("BUILD_BUILDNUMBER")); protected string GetEnvironmentVariable(string name) { - var result = Environment.GetEnvironmentVariable(name); + var result = TaskEnvironment.GetEnvironmentVariable(name); if (string.IsNullOrEmpty(result)) { throw new InvalidOperationException($"Required environment variable {name} not set."); @@ -194,8 +197,6 @@ protected async Task ParseResponseAsync(HttpRequestMessage req, HttpRes return null; } - private static readonly Random s_rand = new Random(); - public int RetryCount { get; set; } = 15; public double RetryBackOffFactor { get; set; } = 1.3; @@ -205,7 +206,7 @@ protected virtual int GetRetryDelay(int attempt) var factor = RetryBackOffFactor; var min = (int)(Math.Pow(factor, attempt) * 1000); var max = (int)(Math.Pow(factor, attempt + 1) * 1000); - return s_rand.Next(min, max); + return Random.Shared.Next(min, max); } public static bool IsRetryableHttpException(Exception ex) diff --git a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs index 2feb8f39c3d..9d10be18397 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CancelHelixJob.cs @@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class CancelHelixJobs : HelixTask { /// diff --git a/src/Microsoft.DotNet.Helix/Sdk/CheckAzurePipelinesTestResults.cs b/src/Microsoft.DotNet.Helix/Sdk/CheckAzurePipelinesTestResults.cs index 07c145507e7..7011c5b02d1 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CheckAzurePipelinesTestResults.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CheckAzurePipelinesTestResults.cs @@ -13,6 +13,7 @@ namespace Microsoft.DotNet.Helix.AzureDevOps { + [MSBuildMultiThreadableTask] public class CheckAzurePipelinesTestResults : AzureDevOpsTask { public int[] TestRunIds { get; set; } diff --git a/src/Microsoft.DotNet.Helix/Sdk/CheckHelixJobStatus.cs b/src/Microsoft.DotNet.Helix/Sdk/CheckHelixJobStatus.cs index 2cc315a4899..868582dff4c 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CheckHelixJobStatus.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CheckHelixJobStatus.cs @@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class CheckHelixJobStatus : HelixTask { /// diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateFailedTestsForFailedWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateFailedTestsForFailedWorkItems.cs index 1fa4d85411c..2a820d46665 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateFailedTestsForFailedWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateFailedTestsForFailedWorkItems.cs @@ -12,6 +12,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class CreateTestsForWorkItems : AzureDevOpsTask { [Required] diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateMTPWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateMTPWorkItems.cs index 132c35f314b..58bdd6febd9 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateMTPWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateMTPWorkItems.cs @@ -17,6 +17,7 @@ namespace Microsoft.DotNet.Helix.Sdk /// run directly with 'dotnet exec'. This applies to MSTest 4.x, xUnit v3 with MTP, /// NUnit with MTP, TUnit, and any custom MTP-based test framework. /// + [MSBuildMultiThreadableTask] public class CreateMTPWorkItems : BaseTask { /// diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs index e8f00390894..89069ed0ce0 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs @@ -15,6 +15,7 @@ namespace Microsoft.DotNet.Helix.Sdk /// /// MSBuild custom task to create HelixWorkItems for provided Android application packages. /// + [MSBuildMultiThreadableTask] public class CreateXHarnessAndroidWorkItems : XHarnessTaskBase { public static class MetadataNames diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs index a1d7c943d8c..26cb89f6946 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs @@ -15,6 +15,7 @@ namespace Microsoft.DotNet.Helix.Sdk /// /// MSBuild custom task to create HelixWorkItems for provided iOS app bundle paths. /// + [MSBuildMultiThreadableTask] public class CreateXHarnessAppleWorkItems : XHarnessTaskBase { public const string iOSTargetName = "ios-device"; diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXUnitWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXUnitWorkItems.cs index d41471235fb..286b3bd8eca 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXUnitWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXUnitWorkItems.cs @@ -15,6 +15,7 @@ namespace Microsoft.DotNet.Helix.Sdk /// /// MSBuild custom task to create HelixWorkItems given xUnit project publish information /// + [MSBuildMultiThreadableTask] public class CreateXUnitWorkItems : BaseTask { /// diff --git a/src/Microsoft.DotNet.Helix/Sdk/DownloadFromResultsContainer.cs b/src/Microsoft.DotNet.Helix/Sdk/DownloadFromResultsContainer.cs index 13ac6caac6b..8319f53417c 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/DownloadFromResultsContainer.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/DownloadFromResultsContainer.cs @@ -12,8 +12,12 @@ namespace Microsoft.DotNet.Helix.Sdk { - public class DownloadFromResultsContainer : HelixTask, ICancelableTask + [MSBuildMultiThreadableTask] + public class DownloadFromResultsContainer : HelixTask, ICancelableTask, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] WorkItems { get; set; } @@ -49,7 +53,7 @@ protected override async Task ExecuteCore(CancellationToken cancellationToken) Log.LogMessage(MessageImportance.High, $"Downloading result files for job {JobId}"); - DirectoryInfo directory = Directory.CreateDirectory(Path.Combine(OutputDirectory, JobId)); + DirectoryInfo directory = Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.Combine(OutputDirectory, JobId))); using (FileStream stream = File.Open(Path.Combine(directory.FullName, MetadataFile), FileMode.Create, FileAccess.Write)) using (var writer = new StreamWriter(stream)) { @@ -74,7 +78,7 @@ private async Task DownloadFilesForWorkItem(ITaskItem workItem, string directory var allAvailableFiles = await HelixApi.WorkItem.ListFilesAsync(workItemName, JobId, true, ct); var resultsUri = await HelixApi.Job.ResultsAsync(JobId, ct); - DirectoryInfo destinationDir = Directory.CreateDirectory(Path.Combine(directoryPath, workItemName)); + DirectoryInfo destinationDir = Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.Combine(directoryPath, workItemName))); foreach (string file in filesToDownload) { try diff --git a/src/Microsoft.DotNet.Helix/Sdk/FindDotNetCliPackage.cs b/src/Microsoft.DotNet.Helix/Sdk/FindDotNetCliPackage.cs index 59723ce43c2..bd41f95c294 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/FindDotNetCliPackage.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/FindDotNetCliPackage.cs @@ -17,6 +17,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class FindDotNetCliPackage : MSBuildTaskBase { // Use lots of retries since an Http Client failure here means failure to send to Helix @@ -55,7 +56,7 @@ public class FindDotNetCliPackage : MSBuildTaskBase [Output] public string PackageUri { get; set; } - private static HttpClient _client; + private HttpClient _client; private HttpMessageHandler _httpMessageHandler; public override void ConfigureServices(IServiceCollection collection) diff --git a/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs index d8d25d70c19..fc6aac62896 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/GetHelixWorkItems.cs @@ -16,6 +16,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class GetHelixWorkItems : HelixTask { public const int DelayBetweenHelixApiCallsInMs = 500; diff --git a/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs b/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs index 5e636785c46..6290fafa470 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs @@ -15,6 +15,7 @@ namespace Microsoft.DotNet.Helix.Sdk /// Task that installs a .NET tool in a given folder. /// Handles parallel builds that install the same tool. /// + [MSBuildMultiThreadableTask] public class InstallDotNetTool : MSBuildTaskBase { /// diff --git a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs index e2bacc45c52..0de6b4c21ea 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs @@ -16,7 +16,8 @@ namespace Microsoft.DotNet.Helix.Sdk { - public class SendHelixJob : HelixTask + [MSBuildMultiThreadableTask] + public class SendHelixJob : HelixTask, IMultiThreadableTask { public static class MetadataNames { @@ -40,6 +41,9 @@ public static class MetadataNames public const string AsArchive = "AsArchive"; } + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// The 'type' value reported to Helix /// @@ -307,7 +311,7 @@ private IJobDefinition AddBuildVariableProperty(IJobDefinition def, string key, { string envName = FromAzdoVariableNameToEnvironmentVariableName(azdoVariableName); - var value = Environment.GetEnvironmentVariable(envName); + var value = TaskEnvironment.GetEnvironmentVariable(envName); if (string.IsNullOrEmpty(value)) { return def; @@ -548,7 +552,7 @@ private IJobDefinition AddCorrelationPayload(IJobDefinition def, ITaskItem corre } } - if (Directory.Exists(path)) + if (Directory.Exists(TaskEnvironment.GetAbsolutePath(path))) { string includeDirectoryNameStr = correlationPayload.GetMetadata(MetadataNames.IncludeDirectoryName); if (!bool.TryParse(includeDirectoryNameStr, out bool includeDirectoryName)) @@ -564,7 +568,7 @@ private IJobDefinition AddCorrelationPayload(IJobDefinition def, ITaskItem corre } - if (File.Exists(path)) + if (File.Exists(TaskEnvironment.GetAbsolutePath(path))) { string asArchiveStr = correlationPayload.GetMetadata(MetadataNames.AsArchive); if (!bool.TryParse(asArchiveStr, out bool asArchive)) diff --git a/src/Microsoft.DotNet.Helix/Sdk/StartAzurePipelinesTestRun.cs b/src/Microsoft.DotNet.Helix/Sdk/StartAzurePipelinesTestRun.cs index ef838479e70..c94e88fbe6c 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/StartAzurePipelinesTestRun.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/StartAzurePipelinesTestRun.cs @@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Helix.AzureDevOps { + [MSBuildMultiThreadableTask] public class StartAzurePipelinesTestRun : AzureDevOpsTask { [Required] diff --git a/src/Microsoft.DotNet.Helix/Sdk/StopAzurePipelinesTestRun.cs b/src/Microsoft.DotNet.Helix/Sdk/StopAzurePipelinesTestRun.cs index 2c2e1ce1c92..7f3f4b2acc2 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/StopAzurePipelinesTestRun.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/StopAzurePipelinesTestRun.cs @@ -10,6 +10,7 @@ namespace Microsoft.DotNet.Helix.AzureDevOps { + [MSBuildMultiThreadableTask] public class StopAzurePipelinesTestRun : AzureDevOpsTask { [Required] diff --git a/src/Microsoft.DotNet.Helix/Sdk/WaitForHelixJobCompletion.cs b/src/Microsoft.DotNet.Helix/Sdk/WaitForHelixJobCompletion.cs index ba2a1b1d225..f6775f7dfb6 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/WaitForHelixJobCompletion.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/WaitForHelixJobCompletion.cs @@ -12,6 +12,7 @@ namespace Microsoft.DotNet.Helix.Sdk { + [MSBuildMultiThreadableTask] public class WaitForHelixJobCompletion : HelixTask { internal const string HelixControllerWorkQueueingWorkItemName = "HelixController Work Queueing"; diff --git a/src/Microsoft.DotNet.NuGetRepack/tasks/src/NuGetVersionUpdater.cs b/src/Microsoft.DotNet.NuGetRepack/tasks/src/NuGetVersionUpdater.cs index bd92f54d76d..80bab0f82f4 100644 --- a/src/Microsoft.DotNet.NuGetRepack/tasks/src/NuGetVersionUpdater.cs +++ b/src/Microsoft.DotNet.NuGetRepack/tasks/src/NuGetVersionUpdater.cs @@ -54,17 +54,21 @@ public PackageInfo( } public static void Run( - IEnumerable packagePaths, - string outDirectoryOpt, + IEnumerable packagePaths, + Microsoft.Build.Framework.AbsolutePath? outDirectoryOpt, VersionTranslation translation, bool exactVersions, Func allowPreReleaseDependency = null) { - string tempDirectoryOpt; + Microsoft.Build.Framework.AbsolutePath? tempDirectoryOpt; if (outDirectoryOpt != null) { - tempDirectoryOpt = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempDirectoryOpt); + // MSBuildTask0002: the temp root is only used as the parent of a freshly generated unique + // directory/file name, so it is never shared between concurrently running tasks. + #pragma warning disable MSBuildTask0002 + tempDirectoryOpt = new Microsoft.Build.Framework.AbsolutePath(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + #pragma warning restore MSBuildTask0002 + Directory.CreateDirectory(tempDirectoryOpt.Value); } else { @@ -79,7 +83,7 @@ public static void Run( if (outDirectoryOpt != null) { - SavePackages(packages, outDirectoryOpt); + SavePackages(packages, outDirectoryOpt.Value); } } finally @@ -92,12 +96,12 @@ public static void Run( if (tempDirectoryOpt != null) { - Directory.Delete(tempDirectoryOpt, recursive: true); + Directory.Delete(tempDirectoryOpt.Value, recursive: true); } } } - private static void LoadPackages(IEnumerable packagePaths, Dictionary packages, string tempDirectoryOpt, VersionTranslation translation) + private static void LoadPackages(IEnumerable packagePaths, Dictionary packages, Microsoft.Build.Framework.AbsolutePath? tempDirectoryOpt, VersionTranslation translation) { bool readOnly = tempDirectoryOpt == null; @@ -113,8 +117,8 @@ private static void LoadPackages(IEnumerable packagePaths, Dictionary packagePaths, Dictionary packages, ThrowExceptions(errors); } - private static void SavePackages(Dictionary packages, string outDirectory) + private static void SavePackages(Dictionary packages, Microsoft.Build.Framework.AbsolutePath outDirectory) { Directory.CreateDirectory(outDirectory); @@ -371,7 +375,7 @@ private static void SavePackages(Dictionary packages, strin try { - File.Copy(package.TempPathOpt, finalPath, overwrite: true); + File.Copy(new Microsoft.Build.Framework.AbsolutePath(package.TempPathOpt), new Microsoft.Build.Framework.AbsolutePath(finalPath), overwrite: true); } catch (Exception e) { diff --git a/src/Microsoft.DotNet.NuGetRepack/tasks/src/ReplacePackageParts.cs b/src/Microsoft.DotNet.NuGetRepack/tasks/src/ReplacePackageParts.cs index e715f80a5d4..01e22a3bca1 100644 --- a/src/Microsoft.DotNet.NuGetRepack/tasks/src/ReplacePackageParts.cs +++ b/src/Microsoft.DotNet.NuGetRepack/tasks/src/ReplacePackageParts.cs @@ -17,8 +17,12 @@ namespace Microsoft.DotNet.Tools /// /// Replaces content of files in specified package with new content and updates version of the package. /// - public sealed class ReplacePackageParts : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public sealed class ReplacePackageParts : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Full path to the package to process. /// @@ -107,10 +111,14 @@ private void ExecuteImpl() string packageId = null; SemanticVersion packageVersion = null; + // MSBuildTask0002: the temp root is only used as the parent of a freshly generated unique + // directory/file name, so it is never shared between concurrently running tasks. + #pragma warning disable MSBuildTask0002 string tempPackagePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + #pragma warning restore MSBuildTask0002 try { - File.Copy(SourcePackage, tempPackagePath); + File.Copy(TaskEnvironment.GetAbsolutePath(SourcePackage), TaskEnvironment.GetAbsolutePath(tempPackagePath)); using (var package = Package.Open(tempPackagePath, FileMode.Open, FileAccess.ReadWrite)) { @@ -178,7 +186,7 @@ private void ExecuteImpl() Stream replacementStream; try { - replacementStream = File.OpenRead(replacementFilePath); + replacementStream = File.OpenRead(TaskEnvironment.GetAbsolutePath(replacementFilePath)); } catch (Exception e) { @@ -216,19 +224,19 @@ private void ExecuteImpl() } // remove signature if present (the signature part is not accessible thru Package API): - using (var archive = new ZipArchive(File.Open(tempPackagePath, FileMode.Open, FileAccess.ReadWrite), ZipArchiveMode.Update)) + using (var archive = new ZipArchive(File.Open(TaskEnvironment.GetAbsolutePath(tempPackagePath), FileMode.Open, FileAccess.ReadWrite), ZipArchiveMode.Update)) { archive.Entries.FirstOrDefault(e => e.FullName == NuGetUtils.SignaturePartUri)?.Delete(); } NewPackage = Path.Combine(DestinationFolder, packageId + "." + packageVersion + ".nupkg"); - Directory.CreateDirectory(DestinationFolder); - File.Copy(tempPackagePath, NewPackage, overwrite: true); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(DestinationFolder)); + File.Copy(TaskEnvironment.GetAbsolutePath(tempPackagePath), TaskEnvironment.GetAbsolutePath(NewPackage), overwrite: true); } finally { - File.Delete(tempPackagePath); + File.Delete(TaskEnvironment.GetAbsolutePath(tempPackagePath)); } } diff --git a/src/Microsoft.DotNet.NuGetRepack/tasks/src/UpdatePackageVersionTask.cs b/src/Microsoft.DotNet.NuGetRepack/tasks/src/UpdatePackageVersionTask.cs index 751c56330df..309f690375b 100644 --- a/src/Microsoft.DotNet.NuGetRepack/tasks/src/UpdatePackageVersionTask.cs +++ b/src/Microsoft.DotNet.NuGetRepack/tasks/src/UpdatePackageVersionTask.cs @@ -10,8 +10,12 @@ namespace Microsoft.DotNet.Tools { - public class UpdatePackageVersionTask : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class UpdatePackageVersionTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + public string VersionKind { get; set; } [Required] @@ -24,7 +28,12 @@ public class UpdatePackageVersionTask : Microsoft.Build.Utilities.Task public bool AllowPreReleaseDependencies { get; set; } + // MSBuildTask0005: the only remaining unsafe call in this chain is Path.GetTempPath(), which + // is used purely as the parent of a freshly generated GUID directory, so it is never shared + // between concurrently running tasks. + #pragma warning disable MSBuildTask0005 public override bool Execute() + #pragma warning restore MSBuildTask0005 { try { @@ -61,7 +70,7 @@ private void ExecuteImpl() try { - NuGetVersionUpdater.Run(Packages, OutputDirectory, translation, ExactVersions, allowPreReleaseDependency: (packageId, dependencyId, dependencyVersion) => + NuGetVersionUpdater.Run(Packages.Select(TaskEnvironment.GetAbsolutePath), string.IsNullOrEmpty(OutputDirectory) ? null : TaskEnvironment.GetAbsolutePath(OutputDirectory), translation, ExactVersions, allowPreReleaseDependency: (packageId, dependencyId, dependencyVersion) => { if (AllowPreReleaseDependencies) { @@ -75,7 +84,7 @@ private void ExecuteImpl() if (translation == VersionTranslation.Release) { - File.WriteAllLines(Path.Combine(OutputDirectory, "PreReleaseDependencies.txt"), preReleaseDependencies.Distinct()); + File.WriteAllLines(TaskEnvironment.GetAbsolutePath(Path.Combine(OutputDirectory, "PreReleaseDependencies.txt")), preReleaseDependencies.Distinct()); } } catch (AggregateException e) diff --git a/src/Microsoft.DotNet.NuGetRepack/tests/VersionUpdaterTests.cs b/src/Microsoft.DotNet.NuGetRepack/tests/VersionUpdaterTests.cs index d4d76da2883..7e240addedd 100644 --- a/src/Microsoft.DotNet.NuGetRepack/tests/VersionUpdaterTests.cs +++ b/src/Microsoft.DotNet.NuGetRepack/tests/VersionUpdaterTests.cs @@ -8,6 +8,7 @@ using System.Xml.Linq; using Microsoft.DotNet.Tools.Tests.Utilities; using Xunit; +using Microsoft.Build.Framework; namespace Microsoft.DotNet.Tools.Tests { @@ -78,8 +79,8 @@ public void TestPackagesSemVer1() var d_rel = Path.Combine(dir, TestResources.ReleasePackages.NameD); var g_rel = Path.Combine(dir, TestResources.ReleasePackages.NameG); - NuGetVersionUpdater.Run(new[] { a_daily, b_daily, c_daily, d_daily, g_daily }, dir, VersionTranslation.Release, exactVersions: false); - NuGetVersionUpdater.Run(new[] { a_daily, b_daily, c_daily, d_daily, g_daily }, dir, VersionTranslation.PreRelease, exactVersions: false); + NuGetVersionUpdater.Run(new[] { a_daily, b_daily, c_daily, d_daily, g_daily }.Select(p => new AbsolutePath(p)), new AbsolutePath(dir), VersionTranslation.Release, exactVersions: false); + NuGetVersionUpdater.Run(new[] { a_daily, b_daily, c_daily, d_daily, g_daily }.Select(p => new AbsolutePath(p)), new AbsolutePath(dir), VersionTranslation.PreRelease, exactVersions: false); AssertPackagesEqual(TestResources.ReleasePackages.TestPackageA, File.ReadAllBytes(a_rel)); AssertPackagesEqual(TestResources.ReleasePackages.TestPackageB, File.ReadAllBytes(b_rel)); @@ -112,8 +113,8 @@ public void TestPackagesSemVer2() var e_rel = Path.Combine(dir, TestResources.ReleasePackages.NameE); var f_rel = Path.Combine(dir, TestResources.ReleasePackages.NameF); - NuGetVersionUpdater.Run(new[] { e_daily, f_daily }, dir, VersionTranslation.Release, exactVersions: true); - NuGetVersionUpdater.Run(new[] { e_daily, f_daily }, dir, VersionTranslation.PreRelease, exactVersions: true); + NuGetVersionUpdater.Run(new[] { e_daily, f_daily }.Select(p => new AbsolutePath(p)), new AbsolutePath(dir), VersionTranslation.Release, exactVersions: true); + NuGetVersionUpdater.Run(new[] { e_daily, f_daily }.Select(p => new AbsolutePath(p)), new AbsolutePath(dir), VersionTranslation.PreRelease, exactVersions: true); AssertPackagesEqual(TestResources.ReleasePackages.TestPackageE, File.ReadAllBytes(e_rel)); AssertPackagesEqual(TestResources.ReleasePackages.TestPackageF, File.ReadAllBytes(f_rel)); @@ -135,10 +136,10 @@ public void TestValidation() File.WriteAllBytes(b_daily = Path.Combine(dir, TestResources.DailyBuildPackages.NameB), TestResources.DailyBuildPackages.TestPackageB); File.WriteAllBytes(c_daily = Path.Combine(dir, TestResources.DailyBuildPackages.NameC), TestResources.DailyBuildPackages.TestPackageC); - var e1 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { c_daily }, outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); + var e1 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { c_daily }.Select(p => new AbsolutePath(p)), outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); AssertEx.AreEqual("Package 'TestPackageC' depends on a pre-release package 'TestPackageB, [1.0.0-beta-12345-01]'", e1.Message); - var e2 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily }, outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); + var e2 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily }.Select(p => new AbsolutePath(p)), outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); AssertEx.Equal(new[] { "System.InvalidOperationException: Package 'TestPackageA' depends on a pre-release package 'TestPackageB, 1.0.0-beta-12345-01'", @@ -146,14 +147,14 @@ public void TestValidation() "System.InvalidOperationException: Package 'TestPackageA' depends on a pre-release package 'TestPackageC, 1.0.0-beta-12345-01'" }, e2.InnerExceptions.Select(i => i.ToString())); - var e3 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily, b_daily }, outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); + var e3 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily, b_daily }.Select(p => new AbsolutePath(p)), outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); AssertEx.Equal(new[] { "System.InvalidOperationException: Package 'TestPackageA' depends on a pre-release package 'TestPackageC, (, 1.0.0-beta-12345-01]'", "System.InvalidOperationException: Package 'TestPackageA' depends on a pre-release package 'TestPackageC, 1.0.0-beta-12345-01'" }, e3.InnerExceptions.Select(i => i.ToString())); - var e4 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily, c_daily }, outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); + var e4 = Assert.Throws(() => NuGetVersionUpdater.Run(new[] { a_daily, c_daily }.Select(p => new AbsolutePath(p)), outDirectoryOpt: null, VersionTranslation.Release, exactVersions: false)); AssertEx.Equal(new[] { "System.InvalidOperationException: Package 'TestPackageA' depends on a pre-release package 'TestPackageB, 1.0.0-beta-12345-01'", @@ -175,7 +176,7 @@ public void TestDotnetToolValidation() string normal_package_b_daily; File.WriteAllBytes(normal_package_b_daily = Path.Combine(dir, TestResources.DailyBuildPackages.NameB), TestResources.DailyBuildPackages.TestPackageB); - NuGetVersionUpdater.Run(new[] { dotnet_tool, normal_package_b_daily }, outDirectoryOpt: outputDir, VersionTranslation.Release, exactVersions: false); + NuGetVersionUpdater.Run(new[] { dotnet_tool, normal_package_b_daily }.Select(p => new AbsolutePath(p)), outDirectoryOpt: new AbsolutePath(outputDir), VersionTranslation.Release, exactVersions: false); // Only contain normal package. dotnet tool package is skipped Assert.Single(Directory.EnumerateFiles(outputDir), fullPath => Path.GetFileNameWithoutExtension(fullPath) == "TestPackageB.1.0.0"); diff --git a/src/Microsoft.DotNet.PackageTesting.Tests/GetCompatibilePackageTargetFrameworksTests.cs b/src/Microsoft.DotNet.PackageTesting.Tests/GetCompatibilePackageTargetFrameworksTests.cs index 1fbcc5ff995..0f585d2ca6f 100644 --- a/src/Microsoft.DotNet.PackageTesting.Tests/GetCompatibilePackageTargetFrameworksTests.cs +++ b/src/Microsoft.DotNet.PackageTesting.Tests/GetCompatibilePackageTargetFrameworksTests.cs @@ -10,9 +10,11 @@ namespace Microsoft.DotNet.PackageTesting.Tests { public class GetCompatibilePackageTargetFrameworksTests { + private readonly GetCompatiblePackageTargetFrameworks _task = new(); + public GetCompatibilePackageTargetFrameworksTests() { - GetCompatiblePackageTargetFrameworks.Initialize("netcoreapp3.1;net5.0;net6.0;net461;net462;net471;net472;netstandard2.0;netstandard2.1"); + _task.Initialize("netcoreapp3.1;net5.0;net6.0;net461;net462;net471;net472;netstandard2.0;netstandard2.1"); } public static IEnumerable PackageTfmData => new List @@ -137,7 +139,7 @@ public GetCompatibilePackageTargetFrameworksTests() public void GetCompatibleFrameworks(List filePaths, List expectedTestFrameworks) { Package package = new("TestPackage", "1.0.0", filePaths, Enumerable.Empty()); - IEnumerable actualTestFrameworks = GetCompatiblePackageTargetFrameworks.GetTestFrameworks(package, "netcoreapp3.1"); + IEnumerable actualTestFrameworks = _task.GetTestFrameworks(package, "netcoreapp3.1"); CollectionsEqual(expectedTestFrameworks, actualTestFrameworks); } @@ -153,7 +155,7 @@ public void GetCompatibleFrameworksFromDependencies() NuGetFramework.Parse("net6.0"), }; Package package = new("TestPackage", "1.0.0", Enumerable.Empty(), dependencyFrameworks); - IEnumerable actualTestFrameworks = GetCompatiblePackageTargetFrameworks.GetTestFrameworks(package, "netcoreapp3.1"); + IEnumerable actualTestFrameworks = _task.GetTestFrameworks(package, "netcoreapp3.1"); var expectedTestFrameworks = new[] { diff --git a/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs b/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs index 4fa94bfe4c8..3e7612186ed 100644 --- a/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs +++ b/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using NuGet.Frameworks; using System; using System.Collections.Generic; @@ -11,10 +10,11 @@ namespace Microsoft.DotNet.PackageTesting { - public class GetCompatiblePackageTargetFrameworks : BuildTask + [MSBuildMultiThreadableTask] + public class GetCompatiblePackageTargetFrameworks : Microsoft.Build.Utilities.Task { - private static List allTargetFrameworks = new(); - private static Dictionary> packageTfmMapping = new(); + private readonly List allTargetFrameworks = new(); + private readonly Dictionary> packageTfmMapping = new(); [Required] public string[] PackagePaths { get; set; } @@ -56,7 +56,7 @@ public override bool Execute() return !Log.HasLoggedErrors; } - public static IEnumerable GetTestFrameworks(Package package, string minDotnetTargetFramework) + public IEnumerable GetTestFrameworks(Package package, string minDotnetTargetFramework) { List frameworksToTest= new(); IEnumerable packageTargetFrameworks = package.FrameworksInPackage; @@ -81,7 +81,7 @@ public static IEnumerable GetTestFrameworks(Package package, str return frameworksToTest.Where(tfm => allTargetFrameworks.Contains(tfm)).Distinct(); } - public static void Initialize(string targetFrameworks) + public void Initialize(string targetFrameworks) { // Defining the set of known frameworks that we care to test foreach (var tfm in targetFrameworks.Split(';')) diff --git a/src/Microsoft.DotNet.PackageTesting/Microsoft.DotNet.PackageTesting.csproj b/src/Microsoft.DotNet.PackageTesting/Microsoft.DotNet.PackageTesting.csproj index 15099f9f9a2..9bdf43d4520 100644 --- a/src/Microsoft.DotNet.PackageTesting/Microsoft.DotNet.PackageTesting.csproj +++ b/src/Microsoft.DotNet.PackageTesting/Microsoft.DotNet.PackageTesting.csproj @@ -13,8 +13,4 @@ - - - - diff --git a/src/Microsoft.DotNet.PackageTesting/VerifyClosure.cs b/src/Microsoft.DotNet.PackageTesting/VerifyClosure.cs index f64f2bf9347..a0477aadd09 100644 --- a/src/Microsoft.DotNet.PackageTesting/VerifyClosure.cs +++ b/src/Microsoft.DotNet.PackageTesting/VerifyClosure.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Framework; -using Microsoft.DotNet.Build.Tasks; using System; using System.Collections.Generic; using System.IO; @@ -18,9 +17,13 @@ namespace Microsoft.DotNet.PackageTesting /// /// Verifies the closure of a set of DLLs, making sure all files are present and no cycles exist /// - public class VerifyClosure : BuildTask + [MSBuildMultiThreadableTask] + public class VerifyClosure : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Sources to scan. Items can be directories or files. /// @@ -93,7 +96,7 @@ private void LoadSources() private void AddSourceFile(string file) { - var assemblyInfo = AssemblyInfo.GetAssemblyInfo(file); + var assemblyInfo = AssemblyInfo.GetAssemblyInfo(TaskEnvironment.GetAbsolutePath(file)); if (assemblyInfo == null) { @@ -244,7 +247,7 @@ void CheckDependencies(Stack depStack) if (assm.State == CheckState.Unchecked) { - Log.LogMessage(LogImportance.Low, $"Checked {assm.Path}"); + Log.LogMessage(MessageImportance.Low, $"Checked {assm.Path}"); assm.State = CheckState.Checked; } @@ -340,7 +343,7 @@ private string PrintCycle(AssemblyInfo[] cycleStack) } private static XNamespace s_dgmlns = @"http://schemas.microsoft.com/vs/2009/dgml"; - private static void WriteDependencyGraph(string dependencyGraphFilePath, IEnumerable assemblies) + private void WriteDependencyGraph(string dependencyGraphFilePath, IEnumerable assemblies) { var doc = new XDocument(new XElement(s_dgmlns + "DirectedGraph")); @@ -387,7 +390,7 @@ private static void WriteDependencyGraph(string dependencyGraphFilePath, IEnumer new XAttribute("Background", "Green") )); - using (var file = File.Create(dependencyGraphFilePath)) + using (var file = File.Create(TaskEnvironment.GetAbsolutePath(dependencyGraphFilePath))) { doc.Save(file); } @@ -424,7 +427,7 @@ public AssemblyInfo(string path, string name, Version version, AssemblyReference public string[] ModuleReferences { get; } public CheckState State { get; set; } - public static AssemblyInfo GetAssemblyInfo(string path) + public static AssemblyInfo GetAssemblyInfo(Microsoft.Build.Framework.AbsolutePath path) { try { diff --git a/src/Microsoft.DotNet.PackageTesting/VerifyTypes.cs b/src/Microsoft.DotNet.PackageTesting/VerifyTypes.cs index 772a766b275..561eb4af33f 100644 --- a/src/Microsoft.DotNet.PackageTesting/VerifyTypes.cs +++ b/src/Microsoft.DotNet.PackageTesting/VerifyTypes.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Framework; -using Microsoft.DotNet.Build.Tasks; using System; using System.Collections.Generic; using System.IO; @@ -15,8 +14,12 @@ namespace Microsoft.DotNet.PackageTesting /// /// Verifies no type overlap in a set of DLLs /// - public class VerifyTypes : BuildTask + [MSBuildMultiThreadableTask] + public class VerifyTypes : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Sources to scan. Items can be directories or files. /// @@ -95,7 +98,7 @@ private void LoadSources() private void AddSourceFile(string file) { - var assemblyInfo = AssemblyInfo.GetAssemblyInfo(file); + var assemblyInfo = AssemblyInfo.GetAssemblyInfo(TaskEnvironment.GetAbsolutePath(file)); if (assemblyInfo != null) { @@ -145,7 +148,7 @@ public AssemblyInfo(string path, string name, string[] types) public string Name { get; } public string[] Types { get; } - public static AssemblyInfo GetAssemblyInfo(string path) + public static AssemblyInfo GetAssemblyInfo(Microsoft.Build.Framework.AbsolutePath path) { try { diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/Microsoft.DotNet.SharedFramework.Sdk.csproj b/src/Microsoft.DotNet.SharedFramework.Sdk/Microsoft.DotNet.SharedFramework.Sdk.csproj index c424ec15114..ee457e9896b 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/Microsoft.DotNet.SharedFramework.Sdk.csproj +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/Microsoft.DotNet.SharedFramework.Sdk.csproj @@ -26,7 +26,6 @@ - diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/CreateFrameworkListFile.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/CreateFrameworkListFile.cs index 68a1af12c73..09c34e0f178 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/CreateFrameworkListFile.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/CreateFrameworkListFile.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Framework; -using Microsoft.DotNet.Build.Tasks; using System; using System.Collections.Generic; using System.Globalization; @@ -13,8 +12,12 @@ namespace Microsoft.DotNet.SharedFramework.Sdk { - public class CreateFrameworkListFile : BuildTask + [MSBuildMultiThreadableTask] + public class CreateFrameworkListFile : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Files to extract basic information from and include in the list. /// @@ -83,8 +86,8 @@ public override bool Execute() Item = item, Filename = Path.GetFileName(item.ItemSpec), TargetPath = item.GetMetadata("TargetPath"), - AssemblyName = FileUtilities.GetAssemblyName(item.ItemSpec), - FileVersion = FileUtilities.GetFileVersion(item.ItemSpec), + AssemblyName = FileUtilities.GetAssemblyName(TaskEnvironment.GetAbsolutePath(item.ItemSpec)), + FileVersion = FileUtilities.GetFileVersion(TaskEnvironment.GetAbsolutePath(item.ItemSpec)), IsNative = item.GetMetadata("IsNative") == "true", IsSymbolFile = item.GetMetadata("IsSymbolFile") == "true", IsPgoData = item.GetMetadata("IsPgoData") == "true", @@ -257,8 +260,8 @@ public override bool Execute() Log.LogError($"Classification matches no files: {unused}"); } - Directory.CreateDirectory(Path.GetDirectoryName(TargetFile)); - File.WriteAllText(TargetFile, frameworkManifest.ToString()); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(TargetFile))); + File.WriteAllText(TaskEnvironment.GetAbsolutePath(TargetFile), frameworkManifest.ToString()); return !Log.HasLoggedErrors; } diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/FileUtilities.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/FileUtilities.cs index b8d9348bf98..c36c15a06f3 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/FileUtilities.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/FileUtilities.cs @@ -17,7 +17,7 @@ internal static partial class FileUtilities new[] { ".dll", ".exe" }, StringComparer.OrdinalIgnoreCase); - public static Version GetFileVersion(string sourcePath) + public static Version GetFileVersion(Microsoft.Build.Framework.AbsolutePath sourcePath) { var fvi = FileVersionInfo.GetVersionInfo(sourcePath); @@ -29,7 +29,7 @@ public static Version GetFileVersion(string sourcePath) return null; } - public static AssemblyName GetAssemblyName(string path) + public static AssemblyName GetAssemblyName(Microsoft.Build.Framework.AbsolutePath path) { if (!s_assemblyExtensions.Contains(Path.GetExtension(path))) { diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromFileList.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromFileList.cs index 2e4ba8dd5a9..ab1b41920f7 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromFileList.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromFileList.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using Microsoft.Extensions.DependencyModel; using System; using System.Collections.Generic; @@ -12,8 +11,12 @@ namespace Microsoft.DotNet.SharedFramework.Sdk { - public class GeneratePlatformManifestEntriesFromFileList : BuildTask + [MSBuildMultiThreadableTask] + public class GeneratePlatformManifestEntriesFromFileList : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] Files { get; set; } @@ -28,8 +31,8 @@ public override bool Execute() entries.Add(new PlatformManifestEntry { Name = file.ItemSpec, - AssemblyVersion = FileUtilities.GetAssemblyName(file.GetMetadata("OriginalFilePath"))?.Version.ToString() ?? string.Empty, - FileVersion = FileUtilities.GetFileVersion(file.GetMetadata("OriginalFilePath"))?.ToString() ?? string.Empty + AssemblyVersion = FileUtilities.GetAssemblyName(TaskEnvironment.GetAbsolutePath(file.GetMetadata("OriginalFilePath")))?.Version.ToString() ?? string.Empty, + FileVersion = FileUtilities.GetFileVersion(TaskEnvironment.GetAbsolutePath(file.GetMetadata("OriginalFilePath")))?.ToString() ?? string.Empty }); } diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromTemplate.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromTemplate.cs index 73f032a2d39..ffd8f3069dd 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromTemplate.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GeneratePlatformManifestEntriesFromTemplate.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using System; using System.Collections.Generic; using System.IO; @@ -11,8 +10,12 @@ namespace Microsoft.DotNet.SharedFramework.Sdk { - public class GeneratePlatformManifestEntriesFromTemplate : BuildTask + [MSBuildMultiThreadableTask] + public class GeneratePlatformManifestEntriesFromTemplate : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] PlatformManifestEntryTemplates { get; set; } @@ -39,8 +42,8 @@ public override bool Execute() entries.Add(new PlatformManifestEntry { Name = entryTemplate.ItemSpec, - AssemblyVersion = FileUtilities.GetAssemblyName(existingFile.ItemSpec)?.Version.ToString() ?? string.Empty, - FileVersion = FileUtilities.GetFileVersion(existingFile.ItemSpec)?.ToString() ?? string.Empty + AssemblyVersion = FileUtilities.GetAssemblyName(TaskEnvironment.GetAbsolutePath(existingFile.ItemSpec))?.Version.ToString() ?? string.Empty, + FileVersion = FileUtilities.GetFileVersion(TaskEnvironment.GetAbsolutePath(existingFile.ItemSpec))?.ToString() ?? string.Empty }); } else diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs index 76b1c22d9de..4a44701f543 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs @@ -3,7 +3,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using Microsoft.DotNet.Build.Tasks; using Microsoft.Extensions.DependencyModel; using NuGet.RuntimeModel; using System; @@ -13,8 +12,12 @@ namespace Microsoft.DotNet.SharedFramework.Sdk { - public class GenerateSharedFrameworkDepsFile : BuildTask + [MSBuildMultiThreadableTask] + public class GenerateSharedFrameworkDepsFile : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string TargetFrameworkMoniker { get; set; } @@ -60,8 +63,8 @@ public override bool Execute() } string filePath = file.ItemSpec; string fileName = Path.GetFileName(filePath); - string fileVersion = FileUtilities.GetFileVersion(filePath)?.ToString() ?? string.Empty; - Version assemblyVersion = FileUtilities.GetAssemblyName(filePath)?.Version; + string fileVersion = FileUtilities.GetFileVersion(TaskEnvironment.GetAbsolutePath(filePath))?.ToString() ?? string.Empty; + Version assemblyVersion = FileUtilities.GetAssemblyName(TaskEnvironment.GetAbsolutePath(filePath))?.Version; string cultureMaybe = file.GetMetadata("Culture"); if (!string.IsNullOrEmpty(cultureMaybe)) { @@ -115,16 +118,16 @@ public override bool Execute() var depsFilePath = Path.Combine(IntermediateOutputPath, depsFileName); try { - using var depsStream = File.Create(depsFilePath); + using var depsStream = File.Create(TaskEnvironment.GetAbsolutePath(depsFilePath)); new DependencyContextWriter().Write(context, depsStream); GeneratedDepsFile = new TaskItem(depsFilePath); } catch (Exception ex) { // If there is a problem, ensure we don't write a partially complete version to disk. - if (File.Exists(depsFilePath)) + if (File.Exists(TaskEnvironment.GetAbsolutePath(depsFilePath))) { - File.Delete(depsFilePath); + File.Delete(TaskEnvironment.GetAbsolutePath(depsFilePath)); } Log.LogErrorFromException(ex, false); return false; diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/ValidateFileVersions.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/ValidateFileVersions.cs index 79bf170071f..e18441fc75f 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/ValidateFileVersions.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/ValidateFileVersions.cs @@ -6,14 +6,17 @@ using System.IO; using System.Linq; using Microsoft.Build.Framework; -using Microsoft.DotNet.Build.Tasks; namespace Microsoft.DotNet.SharedFramework.Sdk { - public class ValidateFileVersions : BuildTask + [MSBuildMultiThreadableTask] + public class ValidateFileVersions : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private static readonly Version ZeroVersion = new Version(0, 0, 0, 0); + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] Files { get; set; } @@ -97,8 +100,8 @@ FileVersionData GetFileVersionData(ITaskItem file) { return new FileVersionData() { - AssemblyVersion = FileUtilities.GetAssemblyName(filePath)?.Version, - FileVersion = FileUtilities.GetFileVersion(filePath), + AssemblyVersion = FileUtilities.GetAssemblyName(TaskEnvironment.GetAbsolutePath(filePath))?.Version, + FileVersion = FileUtilities.GetFileVersion(TaskEnvironment.GetAbsolutePath(filePath)), File = file }; } diff --git a/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs b/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs index 1765b2e21d3..3ea119d279f 100644 --- a/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs +++ b/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs @@ -8,12 +8,18 @@ using Microsoft.Build.Framework; using Microsoft.SignCheck; using Microsoft.SignCheck.Logging; -using BuildTask = Microsoft.Build.Utilities.Task; namespace SignCheckTask { - public class SignCheckTask : BuildTask + // TODO: Not opted into multithreading. SignCheckRunner builds a SignatureVerificationManager whose + // static _fileVerifiers dictionary is populated by every constructor via AddFileVerifier, so + // concurrent or repeated instances race and can throw on duplicate keys. The TaskEnvironment below + // is still used for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + public class SignCheckTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + public bool EnableJarSignatureVerification { get; set; } public bool EnableXmlSignatureVerification { get; set; } @@ -74,7 +80,7 @@ private bool ExecuteImpl() List inputFiles = new List(); if (InputFiles != null) { - ArtifactFolder = ArtifactFolder ?? Environment.CurrentDirectory; + ArtifactFolder = ArtifactFolder ?? TaskEnvironment.ProjectDirectory; SearchOption fileSearchOptions = Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; foreach (var checkFile in InputFiles.Select(s => s.ItemSpec).ToArray()) @@ -85,7 +91,7 @@ private bool ExecuteImpl() } else { - var matchedFiles = Directory.GetFiles(ArtifactFolder, checkFile, fileSearchOptions); + var matchedFiles = Directory.GetFiles(TaskEnvironment.GetAbsolutePath(ArtifactFolder), checkFile, fileSearchOptions); if (matchedFiles.Length == 1) { diff --git a/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs b/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs index 7669d9f4245..36a7e23ace4 100644 --- a/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs +++ b/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Framework; -using BuildTask = Microsoft.Build.Utilities.Task; using System; using System.Collections.Generic; using System.IO; @@ -12,8 +11,16 @@ namespace Microsoft.DotNet.SignTool { - public class SignToolTask : BuildTask + // TODO: https://github.com/dotnet/arcade/issues/17378 - this task is not yet annotated with + // [MSBuildMultiThreadableTask] because signing reads and writes files through a deep helper + // chain (BatchSignUtil, ZipData, Configuration, VerifySignatures) that still resolves paths + // against the process-wide current directory. Until those helpers take AbsolutePath, MSBuild + // keeps routing this task through the out-of-proc TaskHost in multi-threaded mode. + public class SignToolTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Perform validation but do not actually send signing request to the server. /// @@ -163,7 +170,11 @@ public class SignToolTask : BuildTask // This property can be removed if https://github.com/dotnet/arcade/issues/6747 is implemented internal BatchSignInput ParsedSigningInput { get; private set; } + // MSBuildTask0005 is suppressed while this task is still routed through the TaskHost. + // See the TODO on the class declaration and https://github.com/dotnet/arcade/issues/17378. + #pragma warning disable MSBuildTask0005 public override bool Execute() + #pragma warning restore MSBuildTask0005 { try { @@ -193,7 +204,7 @@ public void ExecuteImpl() if (!DryRun) { - if (!File.Exists(DotNetPath)) + if (string.IsNullOrEmpty(DotNetPath) || !File.Exists(TaskEnvironment.GetAbsolutePath(DotNetPath))) { Log.LogError($"DotNet was not found at this path: '{DotNetPath}'."); return; @@ -209,11 +220,11 @@ public void ExecuteImpl() Log.LogError($"PkgToolPath ('{PkgToolPath}') does not exist & is required for unpacking, repacking, and notarizing .pkg files and .app bundles on MacOS."); } } - if(!string.IsNullOrEmpty(Wix3ToolsPath) && !Directory.Exists(Wix3ToolsPath)) + if(!string.IsNullOrEmpty(Wix3ToolsPath) && !Directory.Exists(TaskEnvironment.GetAbsolutePath(Wix3ToolsPath))) { Log.LogError($"Wix3ToolsPath ('{Wix3ToolsPath}') does not exist."); } - if(!string.IsNullOrEmpty(WixToolsPath) && !Directory.Exists(WixToolsPath)) + if(!string.IsNullOrEmpty(WixToolsPath) && !Directory.Exists(TaskEnvironment.GetAbsolutePath(WixToolsPath))) { Log.LogError($"WixToolsPath ('{WixToolsPath}') does not exist."); } @@ -374,7 +385,7 @@ private string GetEnclosingDirectoryOfItemsToSign() continue; } - var directoryParts = Path.GetFullPath(Path.GetDirectoryName(itemToSign.ItemSpec)).Split(separators); + var directoryParts = TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(itemToSign.ItemSpec)).Value.Split(separators); if (result == null) { result = directoryParts; diff --git a/src/Microsoft.DotNet.SourceBuild/tasks/src/ReadNuGetPackageInfos.cs b/src/Microsoft.DotNet.SourceBuild/tasks/src/ReadNuGetPackageInfos.cs index bcb95bf3191..ce4981117b0 100644 --- a/src/Microsoft.DotNet.SourceBuild/tasks/src/ReadNuGetPackageInfos.cs +++ b/src/Microsoft.DotNet.SourceBuild/tasks/src/ReadNuGetPackageInfos.cs @@ -10,8 +10,12 @@ namespace Microsoft.DotNet.SourceBuild.Tasks { - public class ReadNuGetPackageInfos : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class ReadNuGetPackageInfos : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string[] PackagePaths { get; set; } @@ -28,7 +32,8 @@ public override bool Execute() PackageInfoItems = PackagePaths .Select(p => { - PackageIdentity identity = ReadIdentity(p); + // Read through the resolved path, but keep the original spec as the item identity. + PackageIdentity identity = ReadIdentity(TaskEnvironment.GetAbsolutePath(p)); return new TaskItem( p, new Dictionary diff --git a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs index caf7252a163..03fc1cec470 100644 --- a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs +++ b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs @@ -17,8 +17,12 @@ namespace Microsoft.DotNet.SourceBuild.Tasks.UsageReport { - public class WritePackageUsageData : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class WritePackageUsageData : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + public string[] RestoredPackageFiles { get; set; } public string[] TarballPrebuiltPackageFiles { get; set; } public string[] ReferencePackageFiles { get; set; } @@ -86,8 +90,10 @@ public override bool Execute() DateTime startTime = DateTime.Now; Log.LogMessage(MessageImportance.High, "Writing package usage data..."); + // Compare resolved paths on both sides; GetPathRelativeToRoot below resolves too, so a + // raw comparison here would disagree with it whenever RootDir is relative. string[] projectDirectoriesOutsideRoot = ProjectDirectories.NullAsEmpty() - .Where(dir => !dir.StartsWith(RootDir, StringComparison.Ordinal)) + .Where(dir => !TaskEnvironment.GetAbsolutePath(dir).Value.StartsWith(AbsoluteRootDir, StringComparison.Ordinal)) .ToArray(); if (projectDirectoriesOutsideRoot.Any()) @@ -138,7 +144,7 @@ public override bool Execute() Log.LogMessage(MessageImportance.Low, "Finding project.assets.json files..."); string[] assetFiles = Directory - .GetFiles(RootDir, "project.assets.json", SearchOption.AllDirectories) + .GetFiles(TaskEnvironment.GetAbsolutePath(AbsoluteRootDir), "project.assets.json", SearchOption.AllDirectories) .Select(GetPathRelativeToRoot) .Except(IgnoredProjectAssetsJsonFiles.NullAsEmpty().Select(GetPathRelativeToRoot)) .ToArray(); @@ -147,11 +153,11 @@ public override bool Execute() { Log.LogMessage(MessageImportance.Low, "Archiving project.assets.json files..."); - Directory.CreateDirectory(Path.GetDirectoryName(ProjectAssetsJsonArchiveFile)); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(ProjectAssetsJsonArchiveFile))); using (var projectAssetArchive = new ZipArchive( - File.Open( - ProjectAssetsJsonArchiveFile, +File.Open(TaskEnvironment.GetAbsolutePath( + ProjectAssetsJsonArchiveFile), FileMode.Create, FileAccess.ReadWrite), ZipArchiveMode.Create)) @@ -160,7 +166,7 @@ public override bool Execute() // ForEach later. foreach (var relativePath in assetFiles) { - using (var stream = File.OpenRead(Path.Combine(RootDir, relativePath))) + using (var stream = File.OpenRead(TaskEnvironment.GetAbsolutePath(Path.Combine(AbsoluteRootDir, relativePath)))) using (Stream entryWriter = projectAssetArchive .CreateEntry(relativePath, CompressionLevel.Optimal) .Open()) @@ -181,7 +187,7 @@ public override bool Execute() { JObject jObj; - using (var file = File.OpenRead(Path.Combine(RootDir, assetFile))) + using (var file = File.OpenRead(TaskEnvironment.GetAbsolutePath(Path.Combine(AbsoluteRootDir, assetFile)))) using (var reader = new StreamReader(file)) using (var jsonReader = new JsonTextReader(reader)) { @@ -251,8 +257,8 @@ public override bool Execute() .ToArray() }; - Directory.CreateDirectory(Path.GetDirectoryName(DataFile)); - File.WriteAllText(DataFile, data.ToXml().ToString()); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(DataFile))); + File.WriteAllText(TaskEnvironment.GetAbsolutePath(DataFile), data.ToXml().ToString()); Log.LogMessage( MessageImportance.High, @@ -261,19 +267,55 @@ public override bool Execute() return !Log.HasLoggedErrors; } + private string _absoluteRootDir; + + /// + /// resolved against the project directory. Any trailing separator is + /// preserved, because strips exactly this prefix and its + /// callers rely on the result staying relative. + /// + private string AbsoluteRootDir + { + get + { + if (_absoluteRootDir == null) + { + string resolved = TaskEnvironment.GetAbsolutePath(RootDir); + + if (EndsWithDirectorySeparator(RootDir) && !EndsWithDirectorySeparator(resolved)) + { + resolved += Path.DirectorySeparatorChar; + } + + _absoluteRootDir = resolved; + } + + return _absoluteRootDir; + } + } + + private static bool EndsWithDirectorySeparator(string path) => + !string.IsNullOrEmpty(path) && + (path[path.Length - 1] == Path.DirectorySeparatorChar || + path[path.Length - 1] == Path.AltDirectorySeparatorChar); + private string GetPathRelativeToRoot(string path) { - if (path.StartsWith(RootDir)) + // Compare against the same resolved root that was used to enumerate these paths, + // otherwise a relative RootDir never matches the absolute results. + string absolutePath = TaskEnvironment.GetAbsolutePath(path); + + if (absolutePath.StartsWith(AbsoluteRootDir)) { - return path.Substring(RootDir.Length).Replace(Path.DirectorySeparatorChar, '/'); + return absolutePath.Substring(AbsoluteRootDir.Length).Replace(Path.DirectorySeparatorChar, '/'); } throw new ArgumentException($"Path '{path}' is not within RootDir '{RootDir}'"); } - private static string[] ReadRidsFromRuntimeJson(string path) + private string[] ReadRidsFromRuntimeJson(string path) { - var root = JObject.Parse(File.ReadAllText(path)); + var root = JObject.Parse(File.ReadAllText(TaskEnvironment.GetAbsolutePath(path))); return root["runtimes"] .Values() .Select(o => o.Name) diff --git a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WriteUsageReports.cs b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WriteUsageReports.cs index eb86e4e5b3c..8bb30b78524 100644 --- a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WriteUsageReports.cs +++ b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WriteUsageReports.cs @@ -12,11 +12,15 @@ namespace Microsoft.DotNet.SourceBuild.Tasks.UsageReport { - public class WriteUsageReports : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class WriteUsageReports : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private const string SnapshotPrefix = "PackageVersions.props.pre."; private const string SnapshotSuffix = ".xml"; + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// Source usage data JSON file. /// @@ -60,7 +64,7 @@ public class WriteUsageReports : Microsoft.Build.Utilities.Task public override bool Execute() { - UsageData data = UsageData.Parse(XElement.Parse(File.ReadAllText(DataFile))); + UsageData data = UsageData.Parse(XElement.Parse(File.ReadAllText(TaskEnvironment.GetAbsolutePath(DataFile)))); IEnumerable sourceBuildRepoOutputs = GetSourceBuildRepoOutputs(); @@ -76,9 +80,9 @@ public override bool Execute() item.GetMetadata("OriginBuildName")); } - if (File.Exists(ProdConBuildManifestFile)) + if (!string.IsNullOrEmpty(ProdConBuildManifestFile) && File.Exists(TaskEnvironment.GetAbsolutePath(ProdConBuildManifestFile))) { - var xml = XElement.Parse(File.ReadAllText(ProdConBuildManifestFile)); + var xml = XElement.Parse(File.ReadAllText(TaskEnvironment.GetAbsolutePath(ProdConBuildManifestFile))); foreach (var x in xml.Descendants("Package")) { AddProdConPackage( @@ -90,9 +94,9 @@ public override bool Execute() var poisonNupkgFilenames = new HashSet(StringComparer.OrdinalIgnoreCase); - if (File.Exists(PoisonedReportFile)) + if (!string.IsNullOrEmpty(PoisonedReportFile) && File.Exists(TaskEnvironment.GetAbsolutePath(PoisonedReportFile))) { - foreach (string line in File.ReadAllLines(PoisonedReportFile)) + foreach (string line in File.ReadAllLines(TaskEnvironment.GetAbsolutePath(PoisonedReportFile))) { string[] segments = line.Split('\\'); if (segments.Length > 2 && @@ -156,10 +160,10 @@ public override bool Execute() report.Add(annotatedUsages.Select(u => u.ToXml())); - Directory.CreateDirectory(OutputDirectory); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(OutputDirectory)); File.WriteAllText( - Path.Combine(OutputDirectory, "annotated-usage.xml"), +TaskEnvironment.GetAbsolutePath(Path.Combine(OutputDirectory, "annotated-usage.xml")), report.ToString()); return !Log.HasLoggedErrors; @@ -170,7 +174,7 @@ private RepoOutput[] GetSourceBuildRepoOutputs() var pvpSnapshotFiles = PackageVersionPropsSnapshots.NullAsEmpty() .Select(item => { - var content = File.ReadAllText(item.ItemSpec); + var content = File.ReadAllText(TaskEnvironment.GetAbsolutePath(item.ItemSpec)); return new { Path = item.ItemSpec, diff --git a/src/Microsoft.DotNet.SourceBuild/tasks/src/WriteBuildOutputProps.cs b/src/Microsoft.DotNet.SourceBuild/tasks/src/WriteBuildOutputProps.cs index 1b32c5f1112..00caa67cd01 100644 --- a/src/Microsoft.DotNet.SourceBuild/tasks/src/WriteBuildOutputProps.cs +++ b/src/Microsoft.DotNet.SourceBuild/tasks/src/WriteBuildOutputProps.cs @@ -13,12 +13,16 @@ namespace Microsoft.DotNet.SourceBuild.Tasks { - public class WriteBuildOutputProps : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class WriteBuildOutputProps : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private static readonly Regex InvalidElementNameCharRegex = new Regex(@"(^|[^A-Za-z0-9])(?.)"); public const string CreationTimePropertyName = "BuildOutputPropsCreationTime"; + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] NuGetPackages { get; set; } @@ -65,16 +69,18 @@ public override bool Execute() .ToArray(); var additionalAssets = (AdditionalAssetDirs ?? new string[0]) - .Where(Directory.Exists) + .Where(dir => !string.IsNullOrEmpty(dir)) + .Select(dir => TaskEnvironment.GetAbsolutePath(dir)) + .Where(dir => Directory.Exists(dir)) .Where(dir => Directory.GetDirectories(dir).Count() > 0) .Select(dir => new { Name = new DirectoryInfo(dir).Name + "Version", - Version = new DirectoryInfo(Directory.EnumerateDirectories(dir).OrderBy(s => s).Last()).Name + Version = new DirectoryInfo(TaskEnvironment.GetAbsolutePath(Directory.EnumerateDirectories(dir).OrderBy(s => s).Last())).Name }).ToArray(); - Directory.CreateDirectory(Path.GetDirectoryName(OutputPath)); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(OutputPath))); - using (var outStream = File.Open(OutputPath, FileMode.Create)) + using (var outStream = File.Open(TaskEnvironment.GetAbsolutePath(OutputPath), FileMode.Create)) using (var sw = new StreamWriter(outStream, new UTF8Encoding(false))) { sw.WriteLine(@""); diff --git a/src/Microsoft.DotNet.SwaggerGenerator/Microsoft.DotNet.SwaggerGenerator.MSBuild/GenerateSwaggerCode.cs b/src/Microsoft.DotNet.SwaggerGenerator/Microsoft.DotNet.SwaggerGenerator.MSBuild/GenerateSwaggerCode.cs index fe67d428640..8bb4503c1e1 100644 --- a/src/Microsoft.DotNet.SwaggerGenerator/Microsoft.DotNet.SwaggerGenerator.MSBuild/GenerateSwaggerCode.cs +++ b/src/Microsoft.DotNet.SwaggerGenerator/Microsoft.DotNet.SwaggerGenerator.MSBuild/GenerateSwaggerCode.cs @@ -16,8 +16,12 @@ namespace Microsoft.DotNet.SwaggerGenerator.MSBuild { - public class GenerateSwaggerCode : Microsoft.Build.Utilities.Task + [MSBuildMultiThreadableTask] + public class GenerateSwaggerCode : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string SwaggerDocumentUri { get; set; } @@ -76,7 +80,7 @@ private async System.Threading.Tasks.Task ExecuteAsync() List code = codeFactory.GenerateCode(model, options); Log.LogMessage(MessageImportance.High, $"Generating {SwaggerDocumentUri} -> {OutputDirectory}"); - var outputDirectory = new DirectoryInfo(OutputDirectory); + var outputDirectory = new DirectoryInfo(TaskEnvironment.GetAbsolutePath(OutputDirectory)); outputDirectory.Create(); var generatedFiles = new List(); diff --git a/src/Microsoft.DotNet.XliffTasks/Model/Document.cs b/src/Microsoft.DotNet.XliffTasks/Model/Document.cs index ff73f0e0c34..da08d27ae42 100644 --- a/src/Microsoft.DotNet.XliffTasks/Model/Document.cs +++ b/src/Microsoft.DotNet.XliffTasks/Model/Document.cs @@ -26,7 +26,7 @@ internal abstract class Document /// /// Loads (or reloads) the document content from the given file path. /// - public void Load(string path) + public void Load(Microsoft.Build.Framework.AbsolutePath path) { using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); Load(stream); @@ -56,7 +56,7 @@ public void Load(Stream stream) /// /// Saves the document's content to the given file path. /// - public void Save(string path) + public void Save(Microsoft.Build.Framework.AbsolutePath path) { //On Windows: // Readers will prevent the file from being overwritten due to FileShare.Read. @@ -67,7 +67,7 @@ public void Save(string path) // reading is happening, each reader will see file before or after overwrite, not in between EnsureContent(); - string tempPath = Path.Combine(Path.GetDirectoryName(path), Path.GetRandomFileName()); + Microsoft.Build.Framework.AbsolutePath tempPath = new(Path.Combine(Path.GetDirectoryName(path), Path.GetRandomFileName())); using (FileStream stream = File.Open(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) { diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/EnsureAllResourcesTranslated.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/EnsureAllResourcesTranslated.cs index 181c8f013df..81a969fb11a 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/EnsureAllResourcesTranslated.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/EnsureAllResourcesTranslated.cs @@ -9,6 +9,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class EnsureAllResourcesTranslated : XlfTask { [Required] @@ -39,7 +40,7 @@ protected override void ExecuteCore() try { - xlfDocument = XlfTask.LoadXlfDocument(xlfPath); + xlfDocument = XlfTask.LoadXlfDocument(TaskEnvironment.GetAbsolutePath(xlfPath)); } catch (FileNotFoundException) { diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/GatherTranslatedSource.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/GatherTranslatedSource.cs index 67d3a1543d9..2d71e120a6e 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/GatherTranslatedSource.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/GatherTranslatedSource.cs @@ -8,6 +8,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class GatherTranslatedSource : XlfTask { [Required] @@ -103,13 +104,13 @@ private static void AdjustLogicalName(ITaskItem xlf, ITaskItem output, string la } } - private static void AdjustDependentUpon(ITaskItem xlf, ITaskItem output) + private void AdjustDependentUpon(ITaskItem xlf, ITaskItem output) { string dependentUpon = xlf.GetMetadata(MetadataKey.DependentUpon); if (!string.IsNullOrEmpty(dependentUpon)) { string sourceDirectory = Path.GetDirectoryName(xlf.GetMetadataOrThrow(MetadataKey.XlfSource)); - dependentUpon = Path.GetFullPath(Path.Combine(sourceDirectory, dependentUpon)); + dependentUpon = TaskEnvironment.GetAbsolutePath(Path.Combine(sourceDirectory, dependentUpon)); output.SetMetadata(MetadataKey.DependentUpon, dependentUpon); } } diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/GatherXlf.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/GatherXlf.cs index 5c0665792ad..e651c13e66f 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/GatherXlf.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/GatherXlf.cs @@ -9,6 +9,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class GatherXlf : XlfTask { [Required] diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/SortXlf.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/SortXlf.cs index 825f6071ae0..d88242cb635 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/SortXlf.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/SortXlf.cs @@ -7,6 +7,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class SortXlf : XlfTask { [Required] @@ -28,7 +29,7 @@ protected override void ExecuteCore() try { - xlfDocument = XlfTask.LoadXlfDocument(xlfPath); + xlfDocument = XlfTask.LoadXlfDocument(TaskEnvironment.GetAbsolutePath(xlfPath)); } catch (FileNotFoundException) { @@ -42,8 +43,8 @@ protected override void ExecuteCore() continue; // no changes } - Directory.CreateDirectory(Path.GetDirectoryName(xlfPath)); - xlfDocument.Save(xlfPath); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(xlfPath))); + xlfDocument.Save(TaskEnvironment.GetAbsolutePath(xlfPath)); } } } diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/TransformTemplates.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/TransformTemplates.cs index 37d1f9c611e..0b08d5bb7c2 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/TransformTemplates.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/TransformTemplates.cs @@ -12,6 +12,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class TransformTemplates : XlfTask { [Required] @@ -75,15 +76,15 @@ private ITaskItem TransformTemplate(ITaskItem template, string language, IDictio string localizedTemplateDirectory = transformingDefaultTemplate ? Path.Combine(TranslatedOutputDirectory, $"{templateName}.default.1033") : Path.Combine(TranslatedOutputDirectory, $"{templateName}.{language}"); - Directory.CreateDirectory(localizedTemplateDirectory); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(localizedTemplateDirectory)); string cultureSpecificTemplateFile = Path.Combine(localizedTemplateDirectory, Path.GetFileName(template.ItemSpec)); - File.Copy(templatePath, cultureSpecificTemplateFile, overwrite: true); + File.Copy(TaskEnvironment.GetAbsolutePath(templatePath), TaskEnvironment.GetAbsolutePath(cultureSpecificTemplateFile), overwrite: true); // copy the template project files foreach (XElement projectNode in templateXml.Descendants().Where(d => d.Name.LocalName == "Project")) { string projectFileFullPath = Path.Combine(templateDirectory, projectNode.Attribute("File").Value); - File.Copy(projectFileFullPath, Path.Combine(localizedTemplateDirectory, Path.GetFileName(projectNode.Attribute("File").Value)), overwrite: true); + File.Copy(TaskEnvironment.GetAbsolutePath(projectFileFullPath), TaskEnvironment.GetAbsolutePath(Path.Combine(localizedTemplateDirectory, Path.GetFileName(projectNode.Attribute("File").Value))), overwrite: true); } // copy the template project items @@ -95,10 +96,10 @@ private ITaskItem TransformTemplate(ITaskItem template, string language, IDictio { // if not localizing anything, simply strip out the translation markers UnstructuredDocument document = new(); - document.Load(templateItemFullPath); + document.Load(TaskEnvironment.GetAbsolutePath(templateItemFullPath)); Dictionary defaultTranslation = document.Nodes.ToDictionary(node => node.Id, node => node.Source); document.Translate(defaultTranslation); - document.Save(templateItemDestinationPath); + document.Save(TaskEnvironment.GetAbsolutePath(templateItemDestinationPath)); } else { @@ -111,12 +112,12 @@ private ITaskItem TransformTemplate(ITaskItem template, string language, IDictio ".", language, Path.GetExtension(unstructuredResource.ItemSpec)); - File.Copy(Path.Combine(TranslatedOutputDirectory, localizedFileName), templateItemDestinationPath, overwrite: true); + File.Copy(TaskEnvironment.GetAbsolutePath(Path.Combine(TranslatedOutputDirectory, localizedFileName)), TaskEnvironment.GetAbsolutePath(templateItemDestinationPath), overwrite: true); } else { // copy the original unaltered file - File.Copy(templateItemFullPath, templateItemDestinationPath, overwrite: true); + File.Copy(TaskEnvironment.GetAbsolutePath(templateItemFullPath), TaskEnvironment.GetAbsolutePath(templateItemDestinationPath), overwrite: true); } } } diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/TranslateSource.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/TranslateSource.cs index 1e9aeca0ebc..b0ca5cf824b 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/TranslateSource.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/TranslateSource.cs @@ -8,6 +8,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class TranslateSource : XlfTask { [Required] @@ -20,8 +21,8 @@ protected override void ExecuteCore() string language = XlfFile.GetMetadataOrThrow(MetadataKey.XlfLanguage); string translatedFullPath = XlfFile.GetMetadataOrThrow(MetadataKey.XlfTranslatedFullPath); - TranslatableDocument sourceDocument = XlfTask.LoadSourceDocument(sourcePath, XlfFile.GetMetadata(MetadataKey.XlfSourceFormat)); - XlfDocument xlfDocument = XlfTask.LoadXlfDocument(XlfFile.ItemSpec); + TranslatableDocument sourceDocument = XlfTask.LoadSourceDocument(TaskEnvironment.GetAbsolutePath(sourcePath), XlfFile.GetMetadata(MetadataKey.XlfSourceFormat)); + XlfDocument xlfDocument = XlfTask.LoadXlfDocument(TaskEnvironment.GetAbsolutePath(XlfFile.ItemSpec)); bool validationFailed = false; xlfDocument.Validate(validationError => @@ -36,10 +37,10 @@ protected override void ExecuteCore() sourceDocument.Translate(translations); - Directory.CreateDirectory(Path.GetDirectoryName(translatedFullPath)); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(translatedFullPath))); - sourceDocument.RewriteRelativePathsToAbsolute(Path.GetFullPath(sourcePath)); - sourceDocument.Save(translatedFullPath); + sourceDocument.RewriteRelativePathsToAbsolute(TaskEnvironment.GetAbsolutePath(sourcePath)); + sourceDocument.Save(TaskEnvironment.GetAbsolutePath(translatedFullPath)); } } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/UpdateXlf.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/UpdateXlf.cs index 340c113bfca..f5630202648 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/UpdateXlf.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/UpdateXlf.cs @@ -7,6 +7,7 @@ namespace XliffTasks.Tasks { + [MSBuildMultiThreadableTask] public sealed class UpdateXlf : XlfTask { [Required] @@ -19,7 +20,7 @@ public sealed class UpdateXlf : XlfTask public bool AllowModification { get; set; } private const string HowToUpdate = - "Run `msbuild /t:UpdateXlf` to update .xlf files or set UpdateXlfOnBuild=true" + "Run `dotnet build /t:UpdateXlf` to update .xlf files or set UpdateXlfOnBuild=true" + " to update them on every build, but note that it is strongly discouraged to set" + " UpdateXlfOnBuild=true in official/CI build environments as they should not" + " modify source code during the build."; @@ -31,19 +32,21 @@ protected override void ExecuteCore() string sourcePath = item.ItemSpec; string sourceDocumentPath = item.GetMetadataOrDefault(MetadataKey.SourceDocumentPath, item.ItemSpec); string sourceFormat = item.GetMetadataOrThrow(MetadataKey.XlfSourceFormat); - TranslatableDocument sourceDocument = XlfTask.LoadSourceDocument(sourcePath, sourceFormat); + TranslatableDocument sourceDocument = XlfTask.LoadSourceDocument(TaskEnvironment.GetAbsolutePath(sourcePath), sourceFormat); string sourceDocumentId = XlfTask.GetSourceDocumentId(sourcePath); foreach (string language in Languages) { string xlfPath = XlfTask.GetXlfPath(sourceDocumentPath, language); + Microsoft.Build.Framework.AbsolutePath absoluteXlfPath = TaskEnvironment.GetAbsolutePath(xlfPath); XlfDocument xlfDocument; try { - xlfDocument = XlfTask.LoadXlfDocument(xlfPath, language, createIfNonExistent: AllowModification); + xlfDocument = XlfTask.LoadXlfDocument(absoluteXlfPath, language, createIfNonExistent: AllowModification); } - catch (FileNotFoundException fileNotFoundEx) when (fileNotFoundEx.FileName == xlfPath) + // LoadXlfDocument reports the absolute path it was given, so compare against that. + catch (FileNotFoundException fileNotFoundEx) when (fileNotFoundEx.FileName == absoluteXlfPath.Value) { Release.Assert(!AllowModification); throw new BuildErrorException($"'{xlfPath}' for '{sourcePath}' does not exist. {HowToUpdate}"); @@ -68,8 +71,8 @@ protected override void ExecuteCore() throw new BuildErrorException($"'{xlfPath}' is out-of-date with '{sourcePath}'. {HowToUpdate}"); } - Directory.CreateDirectory(Path.GetDirectoryName(xlfPath)); - xlfDocument.Save(xlfPath); + Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName(absoluteXlfPath))); + xlfDocument.Save(absoluteXlfPath); } } } diff --git a/src/Microsoft.DotNet.XliffTasks/Tasks/XlfTask.cs b/src/Microsoft.DotNet.XliffTasks/Tasks/XlfTask.cs index dd739fae456..e4e5293b95c 100644 --- a/src/Microsoft.DotNet.XliffTasks/Tasks/XlfTask.cs +++ b/src/Microsoft.DotNet.XliffTasks/Tasks/XlfTask.cs @@ -5,10 +5,11 @@ using System; using System.IO; using XliffTasks.Model; +using Microsoft.Build.Framework; namespace XliffTasks.Tasks { - public abstract class XlfTask : Task + public abstract class XlfTask : Task, IMultiThreadableTask { /// /// The language of the neutral (language-agnostic) .xlf file, which is handed to the @@ -24,6 +25,9 @@ internal XlfTask() { } + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + public sealed override bool Execute() { try @@ -40,7 +44,7 @@ public sealed override bool Execute() protected abstract void ExecuteCore(); - internal static TranslatableDocument LoadSourceDocument(string path, string format) + internal static TranslatableDocument LoadSourceDocument(Microsoft.Build.Framework.AbsolutePath path, string format) { TranslatableDocument document; @@ -76,7 +80,7 @@ internal static TranslatableDocument LoadSourceDocument(string path, string form return document; } - internal static XlfDocument LoadXlfDocument(string path, string language = null, bool createIfNonExistent = false) + internal static XlfDocument LoadXlfDocument(Microsoft.Build.Framework.AbsolutePath path, string language = null, bool createIfNonExistent = false) { XlfDocument document = new(); From 7c94aac1a90e51998447f9b88ea3a0a896b9d753 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:44:35 +0200 Subject: [PATCH 2/9] Address multithreading review feedback - GetPackageDescription: stop memoizing failed description loads. GetOrAdd cached the null returned after an IOException, so every later invocation on the node silently got null without retrying or re-logging. - GetAssemblyFullName, ExecWithRetries: implement IMultiThreadableTask and resolve paths through TaskEnvironment. ExecWithRetries forwards its TaskEnvironment into the nested Exec instance, which MSBuild does not inject into because the task constructs it directly. - InstallDotNetTool, PushToBuildStorage, SingleError, LaunchDebugger: remove the MSBuildMultiThreadableTask attribute. These are not yet safe to run in-process, so they must keep routing to a TaskHost until they are migrated properly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/GetAssemblyFullName.cs | 7 +++-- .../src/SingleError.cs | 5 +++- .../src/LaunchDebugger.cs | 5 +++- .../src/PublishArtifactsInManifest.cs | 7 +++++ .../src/PublishSignedAssets.cs | 7 +++++ .../src/PushToBuildStorage.cs | 7 ++++- .../src/CreateLightCommandPackageDrop.cs | 7 +++++ .../src/ExecWithRetries.cs | 11 ++++++- .../src/GetPackageDescription.cs | 14 ++++++++- .../src/CreateVisualStudioWorkload.wix.cs | 7 +++++ .../GenPartialFacadeSource.cs | 7 +++++ .../NotSupportedAssemblyGenerator.cs | 7 +++++ .../Sdk/InstallDotNetTool.cs | 30 +++++++++++++++---- .../SignCheckTask.cs | 7 +++++ .../src/SignToolTask.cs | 7 +++++ 15 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/src/GetAssemblyFullName.cs b/src/Microsoft.DotNet.Arcade.Sdk/src/GetAssemblyFullName.cs index 7c7b820add6..6f185672b3e 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/src/GetAssemblyFullName.cs +++ b/src/Microsoft.DotNet.Arcade.Sdk/src/GetAssemblyFullName.cs @@ -8,8 +8,11 @@ namespace Microsoft.DotNet.Arcade.Sdk { [MSBuildMultiThreadableTask] - public class GetAssemblyFullName : Microsoft.Build.Utilities.Task + public class GetAssemblyFullName : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[] Items { get; set; } @@ -28,7 +31,7 @@ public override bool Execute() foreach (var item in Items) { var assemblyPath = string.IsNullOrEmpty(PathMetadata) ? item.ItemSpec : item.GetMetadata(PathMetadata); - item.SetMetadata(FullNameMetadata, AssemblyName.GetAssemblyName(assemblyPath).FullName); + item.SetMetadata(FullNameMetadata, AssemblyName.GetAssemblyName(TaskEnvironment.GetAbsolutePath(assemblyPath)).FullName); } return true; diff --git a/src/Microsoft.DotNet.Arcade.Sdk/src/SingleError.cs b/src/Microsoft.DotNet.Arcade.Sdk/src/SingleError.cs index a95c8647ff5..b1e5f3d2310 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/src/SingleError.cs +++ b/src/Microsoft.DotNet.Arcade.Sdk/src/SingleError.cs @@ -6,7 +6,10 @@ namespace Microsoft.DotNet.Arcade.Sdk { - [MSBuildMultiThreadableTask] + // Not opted into multithreading: GetRegisteredTaskObject followed by RegisterTaskObject is not + // atomic, so two instances with the same Text running concurrently in one node can both miss the + // sentinel and both log the error, defeating the single-error contract. Opting in requires an + // atomic register-if-absent primitive, which the BuildEngine task-object API does not offer. public sealed class SingleError : Microsoft.Build.Utilities.Task { private static readonly string s_cacheKeyPrefix = "SingleError-F88E25C6-1488-4E81-A458-A0921794E6E3:"; diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs index 22863ad6aa4..011ce7b9777 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs @@ -7,7 +7,10 @@ namespace Microsoft.DotNet.Build.Tasks.Feed { - [MSBuildMultiThreadableTask] + // Not opted into multithreading, and deliberately never will be: Debugger.Launch attaches a + // debugger to the whole process. In a shared multithreaded node that would affect every project + // building on that node, and concurrent invocations would race to attach. Keeping this + // diagnostic task in the out-of-proc TaskHost confines the attach to a single sidecar. public class LaunchDebugger : Microsoft.Build.Utilities.Task { public override bool Execute() diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishArtifactsInManifest.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishArtifactsInManifest.cs index c74f8252c1c..434647188cb 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishArtifactsInManifest.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishArtifactsInManifest.cs @@ -29,6 +29,13 @@ namespace Microsoft.DotNet.Build.Tasks.Feed /// whose shared base resolves Azure credentials from the process environment. The TaskEnvironment /// below is still used for path resolution. /// Tracked by https://github.com/dotnet/arcade/issues/17378. + /// + /// Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + /// the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + /// interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + /// the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + /// "make this safe" - that would revert the path resolution below to the process current + /// directory while leaving the task exactly as unsafe as it is now. /// public class PublishArtifactsInManifest : MSBuildTaskBase, IMultiThreadableTask { diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSignedAssets.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSignedAssets.cs index fc5fef7aa91..849f897efe3 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSignedAssets.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PublishSignedAssets.cs @@ -19,6 +19,13 @@ namespace Microsoft.DotNet.Build.Tasks.Feed.src // by reading AZURESUBSCRIPTION_*, SYSTEM_ACCESSTOKEN and workload-identity variables straight // from the process environment. The TaskEnvironment below is still used for path resolution. // Tracked by https://github.com/dotnet/arcade/issues/17378. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class PublishSignedAssets : PublishArtifactsInManifestBase, IMultiThreadableTask { private static readonly string AzureDevOpsScope = "499b84ac-1321-427f-aa17-267ca6975798/.default"; diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PushToBuildStorage.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PushToBuildStorage.cs index 98a7a74d4a8..39dd347a7a1 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/PushToBuildStorage.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/PushToBuildStorage.cs @@ -45,7 +45,12 @@ namespace Microsoft.DotNet.Build.Tasks.Feed /// - FutureArtifactName and FutureArtifactPublishBasePath are not set. /// - Publishing version should be v3. /// - [MSBuildMultiThreadableTask] + // TODO: https://github.com/dotnet/arcade/issues/17378 - not yet annotated with + // [MSBuildMultiThreadableTask]. The six *LocalStorageDir inputs and AssetManifestPath are + // written through IFileSystem without being resolved, and the artifact item paths they are + // combined with would need the same treatment, so a partial migration here would silently + // publish to the wrong locations rather than fail. MSBuild keeps routing this task through the + // out-of-proc TaskHost until the storage paths and artifact items are resolved together. public class PushToBuildStorage : MSBuildTaskBase { [Required] diff --git a/src/Microsoft.DotNet.Build.Tasks.Installers/src/CreateLightCommandPackageDrop.cs b/src/Microsoft.DotNet.Build.Tasks.Installers/src/CreateLightCommandPackageDrop.cs index f850987f47c..1ce4e0154de 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Installers/src/CreateLightCommandPackageDrop.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Installers/src/CreateLightCommandPackageDrop.cs @@ -11,6 +11,13 @@ namespace Microsoft.DotNet.Build.Tasks.Installers // CreateWixCommandPackageDropBase, which still passes raw OutputFolder, InstallerFile and // WixSrcFiles paths to File/Directory APIs. The TaskEnvironment below is still used for path // resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class CreateLightCommandPackageDrop : CreateWixCommandPackageDropBase, IMultiThreadableTask { /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. diff --git a/src/Microsoft.DotNet.Build.Tasks.Installers/src/ExecWithRetries.cs b/src/Microsoft.DotNet.Build.Tasks.Installers/src/ExecWithRetries.cs index e4cd95936b6..0de82bca694 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Installers/src/ExecWithRetries.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Installers/src/ExecWithRetries.cs @@ -15,8 +15,11 @@ namespace Microsoft.DotNet.Build.Tasks.Installers /// Run a command and retry if the exit code is not 0. /// [MSBuildMultiThreadableTask] - public class ExecWithRetries : Microsoft.Build.Utilities.Task, ICancelableTask + public class ExecWithRetries : Microsoft.Build.Utilities.Task, ICancelableTask, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string Command { get; set; } @@ -67,6 +70,12 @@ public override bool Execute() _runningExec = new Exec { BuildEngine = BuildEngine, + // Exec derives from ToolTask, which resolves WorkingDirectory and builds the child + // process environment through TaskEnvironment. MSBuild only injects that into tasks + // it instantiates itself, so this nested instance has to be given ours explicitly; + // otherwise a relative WorkingDirectory would resolve against the shared node's + // current directory instead of the project directory. + TaskEnvironment = TaskEnvironment, Command = Command, WorkingDirectory = WorkingDirectory, IgnoreStandardErrorWarningFormat = IgnoreStandardErrorWarningFormat, diff --git a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetPackageDescription.cs b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetPackageDescription.cs index cff8c135ef6..357ba0d1ff2 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetPackageDescription.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetPackageDescription.cs @@ -64,7 +64,19 @@ public override bool Execute() return false; } - Dictionary descriptionTable = s_descriptionCache.GetOrAdd(descriptionPath, LoadDescriptions); + if (!s_descriptionCache.TryGetValue(descriptionPath, out Dictionary descriptionTable)) + { + descriptionTable = LoadDescriptions(descriptionPath); + + // Only successful loads are cached. LoadDescriptions returns null after logging an + // IOException or UnauthorizedAccessException, and caching that would memoize a + // transient failure for every later invocation on this node. A concurrent race just + // parses the document twice, which is harmless. + if (descriptionTable != null) + { + s_descriptionCache.TryAdd(descriptionPath, descriptionTable); + } + } string description = null; diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs index 857a3a692da..bf9c0efa6d7 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/CreateVisualStudioWorkload.wix.cs @@ -24,6 +24,13 @@ namespace Microsoft.DotNet.Build.Tasks.Workloads // deep helper chain (WorkloadPackageBase, MsiBase, SwixProject, EmbeddedTemplates, Utils) that // still resolves paths against the process-wide current directory. Until those helpers take // AbsolutePath, MSBuild keeps routing this task through the out-of-proc TaskHost. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class CreateVisualStudioWorkload : VisualStudioWorkloadTaskBase, IMultiThreadableTask { /// diff --git a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs index 6e2814b4a07..2cf34465eed 100644 --- a/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs +++ b/src/Microsoft.DotNet.GenFacades/GenPartialFacadeSource.cs @@ -13,6 +13,13 @@ namespace Microsoft.DotNet.GenFacades // process-wide AssemblyLoadContext.Resolving event, so with differing RoslynAssembliesPath values // one instance can satisfy another instance's resolution. The TaskEnvironment below is still used // for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class GenPartialFacadeSource : RoslynBuildTask, IMultiThreadableTask { /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. diff --git a/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs b/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs index 34da03ab50e..556fdd86f1c 100644 --- a/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs +++ b/src/Microsoft.DotNet.GenFacades/NotSupportedAssemblyGenerator.cs @@ -21,6 +21,13 @@ namespace Microsoft.DotNet.GenFacades /// process-wide AssemblyLoadContext.Resolving event, so with differing RoslynAssembliesPath values /// one instance can satisfy another instance's resolution. The TaskEnvironment below is still used /// for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + /// + /// Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + /// the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + /// interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + /// the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + /// "make this safe" - that would revert the path resolution below to the process current + /// directory while leaving the task exactly as unsafe as it is now. /// public class NotSupportedAssemblyGenerator : RoslynBuildTask, IMultiThreadableTask { diff --git a/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs b/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs index 6290fafa470..d49f6ff36aa 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/InstallDotNetTool.cs @@ -15,9 +15,24 @@ namespace Microsoft.DotNet.Helix.Sdk /// Task that installs a .NET tool in a given folder. /// Handles parallel builds that install the same tool. /// - [MSBuildMultiThreadableTask] - public class InstallDotNetTool : MSBuildTaskBase + // TODO: https://github.com/dotnet/arcade/issues/17378 - not yet annotated with + // [MSBuildMultiThreadableTask]. Paths are resolved through TaskEnvironment below, but the child + // `dotnet tool install` process is spawned through Microsoft.Arcade.Common's ICommandFactory, + // which builds its ProcessStartInfo from the ambient process environment rather than from an + // injected TaskEnvironment. Per-project environment variables would therefore leak between + // projects sharing a node, so MSBuild keeps routing this task through the out-of-proc TaskHost. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. + public class InstallDotNetTool : MSBuildTaskBase, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + /// /// The name of the tool to install (same as the NuGet package name, e.g. Microsoft.DotNet.XHarness.CLI) /// @@ -97,11 +112,12 @@ public bool ExecuteTask(ICommandFactory commandFactory, IFileSystem fileSystem, // We install the tool in [dest]/[name]/[version] because if we tried to install 2 versions in the same dir, // `dotnet tool install` would fail. var version = Version.ToLowerInvariant(); - ToolPath = Path.Combine(DestinationPath, Name, Version); + string absoluteDestinationPath = TaskEnvironment.GetAbsolutePath(DestinationPath); + ToolPath = Path.Combine(absoluteDestinationPath, Name, Version); if (!fileSystem.DirectoryExists(ToolPath)) { - fileSystem.CreateDirectory(DestinationPath); + fileSystem.CreateDirectory(absoluteDestinationPath); } string versionInstallPath = Path.Combine(ToolPath, ".store", Name.ToLowerInvariant(), version); @@ -157,14 +173,16 @@ private bool InstallTool(ICommandFactory commandFactory) args.Add(Name); - var executable = string.IsNullOrEmpty(DotnetPath) ? "dotnet" : DotnetPath; + // A bare "dotnet" is resolved through PATH by the process launcher and must stay + // unresolved; only an explicitly supplied path is made absolute. + var executable = string.IsNullOrEmpty(DotnetPath) ? "dotnet" : (string)TaskEnvironment.GetAbsolutePath(DotnetPath); Log.LogMessage($"Executing {DotnetPath} {string.Join(" ", args)}"); ICommand command = commandFactory.Create(executable, args); if (!string.IsNullOrEmpty(WorkingDirectory)) { - command.WorkingDirectory(WorkingDirectory); + command.WorkingDirectory(TaskEnvironment.GetAbsolutePath(WorkingDirectory)); } CommandResult result = command.Execute(); diff --git a/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs b/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs index 3ea119d279f..82c27839b80 100644 --- a/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs +++ b/src/Microsoft.DotNet.SignCheckTask/SignCheckTask.cs @@ -15,6 +15,13 @@ namespace SignCheckTask // static _fileVerifiers dictionary is populated by every constructor via AddFileVerifier, so // concurrent or repeated instances race and can throw on duplicate keys. The TaskEnvironment below // is still used for path resolution. Tracked by https://github.com/dotnet/arcade/issues/17378. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class SignCheckTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. diff --git a/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs b/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs index 36a7e23ace4..6718691b035 100644 --- a/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs +++ b/src/Microsoft.DotNet.SignTool/src/SignToolTask.cs @@ -16,6 +16,13 @@ namespace Microsoft.DotNet.SignTool // chain (BatchSignUtil, ZipData, Configuration, VerifySignatures) that still resolves paths // against the process-wide current directory. Until those helpers take AbsolutePath, MSBuild // keeps routing this task through the out-of-proc TaskHost in multi-threaded mode. + // + // Implementing IMultiThreadableTask without the attribute is deliberate. Routing is decided by + // the attribute alone (TaskRouter.NeedsTaskHostInMultiThreadedMode); it cannot key off the + // interface, because ToolTask implements it and that would opt in every ToolTask-derived task in + // the ecosystem. The interface only causes TaskEnvironment to be injected. Do not remove it to + // "make this safe" - that would revert the path resolution below to the process current + // directory while leaving the task exactly as unsafe as it is now. public class SignToolTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. From 11f5b1d3ea4f6412dd05fb6eb4326f963cf10bd9 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:49:30 +0200 Subject: [PATCH 3/9] Resolve aka.ms client certificate path through TaskEnvironment CreateAkaMSLinks and DeleteAkaMSLinks are both annotated, so they run in-process, but their shared base passed the ClientCertificate input straight to File.ReadAllText. A relative path would resolve against the shared node's current directory rather than the project directory. AkaMSLinksBase now implements IMultiThreadableTask and resolves the path. Found by extending the validator to check the whole base chain for path, I/O, environment and process APIs instead of reporting attribute-only tasks as conditionally benign. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/AkaMSLinksBase.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/AkaMSLinksBase.cs b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/AkaMSLinksBase.cs index 704a968428d..7f2061faa4f 100644 --- a/src/Microsoft.DotNet.Deployment.Tasks.Links/src/AkaMSLinksBase.cs +++ b/src/Microsoft.DotNet.Deployment.Tasks.Links/src/AkaMSLinksBase.cs @@ -8,8 +8,11 @@ namespace Microsoft.DotNet.Deployment.Tasks.Links { - public abstract class AkaMSLinksBase : Microsoft.Build.Utilities.Task + public abstract class AkaMSLinksBase : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] // Authentication data public string ClientId { get; set; } @@ -24,7 +27,8 @@ protected AkaMSLinkManager CreateAkaMSLinksManager() AkaMSLinkManager manager; if (!string.IsNullOrEmpty(ClientCertificate)) { - manager = new AkaMSLinkManager(ClientId, X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(File.ReadAllText(ClientCertificate)), password: null), Tenant, Log); + string certificatePath = TaskEnvironment.GetAbsolutePath(ClientCertificate); + manager = new AkaMSLinkManager(ClientId, X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(File.ReadAllText(certificatePath)), password: null), Tenant, Log); } else if (!string.IsNullOrEmpty(ClientSecret)) { From 90e58a3c56ab34443aa9f6934a15eda3edf904e4 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:05:28 +0200 Subject: [PATCH 4/9] Document the task-object cache race in LocateDotNet and CheckRequiredDotNetVersion Both tasks do a non-atomic GetRegisteredTaskObject/RegisterTaskObject pair, which under multithreaded execution lets two threads miss and populate the cache. Both computations are pure, so the result is unchanged; the only visible effect is that CheckRequiredDotNetVersion can log a failing check's error twice. Recording this so the pattern is not mistaken for the SingleError case, where the same race defeated the task's entire purpose and the attribute had to be removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/CheckRequiredDotNetVersion.cs | 5 +++++ src/Microsoft.DotNet.Arcade.Sdk/src/LocateDotNet.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Microsoft.DotNet.Arcade.Sdk/src/CheckRequiredDotNetVersion.cs b/src/Microsoft.DotNet.Arcade.Sdk/src/CheckRequiredDotNetVersion.cs index 6cd7b82a836..3a3a900f8f9 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/src/CheckRequiredDotNetVersion.cs +++ b/src/Microsoft.DotNet.Arcade.Sdk/src/CheckRequiredDotNetVersion.cs @@ -64,6 +64,11 @@ public override bool Execute() // The cache is registered per build, not per project, so the repository and the SDK // version being validated have to be part of the entry. Otherwise a different repository // or a different required version could reuse this result. + // + // The read/write pair below is not atomic, so under multithreaded execution two threads + // can both miss and both run the check. The check itself is pure, so the result is + // identical either way; the only observable effect is that a failing check can log its + // error twice, since deduplicating that reporting is part of what the cache buys. var cachedResult = (CacheEntry)BuildEngine4.GetRegisteredTaskObject(s_cacheKey, RegisteredTaskObjectLifetime.Build); if (cachedResult != null && string.Equals(globalJsonPath.Value, cachedResult.GlobalJsonPath, StringComparison.OrdinalIgnoreCase) && diff --git a/src/Microsoft.DotNet.Arcade.Sdk/src/LocateDotNet.cs b/src/Microsoft.DotNet.Arcade.Sdk/src/LocateDotNet.cs index 85eb5da2341..8aa2f51d592 100644 --- a/src/Microsoft.DotNet.Arcade.Sdk/src/LocateDotNet.cs +++ b/src/Microsoft.DotNet.Arcade.Sdk/src/LocateDotNet.cs @@ -56,6 +56,11 @@ private void ExecuteImpl() // The cache is registered per build, not per project, so the repository identity has to // be part of the entry. Otherwise a second repository with a coincidentally matching // global.json timestamp and PATH would reuse the first repository's dotnet. + // + // The read/write pair below is not atomic, so under multithreaded execution two threads + // can both miss and both populate it. That is benign here: the computation is pure and + // deterministic for a given (global.json, timestamp, PATH), so the loser of the race + // simply overwrites an identical entry. The cache is an optimization, not a lock. var cachedResult = (CacheEntry)BuildEngine4.GetRegisteredTaskObject(s_cacheKey, RegisteredTaskObjectLifetime.Build); if (cachedResult != null && string.Equals(globalJsonPath.Value, cachedResult.GlobalJsonPath, StringComparison.OrdinalIgnoreCase) && From f98f38477ae5f58023a55421aa9a3e7d430dea64 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:41:11 +0200 Subject: [PATCH 5/9] Review: de-annotate six tasks with unverifiable ambient-state dependencies All six are P3 with no measured invocations in the hot-path profile, so in-process routing buys no measured build-time saving while taking on real correctness risk. Each keeps its `IMultiThreadableTask` implementation and `TaskEnvironment`-based path handling -- correct either way, and the groundwork for a later migration -- but loses the routing attribute, and carries a comment recording why so it is not silently re-added. - GenAPITask: `HostEnvironment` resolves the raw `LibPath`/`Assembly` values via `Environment.ExpandEnvironmentVariables` plus `Directory.Exists`/`File.Exists` (HostEnvironment.cs:719-740). The task resolves `OutputPath` and `HeaderFile` through `TaskEnvironment` but hands these two through raw -- resolved at one site, raw at another. Fixing it needs expand-then-resolve inside Cci, because resolving a path that still contains %VAR% would itself be wrong. - SendHelixJob: `JobDefinition` reads BUILD_REPOSITORY_NAME, BUILD_SOURCEBRANCH, SYSTEM_TEAMPROJECT and BUILD_REASON straight from `Environment` (JobDefinition.cs:209-223,402-423). - CreateAzureDevOpsFeed, CreateNewAzureContainer, CreateAzureContainerIfNotExists: build an `AzureCliCredential` when no PAT/AccountKey is supplied, which launches `az` from the ambient process environment. This is the same class already used to exclude PublishArtifactsInManifest*; the criterion was applied incompletely. - UploadToAzure: cancellation state is `static`, so `Cancel()` on one instance cancels the token handed to every other concurrent and future instance, and the uploads use an unlinked timeout token rather than the task token. Also avoided writing the literal attribute name in these comments, so an audit grep for the attribute does not match a class that deliberately lacks it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be19d794-fe4f-43fe-9e6e-f6e6b13ec6e7 --- .../src/CreateAzureDevOpsFeed.cs | 5 ++++- .../src/common/CreateAzureContainerIfNotExists.cs | 5 ++++- .../src/common/CreateNewAzureContainer.cs | 5 ++++- .../src/common/UploadToAzure.cs | 5 ++++- src/Microsoft.DotNet.GenAPI/GenAPITask.cs | 6 +++++- src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs | 6 +++++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/CreateAzureDevOpsFeed.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/CreateAzureDevOpsFeed.cs index ba8f56e4b9f..71084054af2 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/CreateAzureDevOpsFeed.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/CreateAzureDevOpsFeed.cs @@ -18,7 +18,10 @@ namespace Microsoft.DotNet.Build.Tasks.Feed { - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: when no PAT is supplied this task builds an + // AzureCliCredential, which resolves and launches `az` from the ambient process environment + // rather than the project's injected one. Migrating requires supplying credentials explicitly or + // launching the CLI through TaskEnvironment. public class CreateAzureDevOpsFeed : MSBuild.Task { [Output] diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateAzureContainerIfNotExists.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateAzureContainerIfNotExists.cs index 041c892d2ee..5c82ae415dd 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateAzureContainerIfNotExists.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateAzureContainerIfNotExists.cs @@ -8,7 +8,10 @@ namespace Microsoft.DotNet.Build.Tasks.Feed { - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: the AccountKey is null path builds an + // AzureCliCredential, which resolves and launches `az` from the ambient process environment + // rather than the project's injected one. Migrating requires supplying credentials explicitly or + // launching the CLI through TaskEnvironment. public class CreateAzureContainerIfNotExists : CreateAzureContainer { /// diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateNewAzureContainer.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateNewAzureContainer.cs index 5cbaa459304..df75595efcb 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateNewAzureContainer.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/CreateNewAzureContainer.cs @@ -14,7 +14,10 @@ namespace Microsoft.DotNet.Build.Tasks.Feed /// try creating a container [ContainerName]-1, [ContainerName]-2 and so on until the name is unique. /// The final name is saved in ContainerName. /// - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: the AccountKey is null path builds an + // AzureCliCredential, which resolves and launches `az` from the ambient process environment + // rather than the project's injected one. Migrating requires supplying credentials explicitly or + // launching the CLI through TaskEnvironment. public class CreateNewAzureContainer : CreateAzureContainer { public override async Task GetBlobStorageUtilsAsync() diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs index 76679e854de..ca0759a809d 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs @@ -13,7 +13,10 @@ namespace Microsoft.DotNet.Build.CloudTestTasks { - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: cancellation state below is static, so Cancel() + // on one instance cancels the token handed to every other concurrent and future instance in the + // process, and the uploads use an unlinked timeout token instead of the task token. Migrating + // requires a per-instance CancellationTokenSource and uploads linked to it. public class UploadToAzure : AzureConnectionStringBuildTask, ICancelableTask, IMultiThreadableTask { private static readonly CancellationTokenSource TokenSource = new CancellationTokenSource(); diff --git a/src/Microsoft.DotNet.GenAPI/GenAPITask.cs b/src/Microsoft.DotNet.GenAPI/GenAPITask.cs index 507ed99004a..d5278ff4b1f 100644 --- a/src/Microsoft.DotNet.GenAPI/GenAPITask.cs +++ b/src/Microsoft.DotNet.GenAPI/GenAPITask.cs @@ -19,7 +19,11 @@ namespace Microsoft.DotNet.GenAPI { - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: HostEnvironment resolves the raw LibPath and + // Assembly values below through Environment.ExpandEnvironmentVariables plus Directory.Exists/ + // File.Exists (Microsoft.Cci.Extensions/HostEnvironment.cs:719-740), so relative inputs and + // per-project variables would bind to process-wide state in a shared node. Migrating requires + // expanding and resolving those paths through TaskEnvironment before they enter HostEnvironment. public class GenAPITask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private const string InternalsVisibleTypeName = "System.Runtime.CompilerServices.InternalsVisibleToAttribute"; diff --git a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs index 0de6b4c21ea..a4e0f7a7753 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/SendHelixJob.cs @@ -16,7 +16,11 @@ namespace Microsoft.DotNet.Helix.Sdk { - [MSBuildMultiThreadableTask] + // Deliberately not marked multithreadable: SendAsync reaches JobDefinition, which reads + // BUILD_REPOSITORY_NAME, BUILD_SOURCEBRANCH, SYSTEM_TEAMPROJECT and BUILD_REASON straight from + // Environment (JobSender/JobDefinition.cs:209-223,402-423). In a shared node those reads see + // process-wide values rather than the project's, so a job could be tagged with another project's + // source metadata. Migrating requires threading TaskEnvironment into JobDefinition. public class SendHelixJob : HelixTask, IMultiThreadableTask { public static class MetadataNames From 01686432a53bc15eddf65be52abe166e729c0028 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:19:57 +0200 Subject: [PATCH 6/9] Remove the unused LaunchDebugger task LaunchDebugger has no UsingTask registration and no reference anywhere in the repo, so it is unreachable dead code. It was one of the tasks left unannotated for the multithreading migration, since Debugger.Launch attaches a debugger to the whole process and a shared multithreaded node would attach for every project building on it. Deleting it removes the exclusion instead of documenting it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be19d794-fe4f-43fe-9e6e-f6e6b13ec6e7 --- .../src/LaunchDebugger.cs | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs deleted file mode 100644 index 011ce7b9777..00000000000 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/LaunchDebugger.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Build.Utilities; -using System.Diagnostics; -using Microsoft.Build.Framework; - -namespace Microsoft.DotNet.Build.Tasks.Feed -{ - // Not opted into multithreading, and deliberately never will be: Debugger.Launch attaches a - // debugger to the whole process. In a shared multithreaded node that would affect every project - // building on that node, and concurrent invocations would race to attach. Keeping this - // diagnostic task in the out-of-proc TaskHost confines the attach to a single sidecar. - public class LaunchDebugger : Microsoft.Build.Utilities.Task - { - public override bool Execute() - { - Debugger.Launch(); - return true; - } - } -} From 9dfec6fd2053570b67e78a6be85af9ecee316542 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:34:15 +0200 Subject: [PATCH 7/9] Remove the unused UploadToAzure task UploadToAzure has no UsingTask registration and no reference anywhere in the repo, so it is unreachable dead code. Uploads go through AzureStorageAssetPublisher and BlobFeedAction instead. It was one of the tasks left unannotated for the multithreading migration: its CancellationTokenSource is static, so Cancel() on one instance cancelled the token handed to every other concurrent and future instance, and the uploads used an unlinked timeout token rather than the task token. Deleting it removes that bug along with the exclusion. Its base AzureConnectionStringBuildTask and the AzureStorageUtils helpers stay in use by CreateAzureContainer and the publishers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be19d794-fe4f-43fe-9e6e-f6e6b13ec6e7 --- .../src/common/UploadToAzure.cs | 145 ------------------ 1 file changed, 145 deletions(-) delete mode 100644 src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs deleted file mode 100644 index ca0759a809d..00000000000 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/common/UploadToAzure.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Build.Framework; -using System.Collections.Generic; -using Azure.Storage.Blobs.Specialized; -using Azure.Storage.Blobs; -using Microsoft.DotNet.Build.Tasks.Feed; - -namespace Microsoft.DotNet.Build.CloudTestTasks -{ - // Deliberately not marked multithreadable: cancellation state below is static, so Cancel() - // on one instance cancels the token handed to every other concurrent and future instance in the - // process, and the uploads use an unlinked timeout token instead of the task token. Migrating - // requires a per-instance CancellationTokenSource and uploads linked to it. - public class UploadToAzure : AzureConnectionStringBuildTask, ICancelableTask, IMultiThreadableTask - { - private static readonly CancellationTokenSource TokenSource = new CancellationTokenSource(); - private static readonly CancellationToken CancellationToken = TokenSource.Token; - - /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. - public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; - - /// - /// The name of the container to access. The specified name must be in the correct format, see the - /// following page for more info. https://msdn.microsoft.com/en-us/library/azure/dd135715.aspx - /// - [Required] - public string ContainerName { get; set; } - - /// - /// An item group of files to upload. Each item must have metadata RelativeBlobPath - /// that specifies the path relative to ContainerName where the item will be uploaded. - /// - [Required] - public ITaskItem[] Items { get; set; } - - /// - /// Indicates if the destination blob should be overwritten if it already exists. The default if false. - /// - public bool Overwrite { get; set; } = false; - - /// - /// Enables idempotency when Overwrite is false. - /// - /// false: (default) Attempting to upload an item that already exists fails. - /// - /// true: When an item already exists, download the existing blob to check if it's - /// byte-for-byte identical to the one being uploaded. If so, pass. If not, fail. - /// - public bool PassIfExistingItemIdentical { get; set; } - - /// - /// Specifies the maximum number of clients to concurrently upload blobs to azure - /// - [Obsolete] - public int MaxClients { get; set; } = 8; - - public int UploadTimeoutInMinutes { get; set; } = 5; - - public void Cancel() - { - TokenSource.Cancel(); - } - - public override bool Execute() - { - return ExecuteAsync(CancellationToken).GetAwaiter().GetResult(); - } - - public async Task ExecuteAsync(CancellationToken ct) - { - if (Items.Length == 0) - { - Log.LogError("No items were provided for upload."); - return false; - } - - Log.LogMessage("Begin uploading blobs to Azure account {0} in container {1}.", - AccountName, - ContainerName); - - try - { - AzureStorageUtils blobUtils = new AzureStorageUtils(AccountName, AccountKey, ContainerName); - - List uploadTasks = new List(); - - foreach (var item in Items) - { - uploadTasks.Add(Task.Run(async () => - { - string relativeBlobPath = item.GetMetadata("RelativeBlobPath"); - - if (string.IsNullOrEmpty(relativeBlobPath)) - { - throw new Exception(string.Format("Metadata 'RelativeBlobPath' is missing for item '{0}'.", item.ItemSpec)); - } - - if (!File.Exists(TaskEnvironment.GetAbsolutePath(item.ItemSpec))) - { - throw new Exception(string.Format("The file '{0}' does not exist.", item.ItemSpec)); - } - - BlobClient blobReference = blobUtils.GetBlob(relativeBlobPath); - - if (!Overwrite && await blobReference.ExistsAsync()) - { - if (PassIfExistingItemIdentical) - { - if (await blobReference.IsFileIdenticalToBlobAsync(TaskEnvironment.GetAbsolutePath(item.ItemSpec))) - { - return; - } - } - - throw new Exception(string.Format("The blob '{0}' already exists.", relativeBlobPath)); - } - - CancellationTokenSource timeoutTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(UploadTimeoutInMinutes)); - - using (Stream localFileStream = File.OpenRead(TaskEnvironment.GetAbsolutePath(item.ItemSpec))) - { - await blobReference.UploadAsync(localFileStream, timeoutTokenSource.Token); - } - })); - } - - await Task.WhenAll(uploadTasks); - - Log.LogMessage("Upload to Azure is complete, a total of {0} items were uploaded.", Items.Length); - } - catch (Exception e) - { - Log.LogErrorFromException(e, true); - } - - return !Log.HasLoggedErrors; - } - } -} From 894c05a898380dbe1d345d9d8e98e5bdc2e081a2 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:59 +0200 Subject: [PATCH 8/9] Resolve task input paths before they enter helper chains Copilot review flagged eleven annotated tasks that pass a raw, user-settable path into a helper that opens it with plain File/Directory/NuGet APIs. In a shared -mt node those bind to the process current directory rather than the invoking project. The inputs are not absolute by construction. RuntimeGraph and RuntimeIdentifierGraph come from $(RuntimeIdentifierGraphPath), which is a public settable property, and RuntimeFile comes from $(RuntimeIdGraphDefinitionFile), which is explicitly guarded with Condition="'$(...)' == ''" so it is designed to be overridden. Only the Bundled* SDK defaults are absolute by construction. Resolved through the injected TaskEnvironment: - ChooseBestTargetFrameworksTask, ChooseBestP2PTargetFrameworkTask: RuntimeGraph. These had the attribute but did not implement IMultiThreadableTask, so they had no way to resolve anything; both now do. TargetFrameworkResolver also caches by the path string, so a relative value additionally let two projects share one resolver built from different files. - GeneratePackageReport, GetApplicableAssetsFromPackages, HarvestPackage: RuntimeFile, before constructing the NuGet asset resolvers. - GenerateSharedFrameworkDepsFile: RuntimeIdentifierGraph. It is optional, so an unset value is passed through unchanged to keep the existing ReadRuntimeGraph error instead of throwing from GetAbsolutePath. - NuGetPack: BaseDirectory before PopulateFiles, and the nuspec-relative fallback. - GetCompatiblePackageTargetFrameworks: PackagePaths. Also had the attribute without the interface. - WritePackageUsageData: the four package-file arrays feeding ReadIdentity. - CreateXHarnessAndroidWorkItems, CreateXHarnessAppleWorkItems: ApkPath and AppBundlePath, plus the Apple TmpDir. Relative paths here are the documented usage in tools/xharness-runner/Readme.md, so this was the most likely of the set to bite. TaskEnvironment is added to the shared XHarnessTaskBase. The XHarness tests already covered relative paths, which is why five of them caught the change. They now resolve their MockFileSystem registrations through the same TaskEnvironment the task uses, so the relative-path coverage is kept and the resolution itself is exercised. Full non-incremental Release build: 0 errors, 0 warnings. Helix.Sdk 236, Packaging 48, PackageTesting 9, SourceBuild 1: all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be19d794-fe4f-43fe-9e6e-f6e6b13ec6e7 --- .../src/GeneratePackageReport.cs | 4 ++-- .../src/GetApplicableAssetsFromPackages.cs | 2 +- .../src/HarvestPackage.cs | 4 ++-- .../src/NuGetPack.cs | 4 +++- .../src/ChooseBestP2PTargetFrameworkTask.cs | 7 +++++-- .../src/ChooseBestTargetFrameworksTask.cs | 7 +++++-- .../CreateXHarnessAndroidWorkItemsTests.cs | 4 ++-- .../CreateXHarnessAppleWorkItemsTests.cs | 4 ++-- .../Sdk/CreateXHarnessAndroidWorkItems.cs | 5 +++++ .../Sdk/CreateXHarnessAppleWorkItems.cs | 10 +++++++++- src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs | 5 ++++- .../GetCompatiblePackageTargetFrameworks.cs | 7 +++++-- .../src/GenerateSharedFrameworkDepsFile.cs | 7 ++++++- .../tasks/src/UsageReport/WritePackageUsageData.cs | 11 +++++++---- 14 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GeneratePackageReport.cs b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GeneratePackageReport.cs index acdc457882f..6c656afa825 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GeneratePackageReport.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GeneratePackageReport.cs @@ -337,7 +337,7 @@ private void LoadFiles() } } - _resolver = new AggregateNuGetAssetResolver(RuntimeFile); + _resolver = new AggregateNuGetAssetResolver(TaskEnvironment.GetAbsolutePath(RuntimeFile)); foreach (string packageId in packageItems.Keys) { _resolver.AddPackageItems(packageId, packageItems[packageId].Select(f => f.TargetPath)); @@ -352,7 +352,7 @@ private void LoadFiles() .Select(pf => pf.TargetPath) .Where(f => !NuGetAssetResolver.IsPlaceholder(f)); - _resolverWithoutPlaceholders = new NuGetAssetResolver(RuntimeFile, filesWithoutPlaceholders); + _resolverWithoutPlaceholders = new NuGetAssetResolver(TaskEnvironment.GetAbsolutePath(RuntimeFile), filesWithoutPlaceholders); } } diff --git a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetApplicableAssetsFromPackages.cs b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetApplicableAssetsFromPackages.cs index fb0a12141bf..598fdd0d975 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetApplicableAssetsFromPackages.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/GetApplicableAssetsFromPackages.cs @@ -192,7 +192,7 @@ private void LoadFiles() } } - _resolver = new AggregateNuGetAssetResolver(RuntimeFile); + _resolver = new AggregateNuGetAssetResolver(TaskEnvironment.GetAbsolutePath(RuntimeFile)); foreach (string packageId in _packageToPackageItems.Keys) { _resolver.AddPackageItems(packageId, _packageToPackageItems[packageId].Select(f => f.TargetPath)); diff --git a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/HarvestPackage.cs b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/HarvestPackage.cs index 5a05927567c..4c84f0d158f 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/HarvestPackage.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/HarvestPackage.cs @@ -161,7 +161,7 @@ private void HarvestSupportedFrameworks() { List supportedFrameworks = new List(); - AggregateNuGetAssetResolver resolver = new AggregateNuGetAssetResolver(RuntimeFile); + AggregateNuGetAssetResolver resolver = new AggregateNuGetAssetResolver(TaskEnvironment.GetAbsolutePath(RuntimeFile)); string packagePath = _packageFolders[PackageId]; foreach (var packageFolder in _packageFolders) @@ -174,7 +174,7 @@ private void HarvestSupportedFrameworks() // and use the netstandard reference assembly to determine the API version var filesWithoutPlaceholders = GetPackageItems(packagePath) .Where(f => !NuGetAssetResolver.IsPlaceholder(f)); - NuGetAssetResolver resolverWithoutPlaceholders = new NuGetAssetResolver(RuntimeFile, filesWithoutPlaceholders); + NuGetAssetResolver resolverWithoutPlaceholders = new NuGetAssetResolver(TaskEnvironment.GetAbsolutePath(RuntimeFile), filesWithoutPlaceholders); string package = $"{PackageId}/{PackageVersion}"; diff --git a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/NuGetPack.cs b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/NuGetPack.cs index 3fa1a8ff99b..639e4aa4de6 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Packaging/src/NuGetPack.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Packaging/src/NuGetPack.cs @@ -274,7 +274,9 @@ public void Pack(string nuspecPath, string nupkgPath, Manifest manifest, bool pa PackageBuilder builder = new PackageBuilder(deterministic: Deterministic); SetDeterministicTimestamp(builder, DeterministicTimestamp); - string baseDirectoryPath = (string.IsNullOrEmpty(BaseDirectory)) ? Path.GetDirectoryName(nuspecPath) : BaseDirectory; + string baseDirectoryPath = string.IsNullOrEmpty(BaseDirectory) + ? Path.GetDirectoryName(TaskEnvironment.GetAbsolutePath(nuspecPath)) + : TaskEnvironment.GetAbsolutePath(BaseDirectory); builder.Populate(manifest.Metadata); builder.PopulateFiles(baseDirectoryPath, manifest.Files); diff --git a/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestP2PTargetFrameworkTask.cs b/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestP2PTargetFrameworkTask.cs index e9a5f95ef11..b30d2b5cf93 100644 --- a/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestP2PTargetFrameworkTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestP2PTargetFrameworkTask.cs @@ -14,11 +14,14 @@ namespace Microsoft.DotNet.Build.Tasks.TargetFramework { [MSBuildMultiThreadableTask] - public class ChooseBestP2PTargetFrameworkTask : Microsoft.Build.Utilities.Task + public class ChooseBestP2PTargetFrameworkTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private const string NEAREST_TARGET_FRAMEWORK = "NearestTargetFramework"; private const string TARGET_FRAMEWORKS = "TargetFrameworks"; + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string? RuntimeGraph { get; set; } @@ -60,7 +63,7 @@ public override bool Execute() return false; } - TargetFrameworkResolver targetFrameworkResolver = TargetFrameworkResolver.CreateOrGet(RuntimeGraph!); + TargetFrameworkResolver targetFrameworkResolver = TargetFrameworkResolver.CreateOrGet(TaskEnvironment.GetAbsolutePath(RuntimeGraph!)); List assignedProjects = new(AnnotatedProjectReferences.Length); foreach (ITaskItem annotatedProjectReference in AnnotatedProjectReferences) diff --git a/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestTargetFrameworksTask.cs b/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestTargetFrameworksTask.cs index 9b703e3d4da..aeb4a8814a8 100644 --- a/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestTargetFrameworksTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.TargetFramework/src/ChooseBestTargetFrameworksTask.cs @@ -10,8 +10,11 @@ namespace Microsoft.DotNet.Build.Tasks.TargetFramework { [MSBuildMultiThreadableTask] - public class ChooseBestTargetFrameworksTask : Microsoft.Build.Utilities.Task + public class ChooseBestTargetFrameworksTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask { + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public ITaskItem[]? BuildTargetFrameworks { get; set; } @@ -30,7 +33,7 @@ public class ChooseBestTargetFrameworksTask : Microsoft.Build.Utilities.Task public override bool Execute() { List bestTargetFrameworkList = new(BuildTargetFrameworks!.Length); - TargetFrameworkResolver targetframeworkResolver = TargetFrameworkResolver.CreateOrGet(RuntimeGraph!); + TargetFrameworkResolver targetframeworkResolver = TargetFrameworkResolver.CreateOrGet(TaskEnvironment.GetAbsolutePath(RuntimeGraph!)); foreach (ITaskItem buildTargetFramework in BuildTargetFrameworks) { diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs index 6880c3ee589..c1df1cf4f32 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAndroidWorkItemsTests.cs @@ -104,7 +104,7 @@ public void ArchivePayloadIsOverwritten() CreateApk("apks/System.Bar.apk", "System.Bar"), }; - _fileSystem.Files.Add("apks/xharness-payload-system.foo.zip", "archive"); + _fileSystem.Files.Add(_task.TaskEnvironment.GetAbsolutePath("apks/xharness-payload-system.foo.zip"), "archive"); // Act using var provider = collection.BuildServiceProvider(); @@ -245,7 +245,7 @@ private ITaskItem CreateApk( mockBundle.Setup(x => x.GetMetadata(CreateXHarnessAndroidWorkItems.MetadataNames.ApkPath)).Returns(apkPath); } - _fileSystem.WriteToFile(apkPath ?? itemSpec, "apk"); + _fileSystem.WriteToFile(_task.TaskEnvironment.GetAbsolutePath(apkPath ?? itemSpec), "apk"); return mockBundle.Object; } diff --git a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs index 7a4df6ffb1a..83dbfa6f893 100644 --- a/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs +++ b/src/Microsoft.DotNet.Helix/Sdk.Tests/Microsoft.DotNet.Helix.Sdk.Tests/CreateXHarnessAppleWorkItemsTests.cs @@ -114,7 +114,7 @@ public void ArchivePayloadIsOverwritten() CreateAppBundle("apps/System.Bar.app", "ios-simulator-64_13.5"), }; - _fileSystem.Files.Add("apps/xharness-payload-system.foo.zip", "archive"); + _fileSystem.Files.Add(_task.TaskEnvironment.GetAbsolutePath("apps/xharness-payload-system.foo.zip"), "archive"); // Act using var provider = collection.BuildServiceProvider(); @@ -318,7 +318,7 @@ private ITaskItem CreateAppBundle( mockBundle.Setup(x => x.GetMetadata(CreateXHarnessAppleWorkItems.MetadataNames.AppBundlePath)).Returns(appBundlePath); } - _fileSystem.CreateDirectory(appBundlePath ?? itemSpec); + _fileSystem.CreateDirectory(_task.TaskEnvironment.GetAbsolutePath(appBundlePath ?? itemSpec)); return mockBundle.Object; } diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs index 89069ed0ce0..742e90147da 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAndroidWorkItems.cs @@ -74,6 +74,11 @@ private async Task PrepareWorkItem(IZipArchiveManager zipArchiveManag { var (workItemName, apkPath) = GetNameAndPath(appPackage, MetadataNames.ApkPath, fileSystem); + // The APK path is documented as relative (see tools/xharness-runner/Readme.md), so it has to be + // resolved against the project directory before it reaches IFileSystem/ZipArchiveManager, which + // use raw File/Directory APIs and would otherwise bind to the shared node's current directory. + apkPath = TaskEnvironment.GetAbsolutePath(apkPath); + if (!fileSystem.FileExists(apkPath)) { Log.LogError($"App package not found in {apkPath}"); diff --git a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs index 26cb89f6946..1e3ee06c70e 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/CreateXHarnessAppleWorkItems.cs @@ -64,7 +64,9 @@ public static class MetadataNames public override void ConfigureServices(IServiceCollection collection) { - collection.TryAddProvisioningProfileProvider(ProvisioningProfileUrl, TmpDir); + // TmpDir is optional and flows into the provisioning profile provider, which does raw file IO. + string tmpDir = string.IsNullOrEmpty(TmpDir) ? TmpDir : TaskEnvironment.GetAbsolutePath(TmpDir); + collection.TryAddProvisioningProfileProvider(ProvisioningProfileUrl, tmpDir); collection.TryAddTransient(); collection.TryAddTransient(); collection.TryAddSingleton(Log); @@ -102,6 +104,12 @@ private async Task PrepareWorkItem( appFolderPath = appFolderPath.TrimEnd(Path.DirectorySeparatorChar); + // The app bundle path is documented as relative (see tools/xharness-runner/Readme.md), so it has to be + // resolved against the project directory before it reaches IFileSystem/ZipArchiveManager, which use raw + // File/Directory APIs and would otherwise bind to the shared node's current directory. Trim first so the + // trailing separator does not turn a bundle directory into a different resolved path. + appFolderPath = TaskEnvironment.GetAbsolutePath(appFolderPath); + bool isAlreadyArchived = appFolderPath.EndsWith(".zip"); if (isAlreadyArchived && workItemName.EndsWith(".app")) { diff --git a/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs b/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs index 4a2abd0c937..106512d8118 100644 --- a/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs +++ b/src/Microsoft.DotNet.Helix/Sdk/XharnessTaskBase.cs @@ -13,12 +13,15 @@ namespace Microsoft.DotNet.Helix.Sdk /// /// MSBuild custom task to create HelixWorkItems for provided Android application packages. /// - public abstract class XHarnessTaskBase : MSBuildTaskBase + public abstract class XHarnessTaskBase : MSBuildTaskBase, IMultiThreadableTask { private static readonly TimeSpan s_defaultWorkItemTimeout = TimeSpan.FromMinutes(20); private static readonly TimeSpan s_defaultTestTimeout = TimeSpan.FromMinutes(12); private static readonly TimeSpan s_telemetryBuffer = TimeSpan.FromMinutes(2); // extra time to send the XHarness telemetry + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + public class MetadataName { public const string TestTimeout = "TestTimeout"; diff --git a/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs b/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs index 3e7612186ed..350ac34ca19 100644 --- a/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs +++ b/src/Microsoft.DotNet.PackageTesting/GetCompatiblePackageTargetFrameworks.cs @@ -11,11 +11,14 @@ namespace Microsoft.DotNet.PackageTesting { [MSBuildMultiThreadableTask] - public class GetCompatiblePackageTargetFrameworks : Microsoft.Build.Utilities.Task + public class GetCompatiblePackageTargetFrameworks : Microsoft.Build.Utilities.Task, IMultiThreadableTask { private readonly List allTargetFrameworks = new(); private readonly Dictionary> packageTfmMapping = new(); + /// Injected by MSBuild so paths resolve against the project directory in multithreaded builds. + public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; + [Required] public string[] PackagePaths { get; set; } @@ -39,7 +42,7 @@ public override bool Execute() foreach (var packagePath in PackagePaths) { - Package package = NupkgParser.CreatePackageObject(packagePath); + Package package = NupkgParser.CreatePackageObject(TaskEnvironment.GetAbsolutePath(packagePath)); IEnumerable testFrameworks = GetTestFrameworks(package, minDotnetTargetFramework); testProjects.AddRange(CreateItemFromTestFramework(package.PackageId, package.Version, testFrameworks)); diff --git a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs index 4a44701f543..02e893ef4fd 100644 --- a/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs +++ b/src/Microsoft.DotNet.SharedFramework.Sdk/src/GenerateSharedFrameworkDepsFile.cs @@ -100,7 +100,12 @@ public override bool Execute() if (IncludeFallbacksInDepsFile) { - RuntimeGraph runtimeGraph = JsonRuntimeFormat.ReadRuntimeGraph(RuntimeIdentifierGraph); + // RuntimeIdentifierGraph is optional; only resolve it when set so an unset value keeps + // producing the existing error from ReadRuntimeGraph rather than throwing from GetAbsolutePath. + string runtimeIdentifierGraphPath = string.IsNullOrEmpty(RuntimeIdentifierGraph) + ? RuntimeIdentifierGraph + : TaskEnvironment.GetAbsolutePath(RuntimeIdentifierGraph); + RuntimeGraph runtimeGraph = JsonRuntimeFormat.ReadRuntimeGraph(runtimeIdentifierGraphPath); runtimeFallbackGraph = runtimeGraph.Runtimes .Select(runtimeDict => runtimeGraph.ExpandRuntime(runtimeDict.Key)) .Where(expansion => expansion.Contains(RuntimeIdentifier)) diff --git a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs index 03fc1cec470..7973cf4f9b1 100644 --- a/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs +++ b/src/Microsoft.DotNet.SourceBuild/tasks/src/UsageReport/WritePackageUsageData.cs @@ -113,22 +113,22 @@ public override bool Execute() Log.LogMessage(MessageImportance.Low, "Reading package identities..."); PackageIdentity[] restored = RestoredPackageFiles.NullAsEmpty() - .Select(ReadNuGetPackageInfos.ReadIdentity) + .Select(ReadIdentityFromResolvedPath) .Distinct() .ToArray(); PackageIdentity[] tarballPrebuilt = TarballPrebuiltPackageFiles.NullAsEmpty() - .Select(ReadNuGetPackageInfos.ReadIdentity) + .Select(ReadIdentityFromResolvedPath) .Distinct() .ToArray(); PackageIdentity[] referencePackages = ReferencePackageFiles.NullAsEmpty() - .Select(ReadNuGetPackageInfos.ReadIdentity) + .Select(ReadIdentityFromResolvedPath) .Distinct() .ToArray(); PackageIdentity[] sourceBuilt = SourceBuiltPackageFiles.NullAsEmpty() - .Select(ReadNuGetPackageInfos.ReadIdentity) + .Select(ReadIdentityFromResolvedPath) .Distinct() .ToArray(); @@ -321,5 +321,8 @@ private string[] ReadRidsFromRuntimeJson(string path) .Select(o => o.Name) .ToArray(); } + + private PackageIdentity ReadIdentityFromResolvedPath(string nupkgFile) => + ReadNuGetPackageInfos.ReadIdentity(TaskEnvironment.GetAbsolutePath(nupkgFile)); } } From d36f35c756b447446e09847bb4d17562addb6778 Mon Sep 17 00:00:00 2001 From: Viktor Hofer <7412651+ViktorHofer@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:28:16 +0200 Subject: [PATCH 9/9] Remove inert multi-threadable attributes from unregistered WiX tool tasks WixToolTask and HarvesterToolTask are not registered via UsingTask; MsiBase constructs them directly. MSBuild therefore never routes them and never injects a TaskEnvironment, so WixToolTaskBase resolves against TaskEnvironment.Fallback and the annotation had no effect other than marking part of the CreateVisualStudioWorkload helper chain as migrated, which contradicts the TODO comments on both entry-point tasks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: be19d794-fe4f-43fe-9e6e-f6e6b13ec6e7 --- .../src/Wix/HarvesterToolTask.cs | 7 ++++++- .../src/Wix/WixToolTask.cs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs index 1f3d814807d..1de9084549e 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/HarvesterToolTask.cs @@ -10,7 +10,12 @@ namespace Microsoft.DotNet.Build.Tasks.Workloads.Wix /// /// A tool task to invoke the WiX harvesting tool (heat.exe). /// - [MSBuildMultiThreadableTask] + // Deliberately not annotated with [MSBuildMultiThreadableTask]. This type is never registered via + // UsingTask; it is constructed directly by MsiBase, so MSBuild neither routes it nor injects a + // TaskEnvironment, leaving WixToolTaskBase resolving against TaskEnvironment.Fallback (the process + // current directory). The attribute would therefore be inert while falsely marking part of the + // CreateVisualStudioWorkload helper chain as migrated. See dotnet/arcade#17378: the fix is to flow + // the owning task's TaskEnvironment into these instances once that chain takes AbsolutePath. public class HarvesterToolTask : WixToolTaskBase { private static readonly Dictionary s_SuppressionArguments = new() diff --git a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs index 9bee4606fb8..f199c5b0b2d 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Workloads/src/Wix/WixToolTask.cs @@ -13,7 +13,12 @@ namespace Microsoft.DotNet.Build.Tasks.Workloads.Wix /// /// Tool task for invoking the WiX CLI (version 5 and above). This commands is responsible for compiling and linking. /// - [MSBuildMultiThreadableTask] + // Deliberately not annotated with [MSBuildMultiThreadableTask]. This type is never registered via + // UsingTask; it is constructed directly by MsiBase, so MSBuild neither routes it nor injects a + // TaskEnvironment, leaving WixToolTaskBase resolving against TaskEnvironment.Fallback (the process + // current directory). The attribute would therefore be inert while falsely marking part of the + // CreateVisualStudioWorkload helper chain as migrated. See dotnet/arcade#17378: the fix is to flow + // the owning task's TaskEnvironment into these instances once that chain takes AbsolutePath. public class WixToolTask : WixToolTaskBase { private List _sourceFiles = new();