From b5e02cf05102baca57c9c208738318e0a6799b27 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:14:20 +0200 Subject: [PATCH 01/10] Add APIs and implement execution context --- .../BackEnd/MockTaskBuilder.cs | 9 ++ .../BuildRequestEngine/BuildRequestEngine.cs | 12 +- .../BuildRequestEngine/BuildRequestEntry.cs | 11 +- .../Components/RequestBuilder/ITaskBuilder.cs | 7 ++ .../RequestBuilder/RequestBuilder.cs | 50 ++++++-- .../RequestBuilder/TargetBuilder.cs | 20 ++++ .../Components/RequestBuilder/TaskBuilder.cs | 20 +++- .../TaskExecutionHost/TaskExecutionHost.cs | 21 +++- .../TaskFactories/AssemblyTaskFactory.cs | 36 +++++- .../Instance/TaskFactories/TaskHostTask.cs | 24 +++- src/Framework/ChangeWaves.cs | 3 +- src/Framework/IMultiThreadableTask.cs | 19 +++ src/Framework/ITaskEnvironmentDriver.cs | 63 ++++++++++ .../MSBuildMultiThreadableTaskAttribute.cs | 24 ++++ .../MultithreadedTaskEnvironmentDriver.cs | 100 ++++++++++++++++ src/Framework/PathHelpers/AbsolutePath.cs | 86 ++++++++++++++ src/Framework/StubTaskEnvironmentDriver.cs | 108 ++++++++++++++++++ src/Framework/TaskEnvironment.cs | 76 ++++++++++++ src/Tasks/Message.cs | 7 +- 19 files changed, 673 insertions(+), 23 deletions(-) create mode 100644 src/Framework/IMultiThreadableTask.cs create mode 100644 src/Framework/ITaskEnvironmentDriver.cs create mode 100644 src/Framework/MSBuildMultiThreadableTaskAttribute.cs create mode 100644 src/Framework/MultithreadedTaskEnvironmentDriver.cs create mode 100644 src/Framework/PathHelpers/AbsolutePath.cs create mode 100644 src/Framework/StubTaskEnvironmentDriver.cs create mode 100644 src/Framework/TaskEnvironment.cs diff --git a/src/Build.UnitTests/BackEnd/MockTaskBuilder.cs b/src/Build.UnitTests/BackEnd/MockTaskBuilder.cs index cdd280d3b72..463be39ae9b 100644 --- a/src/Build.UnitTests/BackEnd/MockTaskBuilder.cs +++ b/src/Build.UnitTests/BackEnd/MockTaskBuilder.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.Build.BackEnd; using Microsoft.Build.Execution; +using Microsoft.Build.Framework; using Microsoft.Build.Shared; using TargetLoggingContext = Microsoft.Build.BackEnd.Logging.TargetLoggingContext; @@ -122,6 +123,14 @@ public Task ExecuteTask(TargetLoggingContext targetLoggingContex return Task.FromResult(new WorkUnitResult(WorkUnitResultCode.Success, WorkUnitActionCode.Continue, null)); } + /// + /// Sets the task environment on the TaskExecutionHost for use with IMultiThreadableTask instances. + /// + /// The task environment to set, or null to clear. + public void SetTaskEnvironment(TaskEnvironment taskEnvironment) + { + } + #endregion #region IBuildComponent Members diff --git a/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs b/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs index a61a5e7b6c9..5e9e8d0b360 100644 --- a/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs +++ b/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEngine.cs @@ -387,7 +387,17 @@ public void SubmitBuildRequest(BuildRequest request) } else { - BuildRequestEntry entry = new BuildRequestEntry(request, _configCache[request.ConfigurationId]); + BuildRequestConfiguration config = _configCache[request.ConfigurationId]; + + TaskEnvironment taskEnvironment = null; + if (_componentHost.BuildParameters.MultiThreaded) + { + string projectDirectoryFullPath = Path.GetDirectoryName(config.ProjectFullPath); + var environmentVariables = new Dictionary(_componentHost.BuildParameters.BuildProcessEnvironmentInternal); + taskEnvironment = new TaskEnvironment(new MultithreadedTaskEnvironmentDriver(projectDirectoryFullPath, environmentVariables)); + } + + BuildRequestEntry entry = new BuildRequestEntry(request, config, taskEnvironment); entry.OnStateChanged += BuildRequestEntry_StateChanged; diff --git a/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEntry.cs b/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEntry.cs index 51288d38fd2..4b97eb13f3f 100644 --- a/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEntry.cs +++ b/src/Build/BackEnd/Components/BuildRequestEngine/BuildRequestEntry.cs @@ -7,6 +7,7 @@ using System.Linq; using Microsoft.Build.Execution; using Microsoft.Build.Shared; +using Microsoft.Build.Framework; using BuildAbortedException = Microsoft.Build.Exceptions.BuildAbortedException; #nullable disable @@ -119,7 +120,8 @@ internal class BuildRequestEntry /// /// The originating build request. /// The build request configuration. - internal BuildRequestEntry(BuildRequest request, BuildRequestConfiguration requestConfiguration) + /// Task enviroment information that would be passed to tasks executing for the build request. If set to null will use stub environment. + internal BuildRequestEntry(BuildRequest request, BuildRequestConfiguration requestConfiguration, TaskEnvironment taskEnvironment = null) { ErrorUtilities.VerifyThrowArgumentNull(request); ErrorUtilities.VerifyThrowArgumentNull(requestConfiguration); @@ -128,6 +130,7 @@ internal BuildRequestEntry(BuildRequest request, BuildRequestConfiguration reque GlobalLock = new LockType(); Request = request; RequestConfiguration = requestConfiguration; + TaskEnvironment = taskEnvironment ?? new TaskEnvironment(StubTaskEnvironmentDriver.Instance); _blockingGlobalRequestId = BuildRequest.InvalidGlobalRequestId; Result = null; ChangeState(BuildRequestEntryState.Ready); @@ -184,6 +187,12 @@ public IRequestBuilder Builder _requestBuilder = value; } } + + /// + /// Gets or sets the task environment for this request. + /// Tasks implementing IMultiThreadableTask will use this environment for file system and environment operations. + /// + public TaskEnvironment TaskEnvironment { get; set; } /// /// Informs the entry that it has configurations which need to be resolved. diff --git a/src/Build/BackEnd/Components/RequestBuilder/ITaskBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/ITaskBuilder.cs index 5daea9c8d78..93da223c8aa 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/ITaskBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/ITaskBuilder.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Build.Execution; +using Microsoft.Build.Framework; using TargetLoggingContext = Microsoft.Build.BackEnd.Logging.TargetLoggingContext; #nullable disable @@ -52,5 +53,11 @@ internal interface ITaskBuilder /// The cancellation token used to cancel processing of the task. /// A Task representing the work to be done. Task ExecuteTask(TargetLoggingContext targetLoggingContext, BuildRequestEntry requestEntry, ITargetBuilderCallback targetBuilderCallback, ProjectTargetInstanceChild task, TaskExecutionMode mode, Lookup lookupForInference, Lookup lookupForExecution, CancellationToken cancellationToken); + + /// + /// Sets the task environment on the TaskExecutionHost for use with IMultiThreadableTask instances. + /// + /// The task environment to set, or null to clear. + void SetTaskEnvironment(TaskEnvironment taskEnvironment); } } diff --git a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs index 0b112221b9a..30c64c99159 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs @@ -1080,11 +1080,18 @@ private void RaiseResourceRequest(ResourceRequest request) /// This is because if the project has not been saved, this directory may not exist, yet it is often useful to still be able to build the project. /// No errors are masked by doing this: errors loading the project from disk are reported at load time, if necessary. /// - private void SetProjectCurrentDirectory() + private void SetProjectDirectory() { if (_componentHost.BuildParameters.SaveOperatingEnvironment) { - NativeMethodsShared.SetCurrentDirectory(_requestEntry.ProjectRootDirectory); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.PathHelpers.AbsolutePath(_requestEntry.ProjectRootDirectory, ignoreRootedCheck: true); + } + else + { + NativeMethodsShared.SetCurrentDirectory(_requestEntry.ProjectRootDirectory); + } } } @@ -1134,7 +1141,14 @@ private async Task BuildProject() { foreach (ProjectPropertyInstance environmentProperty in environmentProperties) { - Environment.SetEnvironmentVariable(environmentProperty.Name, environmentProperty.EvaluatedValue, EnvironmentVariableTarget.Process); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _requestEntry.TaskEnvironment.SetEnvironmentVariable(environmentProperty.Name, environmentProperty.EvaluatedValue); + } + else + { + Environment.SetEnvironmentVariable(environmentProperty.Name, environmentProperty.EvaluatedValue, EnvironmentVariableTarget.Process); + } } } @@ -1190,7 +1204,7 @@ private async Task BuildProject() _requestEntry.RequestConfiguration.Project.ProjectFileLocation, "NoTargetSpecified"); // Set the current directory to that required by the project. - SetProjectCurrentDirectory(); + SetProjectDirectory(); // Transfer results and state from the previous node, if necessary. // In order for the check for target completeness for this project to be valid, all of the target results from the project must be present @@ -1412,7 +1426,15 @@ private void RestoreOperatingEnvironment() // Restore the saved environment variables. SetEnvironmentVariableBlock(_requestEntry.RequestConfiguration.SavedEnvironmentVariables); - NativeMethodsShared.SetCurrentDirectory(_requestEntry.RequestConfiguration.SavedCurrentDirectory); + + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.PathHelpers.AbsolutePath(_requestEntry.RequestConfiguration.SavedCurrentDirectory, ignoreRootedCheck: true); + } + else + { + NativeMethodsShared.SetCurrentDirectory(_requestEntry.RequestConfiguration.SavedCurrentDirectory); + } } } @@ -1435,7 +1457,14 @@ private void ClearVariablesNotInEnvironment(FrozenDictionary sav { if (!savedEnvironment.ContainsKey(entry.Key)) { - Environment.SetEnvironmentVariable(entry.Key, null); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _requestEntry.TaskEnvironment.SetEnvironmentVariable(entry.Key, null); + } + else + { + Environment.SetEnvironmentVariable(entry.Key, null); + } } } } @@ -1453,7 +1482,14 @@ private void UpdateEnvironmentVariables(FrozenDictionary savedEn string value; if (!currentEnvironment.TryGetValue(entry.Key, out value) || !String.Equals(entry.Value, value, StringComparison.Ordinal)) { - Environment.SetEnvironmentVariable(entry.Key, entry.Value); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _requestEntry.TaskEnvironment.SetEnvironmentVariable(entry.Key, entry.Value); + } + else + { + Environment.SetEnvironmentVariable(entry.Key, entry.Value); + } } } } diff --git a/src/Build/BackEnd/Components/RequestBuilder/TargetBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/TargetBuilder.cs index 65b6903876a..e01d6e72b0a 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/TargetBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/TargetBuilder.cs @@ -170,10 +170,20 @@ public async Task BuildTargets(ProjectLoggingContext loggingContext ITaskBuilder taskBuilder = _componentHost.GetComponent(BuildComponentType.TaskBuilder) as ITaskBuilder; try { + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0) && _requestEntry.TaskEnvironment != null) + { + taskBuilder?.SetTaskEnvironment(_requestEntry.TaskEnvironment); + } + await ProcessTargetStack(taskBuilder); } finally { + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + taskBuilder?.SetTaskEnvironment(null); + } + // If there are still targets left on the stack, they need to be removed from the 'active targets' list foreach (TargetEntry target in _targetsToBuild) { @@ -266,6 +276,11 @@ async Task ITargetBuilderCallback.LegacyCallTarget(string[] tar ITaskBuilder taskBuilder = _componentHost.GetComponent(BuildComponentType.TaskBuilder) as ITaskBuilder; try { + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0) && _requestEntry.TaskEnvironment != null) + { + taskBuilder?.SetTaskEnvironment(_requestEntry.TaskEnvironment); + } + // Flag set to true if one of the targets we call fails. bool errorResult = false; @@ -312,6 +327,11 @@ async Task ITargetBuilderCallback.LegacyCallTarget(string[] tar _targetsToBuild.Pop(); } + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + taskBuilder?.SetTaskEnvironment(null); + } + _legacyCallTargetContinueOnError = originalLegacyCallTargetContinueOnError; ((IBuildComponent)taskBuilder).ShutdownComponent(); } diff --git a/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs index 9ceadc995e1..fc09066fb4d 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs @@ -21,6 +21,7 @@ using Microsoft.Build.Exceptions; using Microsoft.Build.Execution; using Microsoft.Build.Framework; +using Microsoft.Build.Framework.PathHelpers; using Microsoft.Build.Shared; using Microsoft.Build.Shared.FileSystem; using ElementLocation = Microsoft.Build.Construction.ElementLocation; @@ -130,6 +131,16 @@ internal TaskBuilder() { } + /// + /// Sets the task environment on the TaskExecutionHost for use with IMultiThreadableTask instances. + /// + /// The task environment to set, or null to clear. + public void SetTaskEnvironment(TaskEnvironment taskEnvironment) + { + ErrorUtilities.VerifyThrow(_taskExecutionHost != null, "TaskExecutionHost must be initialized before setting TaskEnvironment."); + _taskExecutionHost.TaskEnvironment = taskEnvironment; + } + /// /// Builds the task specified by the XML. /// @@ -415,7 +426,14 @@ private async ValueTask ExecuteBucket(TaskHost taskHost, ItemBuc // If that directory does not exist, do nothing. (Do not check first as it is almost always there and it is slow) // This is because if the project has not been saved, this directory may not exist, yet it is often useful to still be able to build the project. // No errors are masked by doing this: errors loading the project from disk are reported at load time, if necessary. - NativeMethodsShared.SetCurrentDirectory(_buildRequestEntry.ProjectRootDirectory); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) + { + _buildRequestEntry.TaskEnvironment.ProjectDirectory = new AbsolutePath(_buildRequestEntry.ProjectRootDirectory, ignoreRootedCheck: true); + } + else + { + NativeMethodsShared.SetCurrentDirectory(_buildRequestEntry.ProjectRootDirectory); + } } if (howToExecuteTask == TaskExecutionMode.ExecuteTaskAndGatherOutputs) diff --git a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs index 585f4a64d44..5c6f33b980c 100644 --- a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs +++ b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs @@ -159,6 +159,11 @@ internal class TaskExecutionHost : IDisposable private readonly PropertyTrackingSetting _propertyTrackingSettings; + /// + /// The task environment to be used by IMultiThreadableTask instances. + /// + internal TaskEnvironment TaskEnvironment { get; set; } + /// /// Constructor /// @@ -367,6 +372,11 @@ public bool InitializeForBatch(TaskLoggingContext loggingContext, ItemBucket bat TaskInstance.BuildEngine = _buildEngine; TaskInstance.HostObject = _taskHost; + + if (TaskInstance is IMultiThreadableTask multiThreadableTask) + { + multiThreadableTask.TaskEnvironment = TaskEnvironment; + } return true; @@ -614,6 +624,7 @@ public bool Execute() try { + Debug.Assert(TaskInstance is not IMultiThreadableTask multiThreadableTask || multiThreadableTask.TaskEnvironment != null, "task environment missing for multi-threadable task"); taskReturnValue = TaskInstance.Execute(); } finally @@ -728,7 +739,7 @@ private bool SetTaskItemParameter(TaskPropertyInfo parameter, ITaskItem item) { return InternalSetTaskParameter(parameter, item); } - + /// /// Called on the local side. /// @@ -975,7 +986,8 @@ private ITask InstantiateTask(IDictionary taskIdentityParameters AppDomainSetup, #endif IsOutOfProc, - ProjectInstance.GetProperty); + ProjectInstance.GetProperty, + TaskEnvironment); } else { @@ -1792,10 +1804,11 @@ private ITask CreateTaskHostTaskForOutOfProcFactory(IDictionary _buildComponentHost, taskHostParameters, taskLoadedType, - true + true, #if FEATURE_APPDOMAIN - , AppDomainSetup + AppDomainSetup, #endif + TaskEnvironment ); #pragma warning restore SA1111, SA1009 // Closing parenthesis should be on line of last parameter } diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs index 0880c7dee8b..d669030eb63 100644 --- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs +++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs @@ -329,7 +329,7 @@ internal LoadedType InitializeFactory( } /// - /// Create an instance of the wrapped ITask for a batch run of the task. + /// Create an instance of the wrapped ITask for a batch run of the task. For testing only - it provides stub task environment. /// internal ITask CreateTaskInstance( ElementLocation taskLocation, @@ -341,6 +341,34 @@ internal ITask CreateTaskInstance( #endif bool isOutOfProc, Func getProperty) + { + return CreateTaskInstance( + taskLocation, + taskLoggingContext, + buildComponentHost, + taskIdentityParameters, +#if FEATURE_APPDOMAIN + appDomainSetup, +#endif + isOutOfProc, + getProperty, + new TaskEnvironment(StubTaskEnvironmentDriver.Instance)); + } + + /// + /// Create an instance of the wrapped ITask for a batch run of the task. + /// + internal ITask CreateTaskInstance( + ElementLocation taskLocation, + TaskLoggingContext taskLoggingContext, + IBuildComponentHost buildComponentHost, + IDictionary taskIdentityParameters, +#if FEATURE_APPDOMAIN + AppDomainSetup appDomainSetup, +#endif + bool isOutOfProc, + Func getProperty, + TaskEnvironment taskEnvironment) { bool useTaskFactory = false; Dictionary mergedParameters = null; @@ -395,10 +423,12 @@ internal ITask CreateTaskInstance( buildComponentHost, mergedParameters, _loadedType, - taskHostFactoryExplicitlyRequested: _isTaskHostFactory + taskHostFactoryExplicitlyRequested: _isTaskHostFactory, #if FEATURE_APPDOMAIN - , appDomainSetup + appDomainSetup: appDomainSetup, #endif + + taskEnvironment: taskEnvironment ); #pragma warning restore SA1111, SA1009 // Closing parenthesis should be on line of last parameter return task; diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs index a53a52f7c56..5069819e75c 100644 --- a/src/Build/Instance/TaskFactories/TaskHostTask.cs +++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs @@ -130,6 +130,11 @@ internal class TaskHostTask : IGeneratedTask, ICancelableTask, INodePacketFactor /// private bool _taskHostFactoryExplicitlyRequested = false; + /// + /// The task environment for virtualized environment operations. + /// + private TaskEnvironment _taskEnvironment; + /// /// Constructor. /// @@ -140,14 +145,17 @@ public TaskHostTask( IBuildComponentHost buildComponentHost, Dictionary taskHostParameters, LoadedType taskType, - bool taskHostFactoryExplicitlyRequested + bool taskHostFactoryExplicitlyRequested, #if FEATURE_APPDOMAIN - , AppDomainSetup appDomainSetup + AppDomainSetup appDomainSetup, #endif + + TaskEnvironment taskEnvironment ) #pragma warning disable SA1111, SA1009 // Closing parenthesis should be on line of last parameter { ErrorUtilities.VerifyThrowInternalNull(taskType); + ErrorUtilities.VerifyThrowInternalNull(taskEnvironment); _taskLocation = taskLocation; _taskLoggingContext = taskLoggingContext; @@ -158,6 +166,7 @@ bool taskHostFactoryExplicitlyRequested #endif _taskHostParameters = taskHostParameters; _taskHostFactoryExplicitlyRequested = taskHostFactoryExplicitlyRequested; + _taskEnvironment = taskEnvironment; _packetFactory = new NodePacketFactory(); @@ -487,8 +496,15 @@ private void HandleTaskHostTaskComplete(TaskHostTaskComplete taskHostTaskComplet // If it crashed, or if it failed, it didn't succeed. _taskExecutionSucceeded = taskHostTaskComplete.TaskResult == TaskCompleteType.Success ? true : false; - // reset the environment, as though the task were executed in this process all along. - CommunicationsUtilities.SetEnvironment(taskHostTaskComplete.BuildProcessEnvironment); + // Update the task environment with the environment changes from the task host execution + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0) && taskHostTaskComplete.BuildProcessEnvironment != null) + { + _taskEnvironment.SetEnvironment(taskHostTaskComplete.BuildProcessEnvironment); + } + else + { + CommunicationsUtilities.SetEnvironment(taskHostTaskComplete.BuildProcessEnvironment); + } // If it crashed during the execution phase, then we can effectively replicate the inproc task execution // behaviour by just throwing here and letting the taskbuilder code take care of it the way it would diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs index 84e325912c2..0517b2ab9d1 100644 --- a/src/Framework/ChangeWaves.cs +++ b/src/Framework/ChangeWaves.cs @@ -30,7 +30,8 @@ internal static class ChangeWaves internal static readonly Version Wave17_10 = new Version(17, 10); internal static readonly Version Wave17_12 = new Version(17, 12); internal static readonly Version Wave17_14 = new Version(17, 14); - internal static readonly Version[] AllWaves = { Wave17_10, Wave17_12, Wave17_14 }; + internal static readonly Version Wave18_0 = new Version(18, 0); + internal static readonly Version[] AllWaves = { Wave17_10, Wave17_12, Wave17_14, Wave18_0 }; /// /// Special value indicating that all features behind all Change Waves should be enabled. diff --git a/src/Framework/IMultiThreadableTask.cs b/src/Framework/IMultiThreadableTask.cs new file mode 100644 index 00000000000..7f31269dc55 --- /dev/null +++ b/src/Framework/IMultiThreadableTask.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Framework +{ + /// + /// Interface for tasks that can execute in a thread-safe manner. + /// Tasks that implement this interface guarantee they handle their own thread safety + /// and can be safely executed in parallel with other tasks or instances of the same task. + /// + public interface IMultiThreadableTask : ITask + { + /// + /// Gets or sets the task environment that provides access to task execution environment. + /// This property must be set by the MSBuild infrastructure before task execution. + /// + TaskEnvironment TaskEnvironment { get; set; } + } +} diff --git a/src/Framework/ITaskEnvironmentDriver.cs b/src/Framework/ITaskEnvironmentDriver.cs new file mode 100644 index 00000000000..429401dfe29 --- /dev/null +++ b/src/Framework/ITaskEnvironmentDriver.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Build.Framework.PathHelpers; + +namespace Microsoft.Build.Framework +{ + /// + /// Internal interface for managing task execution environment, including environment variables and working directory. + /// + /// + /// If we ever consider making any part of this API public, strongly consider making an abstract class instead of a public interface. + /// + internal interface ITaskEnvironmentDriver + { + /// + /// Gets or sets the current working directory for the task environment. + /// + AbsolutePath ProjectDirectory { get; set; } + + /// + /// Gets an absolute path from the specified path, resolving relative paths against the current project directory. + /// + /// The path to convert to absolute. + /// An absolute path representation. + AbsolutePath GetAbsolutePath(string path); + + /// + /// Gets the value of the specified environment variable. + /// + /// The name of the environment variable. + /// The value of the environment variable, or null if not found. + string? GetEnvironmentVariable(string name); + + /// + /// Gets all environment variables for this task environment. + /// + /// A read-only dictionary of environment variable names and values. + IReadOnlyDictionary GetEnvironmentVariables(); + + /// + /// Sets an environment variable to the specified value. + /// + /// The name of the environment variable. + /// The value to set, or null to remove the variable. + void SetEnvironmentVariable(string name, string? value); + + /// + /// Sets the environment to match the specified collection of variables. + /// Removes variables not present in the new environment and updates or adds those that are. + /// + /// The new environment variable collection. + void SetEnvironment(IDictionary newEnvironment); + + /// + /// Gets a ProcessStartInfo configured with the current environment and working directory. + /// + /// A ProcessStartInfo with the current environment settings. + ProcessStartInfo GetProcessStartInfo(); + } +} diff --git a/src/Framework/MSBuildMultiThreadableTaskAttribute.cs b/src/Framework/MSBuildMultiThreadableTaskAttribute.cs new file mode 100644 index 00000000000..bf4a8852bab --- /dev/null +++ b/src/Framework/MSBuildMultiThreadableTaskAttribute.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; + +namespace Microsoft.Build.Framework +{ + /// + /// Attribute that marks a task class as thread-safe. + /// Task classes marked with this attribute indicate they can be safely executed in parallel in the same process with other tasks. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] + internal class MSBuildMultiThreadableTaskAttribute : Attribute + { + /// + /// Initializes a new instance of the ThreadSafeAttribute class. + /// + public MSBuildMultiThreadableTaskAttribute() + { + } + } +} diff --git a/src/Framework/MultithreadedTaskEnvironmentDriver.cs b/src/Framework/MultithreadedTaskEnvironmentDriver.cs new file mode 100644 index 00000000000..968c5c9caaa --- /dev/null +++ b/src/Framework/MultithreadedTaskEnvironmentDriver.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Build.Framework.PathHelpers; + +namespace Microsoft.Build.Framework +{ + /// + /// Implementation of that virtualizes environment variables and current directory + /// for use in thread nodes where tasks may be executed in parallel. This allows each project to maintain its own + /// isolated environment state without affecting other concurrently building projects. + /// + internal sealed class MultithreadedTaskEnvironmentDriver : ITaskEnvironmentDriver + { + private readonly Dictionary _environmentVariables; + private AbsolutePath _currentDirectory; + + /// + /// Initializes a new instance of the class + /// with the specified working directory and optional environment variables. + /// + /// The initial working directory. + /// Optional dictionary of environment variables to use. + /// If not provided, the current environment variables are used. + public MultithreadedTaskEnvironmentDriver( + string currentDirectoryFullPath, + Dictionary environmentVariables) + { + _environmentVariables = environmentVariables; + _currentDirectory = new AbsolutePath(currentDirectoryFullPath, ignoreRootedCheck: true); + } + + public AbsolutePath ProjectDirectory + { + get => _currentDirectory; + set => _currentDirectory = value; + } + + /// + public AbsolutePath GetAbsolutePath(string path) + { + return new AbsolutePath(path, ProjectDirectory); + } + + /// + public string? GetEnvironmentVariable(string name) + { + return _environmentVariables.TryGetValue(name, out string? value) ? value : null; + } + + /// + public IReadOnlyDictionary GetEnvironmentVariables() + { + return _environmentVariables; + } + + /// + public void SetEnvironmentVariable(string name, string? value) + { + if (value == null) + { + _environmentVariables.Remove(name); + } + else + { + _environmentVariables[name] = value; + } + } + + /// + public void SetEnvironment(IDictionary newEnvironment) + { + // Simply replace the entire environment dictionary + _environmentVariables.Clear(); + foreach (KeyValuePair entry in newEnvironment) + { + _environmentVariables[entry.Key] = entry.Value; + } + } + + /// + public ProcessStartInfo GetProcessStartInfo() + { + var startInfo = new ProcessStartInfo + { + WorkingDirectory = ProjectDirectory.Path + }; + + // Set environment variables + foreach (var kvp in _environmentVariables) + { + startInfo.EnvironmentVariables[kvp.Key] = kvp.Value; + } + + return startInfo; + } + } +} diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs new file mode 100644 index 00000000000..c8d6a11deac --- /dev/null +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Build.Framework.PathHelpers +{ + /// + /// Represents an absolute file system path. + /// + /// + /// This struct ensures that paths are always in absolute form and properly formatted. + /// + public readonly struct AbsolutePath + { + + /// + /// Gets the string representation of this path. + /// + public string Path { get; } + + /// + /// Creates a new instance of AbsolutePath. + /// + /// The absolute path string. + public AbsolutePath(string path) + { + ValidatePath(path); + Path = path; + } + + /// + /// Creates a new instance of AbsolutePath. + /// + /// The absolute path string. + /// If true, skips checking whether the path is rooted. + internal AbsolutePath(string path, bool ignoreRootedCheck) + { + if (!ignoreRootedCheck) + { + ValidatePath(path); + } + Path = path; + } + + /// + /// Validates that the specified file system path is non-empty and rooted. + /// + /// The file system path to validate. Must not be null, empty, or a relative path. + /// Thrown if is null, empty, or not a rooted path. + private void ValidatePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("Path must not be null or empty.", nameof(path)); + } + + if (!System.IO.Path.IsPathRooted(path)) + { + throw new ArgumentException("Path must be rooted.", nameof(path)); + } + } + + /// + /// Creates a new absolute path by combining a absolute path with a relative path. + /// + /// The path to combine with the base path. + /// The base path to combine with. + public AbsolutePath(string path, AbsolutePath basePath) + { + Path = System.IO.Path.Combine(basePath.Path, path); + } + + /// + /// Implicitly converts an AbsolutePath to a string. + /// + /// The path to convert. + public static implicit operator string(AbsolutePath path) => path.Path; + + /// + /// Returns the string representation of this path. + /// + /// The path as a string. + public override string ToString() => Path; + } +} diff --git a/src/Framework/StubTaskEnvironmentDriver.cs b/src/Framework/StubTaskEnvironmentDriver.cs new file mode 100644 index 00000000000..feb1ab33a26 --- /dev/null +++ b/src/Framework/StubTaskEnvironmentDriver.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +using Microsoft.Build.Framework.PathHelpers; + +namespace Microsoft.Build.Framework +{ + /// + /// Default implementation of that directly interacts with the file system + /// and environment variables. Implemented as a singleton since it has no instance state and delegates + /// all operations to the actual system environment. + /// + public class StubTaskEnvironmentDriver : ITaskEnvironmentDriver + { + /// + /// The singleton instance. + /// + private static readonly StubTaskEnvironmentDriver s_instance = new StubTaskEnvironmentDriver(); + + /// + /// Gets the singleton instance of StubTaskEnvironmentDriver. + /// + public static StubTaskEnvironmentDriver Instance => s_instance; + + /// + /// Private constructor to enforce singleton pattern. + /// + private StubTaskEnvironmentDriver() { } + + /// + public AbsolutePath ProjectDirectory + { + get => new AbsolutePath(Directory.GetCurrentDirectory(), ignoreRootedCheck: true); + set => Directory.SetCurrentDirectory(value.Path); + } + + /// + public AbsolutePath GetAbsolutePath(string path) + { + return new AbsolutePath(Path.GetFullPath(path), ignoreRootedCheck: true); + } + + /// + public string? GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name); + } + + /// + public IReadOnlyDictionary GetEnvironmentVariables() + { + var variables = Environment.GetEnvironmentVariables(); + var result = new Dictionary(variables.Count, StringComparer.OrdinalIgnoreCase); + + foreach (string key in variables.Keys) + { + if (variables[key] is string value) + { + result[key] = value; + } + } + + return result; + } + + /// + public void SetEnvironmentVariable(string name, string? value) + { + Environment.SetEnvironmentVariable(name, value); + } + + /// + public void SetEnvironment(IDictionary newEnvironment) + { + // First, delete all no longer set variables + IReadOnlyDictionary currentEnvironment = GetEnvironmentVariables(); + foreach (KeyValuePair entry in currentEnvironment) + { + if (!newEnvironment.ContainsKey(entry.Key)) + { + SetEnvironmentVariable(entry.Key, null); + } + } + + // Then, make sure the new ones have their new values. + foreach (KeyValuePair entry in newEnvironment) + { + if (!currentEnvironment.TryGetValue(entry.Key, out string? currentValue) || currentValue != entry.Value) + { + SetEnvironmentVariable(entry.Key, entry.Value); + } + } + } + + /// + public ProcessStartInfo GetProcessStartInfo() + { + return new ProcessStartInfo(); + } + } +} diff --git a/src/Framework/TaskEnvironment.cs b/src/Framework/TaskEnvironment.cs new file mode 100644 index 00000000000..1f514d0cec5 --- /dev/null +++ b/src/Framework/TaskEnvironment.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Build.Framework.PathHelpers; + +namespace Microsoft.Build.Framework +{ + /// + /// Provides task execution environment including environment variables, + /// file paths, and process management capabilities to multi-threadable tasks. + /// + public sealed class TaskEnvironment + { + private readonly ITaskEnvironmentDriver _driver; + + /// + /// Initializes a new instance of the TaskEnvironment class. + /// + internal TaskEnvironment(ITaskEnvironmentDriver driver) + { + _driver = driver; + } + + /// + /// Gets or sets the project directory for the task execution. + /// + public AbsolutePath ProjectDirectory + { + get => _driver.ProjectDirectory; + internal set => _driver.ProjectDirectory = value; + } + + /// + /// Converts a relative or absolute path string to an absolute path. + /// This function resolves paths relative to ProjectDirectory. + /// + /// The path to convert. + /// An absolute path representation. + public AbsolutePath GetAbsolutePath(string path) => _driver.GetAbsolutePath(path); + + /// + /// Gets the value of an environment variable. + /// + /// The name of the environment variable. + /// The value of the environment variable, or null if it does not exist. + public string? GetEnvironmentVariable(string name) => _driver.GetEnvironmentVariable(name); + + /// + /// Gets a dictionary containing all environment variables. + /// + /// A read-only dictionary of environment variables. + public IReadOnlyDictionary GetEnvironmentVariables() => _driver.GetEnvironmentVariables(); + + /// + /// Sets the value of an environment variable. + /// + /// The name of the environment variable. + /// The value to set, or null to remove the environment variable. + public void SetEnvironmentVariable(string name, string? value) => _driver.SetEnvironmentVariable(name, value); + + /// + /// Updates the environment to match the provided dictionary. + /// This mirrors the behavior of CommunicationsUtilities.SetEnvironment but operates on this TaskEnvironment. + /// + /// The new environment variables to set. + internal void SetEnvironment(IDictionary newEnvironment) => _driver.SetEnvironment(newEnvironment); + + /// + /// Creates a new ProcessStartInfo configured for the current task execution environment. + /// + /// A ProcessStartInfo object configured for the current task execution environment. + public ProcessStartInfo GetProcessStartInfo() => _driver.GetProcessStartInfo(); + } +} diff --git a/src/Tasks/Message.cs b/src/Tasks/Message.cs index befd8c9883d..e53b62c0cdb 100644 --- a/src/Tasks/Message.cs +++ b/src/Tasks/Message.cs @@ -11,7 +11,7 @@ namespace Microsoft.Build.Tasks /// /// Task that simply emits a message. Importance defaults to high if not specified. /// - public sealed class Message : TaskExtension + public sealed class Message : TaskExtension, IMultiThreadableTask { /// /// Text to log. @@ -45,6 +45,11 @@ public sealed class Message : TaskExtension /// public bool IsCritical { get; set; } + /// + /// Task environment for multithreaded execution + /// + public TaskEnvironment TaskEnvironment { get; set; } + public override bool Execute() { MessageImportance messageImportance; From 7f4cfffd260a8c01c7fc13e899e897fc0b22ba78 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:45:49 +0200 Subject: [PATCH 02/10] Add task environment tests --- .../TaskEnvironment_Tests.cs | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 src/Framework.UnitTests/TaskEnvironment_Tests.cs diff --git a/src/Framework.UnitTests/TaskEnvironment_Tests.cs b/src/Framework.UnitTests/TaskEnvironment_Tests.cs new file mode 100644 index 00000000000..457b3caf644 --- /dev/null +++ b/src/Framework.UnitTests/TaskEnvironment_Tests.cs @@ -0,0 +1,334 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Framework.PathHelpers; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.UnitTests +{ + public class TaskEnvironmentTests + { + private const string StubEnvironmentName = "Stub"; + private const string MultithreadedEnvironmentName = "Multithreaded"; + + public static TheoryData EnvironmentTypes => + new TheoryData + { + StubEnvironmentName, + MultithreadedEnvironmentName + }; + + private static TaskEnvironment CreateTaskEnvironment(string environmentType) + { + return environmentType switch + { + StubEnvironmentName => new TaskEnvironment(StubTaskEnvironmentDriver.Instance), + MultithreadedEnvironmentName => new TaskEnvironment(new MultithreadedTaskEnvironmentDriver( + Path.GetTempPath(), + new Dictionary(Environment.GetEnvironmentVariables().Cast() + .Where(e => e.Key is not null && e.Value is not null) + .ToDictionary(e => e.Key!.ToString()!, e => e.Value!.ToString()!)))), + _ => throw new ArgumentException($"Unknown environment type: {environmentType}") + }; + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_SetAndGetEnvironmentVariable_ShouldWork(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string testVarName = $"MSBUILD_TEST_VAR_{environmentType}_{Guid.NewGuid():N}"; + string testVarValue = $"test_value_{environmentType}"; + + try + { + // Act + taskEnvironment.SetEnvironmentVariable(testVarName, testVarValue); + var retrievedValue = taskEnvironment.GetEnvironmentVariable(testVarName); + + // Assert + retrievedValue.ShouldBe(testVarValue); + + // Verify it appears in GetEnvironmentVariables + var allVariables = taskEnvironment.GetEnvironmentVariables(); + allVariables.TryGetValue(testVarName, out string? actualValue).ShouldBeTrue(); + actualValue.ShouldBe(testVarValue); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(testVarName, null); + } + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_SetEnvironmentVariableToNull_ShouldRemoveVariable(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string testVarName = $"MSBUILD_REMOVE_TEST_{environmentType}_{Guid.NewGuid():N}"; + string testVarValue = "value_to_remove"; + + try + { + // Setup - first set the variable + taskEnvironment.SetEnvironmentVariable(testVarName, testVarValue); + taskEnvironment.GetEnvironmentVariable(testVarName).ShouldBe(testVarValue); + + // Act - remove the variable + taskEnvironment.SetEnvironmentVariable(testVarName, null); + + // Assert + taskEnvironment.GetEnvironmentVariable(testVarName).ShouldBeNull(); + var allVariables = taskEnvironment.GetEnvironmentVariables(); + allVariables.TryGetValue(testVarName, out string? actualValue).ShouldBeFalse(); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(testVarName, null); + } + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_SetEnvironment_ShouldReplaceAllVariables(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string prefix = $"MSBUILD_SET_ENV_TEST_{environmentType}_{Guid.NewGuid():N}"; + string var1Name = $"{prefix}_VAR1"; + string var2Name = $"{prefix}_VAR2"; + string var3Name = $"{prefix}_VAR3"; + + try + { + // Setup initial state + taskEnvironment.SetEnvironmentVariable(var1Name, "initial_value1"); + taskEnvironment.SetEnvironmentVariable(var2Name, "initial_value2"); + + var newEnvironment = new Dictionary + { + [var2Name] = "updated_value2", // Update existing + [var3Name] = "new_value3" // Add new + // var1Name is intentionally omitted to test removal + }; + + // Act + taskEnvironment.SetEnvironment(newEnvironment); + + // Assert + taskEnvironment.GetEnvironmentVariable(var1Name).ShouldBeNull(); // Should be removed + taskEnvironment.GetEnvironmentVariable(var2Name).ShouldBe("updated_value2"); // Should be updated + taskEnvironment.GetEnvironmentVariable(var3Name).ShouldBe("new_value3"); // Should be added + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(var1Name, null); + Environment.SetEnvironmentVariable(var2Name, null); + Environment.SetEnvironmentVariable(var3Name, null); + } + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_SetAndGetProjectDirectory_ShouldWork(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string originalDirectory = Directory.GetCurrentDirectory(); + string testDirectory = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + string alternateDirectory = Path.GetDirectoryName(testDirectory)!; + + try + { + // Act - Set project directory + taskEnvironment.ProjectDirectory = new AbsolutePath(testDirectory, ignoreRootedCheck: true); + var retrievedDirectory = taskEnvironment.ProjectDirectory; + + // Assert + retrievedDirectory.Path.ShouldBe(testDirectory); + + // Act - Change to alternate directory + taskEnvironment.ProjectDirectory = new AbsolutePath(alternateDirectory, ignoreRootedCheck: true); + var newRetrievedDirectory = taskEnvironment.ProjectDirectory; + + // Assert + newRetrievedDirectory.Path.ShouldBe(alternateDirectory); + + // Verify behavior differs based on environment type + if (environmentType == StubEnvironmentName) + { + // Stub should change system current directory + Directory.GetCurrentDirectory().ShouldBe(alternateDirectory); + } + else + { + // Multithreaded should not change system current directory + Directory.GetCurrentDirectory().ShouldBe(originalDirectory); + } + } + finally + { + // Restore original directory + Directory.SetCurrentDirectory(originalDirectory); + } + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_GetAbsolutePath_ShouldResolveCorrectly(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string baseDirectory = Path.GetTempPath(); + string relativePath = Path.Combine("subdir", "file.txt"); + string originalDirectory = Directory.GetCurrentDirectory(); + + try + { + // Set project directory + taskEnvironment.ProjectDirectory = new AbsolutePath(baseDirectory, ignoreRootedCheck: true); + + // Act + var absolutePath = taskEnvironment.GetAbsolutePath(relativePath); + + // Assert + Path.IsPathRooted(absolutePath.Path).ShouldBeTrue(); + string expectedPath = Path.Combine(baseDirectory, relativePath); + absolutePath.Path.ShouldBe(expectedPath); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + } + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_GetAbsolutePath_WithAlreadyAbsolutePath_ShouldReturnUnchanged(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string absoluteInputPath = Path.Combine(Path.GetTempPath(), "already", "absolute", "path.txt"); + + // Act + var resultPath = taskEnvironment.GetAbsolutePath(absoluteInputPath); + + // Assert + resultPath.Path.ShouldBe(absoluteInputPath); + } + + [Theory] + [MemberData(nameof(EnvironmentTypes))] + public void TaskEnvironment_GetProcessStartInfo_ShouldConfigureCorrectly(string environmentType) + { + // Arrange + var taskEnvironment = CreateTaskEnvironment(environmentType); + string testDirectory = Path.GetTempPath(); + string testVarName = $"MSBUILD_PROCESS_TEST_{environmentType}_{Guid.NewGuid():N}"; + string testVarValue = "process_test_value"; + + try + { + // Setup + taskEnvironment.ProjectDirectory = new AbsolutePath(testDirectory, ignoreRootedCheck: true); + taskEnvironment.SetEnvironmentVariable(testVarName, testVarValue); + + // Act + var processStartInfo = taskEnvironment.GetProcessStartInfo(); + + // Assert + processStartInfo.ShouldNotBeNull(); + + if (environmentType == StubEnvironmentName) + { + // Stub should reflect system environment, but working directory should be empty + processStartInfo.WorkingDirectory.ShouldBe(string.Empty); + } + else + { + // Multithreaded should reflect isolated environment + processStartInfo.WorkingDirectory.ShouldBe(testDirectory); + } + + processStartInfo.Environment.TryGetValue(testVarName, out string? actualValue).ShouldBeTrue(); + actualValue.ShouldBe(testVarValue); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(testVarName, null); + } + } + + [Fact] + public void TaskEnvironment_StubEnvironment_ShouldAffectSystemEnvironment() + { + // Arrange + string testVarName = $"MSBUILD_STUB_ISOLATION_TEST_{Guid.NewGuid():N}"; + string testVarValue = "stub_test_value"; + + var stubEnvironment = new TaskEnvironment(StubTaskEnvironmentDriver.Instance); + + try + { + // Act - Set variable in stub environment + stubEnvironment.SetEnvironmentVariable(testVarName, testVarValue); + + // Assert - Stub should affect system environment + Environment.GetEnvironmentVariable(testVarName).ShouldBe(testVarValue); + stubEnvironment.GetEnvironmentVariable(testVarName).ShouldBe(testVarValue); + + // Act - Remove from stub environment + stubEnvironment.SetEnvironmentVariable(testVarName, null); + + // Assert - System environment should also be affected + Environment.GetEnvironmentVariable(testVarName).ShouldBeNull(); + stubEnvironment.GetEnvironmentVariable(testVarName).ShouldBeNull(); + } + finally + { + Environment.SetEnvironmentVariable(testVarName, null); + } + } + + [Fact] + public void TaskEnvironment_MultithreadedEnvironment_ShouldBeIsolatedFromSystem() + { + // Arrange + string testVarName = $"MSBUILD_MULTITHREADED_ISOLATION_TEST_{Guid.NewGuid():N}"; + string testVarValue = "multithreaded_test_value"; + + var multithreadedEnvironment = new TaskEnvironment(new MultithreadedTaskEnvironmentDriver( + Path.GetTempPath(), + new Dictionary())); + + try + { + // Verify system doesn't have the test variable initially + Environment.GetEnvironmentVariable(testVarName).ShouldBeNull(); + + // Act - Set variable in multithreaded environment + multithreadedEnvironment.SetEnvironmentVariable(testVarName, testVarValue); + + // Assert - Multithreaded should have the value but system should not + multithreadedEnvironment.GetEnvironmentVariable(testVarName).ShouldBe(testVarValue); + Environment.GetEnvironmentVariable(testVarName).ShouldBeNull(); + } + finally + { + Environment.SetEnvironmentVariable(testVarName, null); + } + } + } +} From 1256bdef4ec6f8db6e2e9f9c5d5372f0decbe800 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Mon, 6 Oct 2025 18:47:05 +0200 Subject: [PATCH 03/10] Fix thread safe tasks docs --- .../specs/multithreading/thread-safe-tasks.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/documentation/specs/multithreading/thread-safe-tasks.md b/documentation/specs/multithreading/thread-safe-tasks.md index 044bb500a9c..99a18dccc78 100644 --- a/documentation/specs/multithreading/thread-safe-tasks.md +++ b/documentation/specs/multithreading/thread-safe-tasks.md @@ -88,16 +88,16 @@ public interface IMultiThreadableTask : ITask public class TaskEnvironment { - public virtual AbsolutePath ProjectDirectory { get; internal set; } + public AbsolutePath ProjectDirectory { get; internal set; } // This function resolves paths relative to ProjectDirectory. - public virtual AbsolutePath GetAbsolutePath(string path); + public AbsolutePath GetAbsolutePath(string path); - public virtual string? GetEnvironmentVariable(string name); - public virtual IReadOnlyDictionary GetEnvironmentVariables(); - public virtual void SetEnvironmentVariable(string name, string? value); + public string? GetEnvironmentVariable(string name); + public IReadOnlyDictionary GetEnvironmentVariables(); + public void SetEnvironmentVariable(string name, string? value); - public virtual ProcessStartInfo GetProcessStartInfo(); + public ProcessStartInfo GetProcessStartInfo(); } ``` From f5540b78923b56281b3ff852670d47b21eec7e54 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:27:05 +0200 Subject: [PATCH 04/10] Add absolute path tests --- src/Framework.UnitTests/AbsolutePath_Tests.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/Framework.UnitTests/AbsolutePath_Tests.cs diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs new file mode 100644 index 00000000000..2eee7fd6823 --- /dev/null +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Microsoft.Build.Framework.PathHelpers; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.UnitTests +{ + public class AbsolutePath_Tests + { + [Fact] + public void AbsolutePath_FromAbsolutePath_ShouldPreservePath() + { + // Arrange + string absolutePathString = Path.GetTempPath(); + + // Act + var absolutePath = new AbsolutePath(absolutePathString); + + // Assert + absolutePath.Path.ShouldBe(absolutePathString); + Path.IsPathRooted(absolutePath.Path).ShouldBeTrue(); + } + + [Theory] + [InlineData("subfolder", "should resolve relative path against base")] + [InlineData("deep/nested/path", "should handle nested relative paths")] + [InlineData(".", "should resolve to base directory")] + [InlineData("", "empty path should resolve to base directory")] + [InlineData("..", "should resolve to parent directory")] + public void AbsolutePath_FromRelativePath_ShouldResolveAgainstBase(string relativePath, string description) + { + // Arrange + string baseDirectory = Path.Combine(Path.GetTempPath(), "testfolder"); + var basePath = new AbsolutePath(baseDirectory); + + // Act + var absolutePath = new AbsolutePath(relativePath, basePath); + + // Assert - {description} + Path.IsPathRooted(absolutePath.Path).ShouldBeTrue(); + + string expectedPath = Path.Combine(baseDirectory, relativePath); + absolutePath.Path.ShouldBe(expectedPath); + } + + [Fact] + public void AbsolutePath_Equality_ShouldWorkCorrectly() + { + // Arrange + string testPath = Path.GetTempPath(); + var path1 = new AbsolutePath(testPath); + var path2 = new AbsolutePath(testPath); + var differentPath = new AbsolutePath(Path.Combine(testPath, "different")); + + // Act & Assert + path1.ShouldBe(path2); + (path1 == path2).ShouldBeTrue(); + path1.ShouldNotBe(differentPath); + (path1 == differentPath).ShouldBeFalse(); + } + + [Theory] + [InlineData("not/rooted/path", false, true)] + [InlineData("not/rooted/path", true, false)] + public void AbsolutePath_RootedValidation_ShouldBehaveProperly(string path, bool ignoreRootedCheck, bool shouldThrow) + { + // Act & Assert + if (shouldThrow) + { + Should.Throw(() => new AbsolutePath(path, ignoreRootedCheck: ignoreRootedCheck)); + } + else + { + var absolutePath = new AbsolutePath(path, ignoreRootedCheck: ignoreRootedCheck); + absolutePath.Path.ShouldBe(path); + } + } + } +} From bf5c794037319f131a6d4a716d4d7d7ff6a38e50 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:06:01 +0200 Subject: [PATCH 05/10] fix merge errors --- src/Build/Instance/TaskFactories/TaskHostTask.cs | 7 ++----- src/Framework.UnitTests/AbsolutePath_Tests.cs | 14 +++++++------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs index d08f805e756..f2cb632994c 100644 --- a/src/Build/Instance/TaskFactories/TaskHostTask.cs +++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs @@ -164,11 +164,8 @@ public TaskHostTask( #if FEATURE_APPDOMAIN AppDomainSetup appDomainSetup, #endif - int scheduledNodeId = -1 - TaskEnvironment taskEnvironment - ) -#pragma warning disable SA1111, SA1009 // Closing parenthesis should be on line of last parameter - + int scheduledNodeId = -1, + TaskEnvironment taskEnvironment = null) { ErrorUtilities.VerifyThrowInternalNull(taskType); ErrorUtilities.VerifyThrowInternalNull(taskEnvironment); diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index 2eee7fd6823..be53cec2515 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -25,12 +25,12 @@ public void AbsolutePath_FromAbsolutePath_ShouldPreservePath() } [Theory] - [InlineData("subfolder", "should resolve relative path against base")] - [InlineData("deep/nested/path", "should handle nested relative paths")] - [InlineData(".", "should resolve to base directory")] - [InlineData("", "empty path should resolve to base directory")] - [InlineData("..", "should resolve to parent directory")] - public void AbsolutePath_FromRelativePath_ShouldResolveAgainstBase(string relativePath, string description) + [InlineData("subfolder")] + [InlineData("deep/nested/path")] + [InlineData(".")] + [InlineData("")] + [InlineData("..")] + public void AbsolutePath_FromRelativePath_ShouldResolveAgainstBase(string relativePath) { // Arrange string baseDirectory = Path.Combine(Path.GetTempPath(), "testfolder"); @@ -39,7 +39,7 @@ public void AbsolutePath_FromRelativePath_ShouldResolveAgainstBase(string relati // Act var absolutePath = new AbsolutePath(relativePath, basePath); - // Assert - {description} + // Assert Path.IsPathRooted(absolutePath.Path).ShouldBeTrue(); string expectedPath = Path.Combine(baseDirectory, relativePath); From 739cf4d20f0efa7d67c3444dd38a9fb274bb2545 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:26:15 +0200 Subject: [PATCH 06/10] Fix bug with not passing schedulled node id to the task host task --- .../BackEnd/TaskExecutionHost/TaskExecutionHost.cs | 10 ++++++++-- .../Instance/TaskFactories/AssemblyTaskFactory.cs | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs index 4a198d664e2..84cfd932a30 100644 --- a/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs +++ b/src/Build/BackEnd/TaskExecutionHost/TaskExecutionHost.cs @@ -1011,7 +1011,7 @@ private ITask InstantiateTask(int scheduledNodeId, IDictionary t return null; } - task = CreateTaskHostTaskForOutOfProcFactory(taskIdentityParameters, loggingHost, outOfProcTaskFactory); + task = CreateTaskHostTaskForOutOfProcFactory(taskIdentityParameters, loggingHost, outOfProcTaskFactory, scheduledNodeId); isTaskHost = true; } else @@ -1748,8 +1748,13 @@ private void DisplayCancelWaitMessage() /// Task identity parameters. /// The logging host to use for the task. /// The out-of-process task factory instance. + /// Node for which the task host should be called /// A TaskHostTask that will execute the inner task out of process, or null if task creation fails. - private ITask CreateTaskHostTaskForOutOfProcFactory(IDictionary taskIdentityParameters, TaskFactoryLoggingHost loggingHost, IOutOfProcTaskFactory outOfProcTaskFactory) + private ITask CreateTaskHostTaskForOutOfProcFactory( + IDictionary taskIdentityParameters, + TaskFactoryLoggingHost loggingHost, + IOutOfProcTaskFactory outOfProcTaskFactory, + int scheduledNodeId) { ITask innerTask; @@ -1809,6 +1814,7 @@ private ITask CreateTaskHostTaskForOutOfProcFactory(IDictionary #if FEATURE_APPDOMAIN AppDomainSetup, #endif + scheduledNodeId: scheduledNodeId, TaskEnvironment ); #pragma warning restore SA1111, SA1009 // Closing parenthesis should be on line of last parameter diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs index 34a6fcb8078..2a5a1536eb5 100644 --- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs +++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs @@ -352,6 +352,7 @@ internal ITask CreateTaskInstance( appDomainSetup, #endif isOutOfProc, + scheduledNodeId, getProperty, new TaskEnvironment(StubTaskEnvironmentDriver.Instance)); } @@ -368,6 +369,7 @@ internal ITask CreateTaskInstance( AppDomainSetup appDomainSetup, #endif bool isOutOfProc, + int scheduledNodeId, Func getProperty, TaskEnvironment taskEnvironment) { From 7b0145b735d4b49e9ae90625346b909441bce867 Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:31:36 +0200 Subject: [PATCH 07/10] Make all task host task constructor parameters required --- src/Build/Instance/TaskFactories/TaskHostTask.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs index f2cb632994c..25d8b0c3056 100644 --- a/src/Build/Instance/TaskFactories/TaskHostTask.cs +++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs @@ -164,8 +164,8 @@ public TaskHostTask( #if FEATURE_APPDOMAIN AppDomainSetup appDomainSetup, #endif - int scheduledNodeId = -1, - TaskEnvironment taskEnvironment = null) + int scheduledNodeId, + TaskEnvironment taskEnvironment) { ErrorUtilities.VerifyThrowInternalNull(taskType); ErrorUtilities.VerifyThrowInternalNull(taskEnvironment); From f23a8e6b4a780db541ae684fc0818bd0379fc0af Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:47:17 +0200 Subject: [PATCH 08/10] Add end-to-end test --- .../MSBuildMultithreaded_Tests.cs | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs diff --git a/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs b/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs new file mode 100644 index 00000000000..a77afec4e6d --- /dev/null +++ b/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs @@ -0,0 +1,152 @@ +// 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 Microsoft.Build.Framework; +using Microsoft.Build.Shared; +using Microsoft.Build.UnitTests; +using Microsoft.Build.UnitTests.Shared; +using Microsoft.Build.Utilities; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Build.Engine.UnitTests +{ + + /// + /// Test task that implements IMultiThreadableTask and verifies environment isolation. + /// This task checks that TaskEnvironment is properly provided and tests different + /// environment variable behavior between multithreaded and single-threaded modes. + /// + public class EnvironmentIsolationTestTask : Task, IMultiThreadableTask + { + public TaskEnvironment TaskEnvironment { get; set; } = null!; + + /// + /// Indicates whether this task is expected to run in multithreaded mode. + /// Used to verify different environment variable behavior. + /// + public bool IsMultithreadedMode { get; set; } = false; + + public override bool Execute() + { + if (!VerifyTaskEnvironment()) + { + return false; + } + + // Test environment variable behavior based on mode + return TestEnvironmentIsolation(); + } + + private bool VerifyTaskEnvironment() + { + if (TaskEnvironment == null) + { + Log.LogError("TaskEnvironment was not provided to multithreadable task"); + return false; + } + + if (TaskEnvironment.ProjectDirectory == null) + { + Log.LogError("TaskEnvironment.ProjectDirectory is null"); + return false; + } + + return true; + } + + private bool TestEnvironmentIsolation() + { + string mode = IsMultithreadedMode ? "MultiThreaded" : "MultiProcess"; + string envVarName = $"MSBUILD_MULTITHREADED_TEST_VAR_{Guid.NewGuid():N}"; + string envVarValue = "TestValue"; + + // Set environment variable using TaskEnvironment + TaskEnvironment.SetEnvironmentVariable(envVarName, envVarValue); + + // Read using both TaskEnvironment and Environment.GetEnvironmentVariable + string? taskEnvValue = TaskEnvironment.GetEnvironmentVariable(envVarName); + string? globalEnvValue = Environment.GetEnvironmentVariable(envVarName); + + // Verify TaskEnvironment always works correctly + if (taskEnvValue != envVarValue) + { + Log.LogError($"{mode} Mode: TaskEnvironment failed to read back value. Set: {envVarValue}, Read: {taskEnvValue}"); + return false; + } + + if (IsMultithreadedMode) + { + // TaskEnvironment and Environment.GetEnvironmentVariable should differ + if (taskEnvValue == globalEnvValue) + { + Log.LogError($"{mode} Mode: Expected TaskEnvironment to be isolated, but it is not"); + return false; + } + Log.LogMessage(MessageImportance.High, $"{mode} Mode - TaskEnvironment is isolated from global environment (PASS)"); + } + else + { + // TaskEnvironment and Environment.GetEnvironmentVariable should be the same + if (taskEnvValue != globalEnvValue) + { + Log.LogError($"{mode} Mode: Expected TaskEnvironment and Environment.GetEnvironmentVariable to be the same, but they differ"); + return false; + } + Log.LogMessage(MessageImportance.High, $"{mode} Mode - TaskEnvironment matches global environment (PASS)"); + } + + return true; + } + } + + /// + /// Integration tests for MSBuild and CallTarget tasks with TaskEnvironment support. + /// These tests verify that tasks work correctly in both multithreaded and single-threaded scenarios + /// with proper environment isolation, following the pattern of MSBuildServer_Tests. + /// + public class MSBuildMultithreaded_Tests : IDisposable + { + private readonly ITestOutputHelper _output; + private readonly TestEnvironment _env; + + public MSBuildMultithreaded_Tests(ITestOutputHelper output) + { + _output = output; + _env = TestEnvironment.Create(output); + } + + public void Dispose() + { + _env.Dispose(); + } + + [Theory] + [InlineData(false, "/m /nodereuse:false /mt")] + [InlineData(false, "/m /nodereuse:false")] + public void MSBuildTask_EnvironmentIsolation(bool isMultithreaded, string msbuildArgs) + { + string project = $@" + + + + + + +"; + TransientTestFile projectFile = _env.CreateFile("main.proj", project); + + string output = RunnerUtilities.ExecMSBuild( + BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, + $"\"{projectFile.Path}\" {msbuildArgs}", + out bool success, + false, + _output); + + success.ShouldBeTrue(); + } + } +} From b543e412b181bc8171b1d08853f68b37575eb22d Mon Sep 17 00:00:00 2001 From: Alina Mayorova <67507805+AR-May@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:04:00 +0200 Subject: [PATCH 09/10] attempt to fix intrinsic tasks --- .../IntrinsicTasks/CallTarget.cs | 10 ++++-- .../RequestBuilder/IntrinsicTasks/MSBuild.cs | 33 ++++++++++++------- src/Framework/PathHelpers/AbsolutePath.cs | 16 +++++++++ src/Tasks/CallTarget.cs | 10 ++++-- src/Tasks/MSBuild.cs | 28 ++++++++++------ 5 files changed, 71 insertions(+), 26 deletions(-) diff --git a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/CallTarget.cs b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/CallTarget.cs index 7d5eb31fc38..e5d011c53e2 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/CallTarget.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/CallTarget.cs @@ -19,7 +19,7 @@ namespace Microsoft.Build.BackEnd /// id validation checks to fail. /// [RunInMTA] - internal class CallTarget : ITask + internal class CallTarget : ITask, IMultiThreadableTask { /// /// The task logging helper @@ -57,6 +57,11 @@ internal class CallTarget : ITask /// public bool UseResultsCache { get; set; } = false; + /// + /// Task environment for isolated execution. + /// + public TaskEnvironment TaskEnvironment { get; set; } + #endregion #region ITask Members @@ -113,7 +118,8 @@ public Task ExecuteInternal() targetOutputs: _targetOutputs, unloadProjectsOnCompletion: false, toolsVersion: null, - skipNonexistentTargets: false); + skipNonexistentTargets: false, + taskEnvironment: TaskEnvironment); } #endregion diff --git a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs index 8142e3266d4..4dc62d7bdfa 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs @@ -6,6 +6,7 @@ using System.IO; using System.Threading.Tasks; using Microsoft.Build.Framework; +using Microsoft.Build.Framework.PathHelpers; using Microsoft.Build.Shared; using Microsoft.Build.Shared.FileSystem; @@ -19,7 +20,7 @@ namespace Microsoft.Build.BackEnd /// /// This class implements the "MSBuild" task, which hands off child project files to the MSBuild engine to be built. /// - internal class MSBuild : ITask + internal class MSBuild : ITask, IMultiThreadableTask { /// /// Enum describing the behavior when a project doesn't exist on disk. @@ -215,6 +216,11 @@ public string SkipNonexistentProjects /// /// public bool SkipNonexistentTargets { get; set; } + + /// + /// Task environment for isolated execution. + /// + public TaskEnvironment TaskEnvironment { get; set; } #endregion #region ITask Members @@ -310,7 +316,7 @@ public async Task ExecuteInternal() { ITaskItem project = Projects[i]; - string projectPath = FileUtilities.AttemptToShortenPath(project.ItemSpec); + AbsolutePath projectPath = TaskEnvironment.GetAbsolutePath(FileUtilities.AttemptToShortenPath(project.ItemSpec)); if (StopOnFirstFailure && !success) { @@ -368,7 +374,8 @@ public async Task ExecuteInternal() _targetOutputs, UnloadProjectsOnCompletion, ToolsVersion, - SkipNonexistentTargets); + SkipNonexistentTargets, + TaskEnvironment); if (!executeResult) { @@ -439,7 +446,8 @@ private async Task BuildProjectsInParallel(Dictionary prop _targetOutputs, UnloadProjectsOnCompletion, ToolsVersion, - SkipNonexistentTargets); + SkipNonexistentTargets, + TaskEnvironment); if (!executeResult) { @@ -531,7 +539,8 @@ internal static async Task ExecuteTargets( List targetOutputs, bool unloadProjectsOnCompletion, string toolsVersion, - bool skipNonexistentTargets) + bool skipNonexistentTargets, + TaskEnvironment taskEnvironment) { bool success = true; @@ -539,14 +548,14 @@ internal static async Task ExecuteTargets( // build, because it'll all be in the immediately subsequent ProjectStarted event. var projectDirectory = new string[projects.Length]; - var projectNames = new string[projects.Length]; + var projectNames = new AbsolutePath[projects.Length]; var toolsVersions = new string[projects.Length]; var projectProperties = new Dictionary[projects.Length]; var undefinePropertiesPerProject = new List[projects.Length]; for (int i = 0; i < projectNames.Length; i++) { - projectNames[i] = null; + projectNames[i] = default; projectProperties[i] = propertiesTable; if (projects[i] != null) @@ -554,7 +563,7 @@ internal static async Task ExecuteTargets( // Retrieve projectDirectory only the first time. It never changes anyway. string projectPath = FileUtilities.AttemptToShortenPath(projects[i].ItemSpec); projectDirectory[i] = Path.GetDirectoryName(projectPath); - projectNames[i] = projects[i].ItemSpec; + projectNames[i] = taskEnvironment.GetAbsolutePath(projects[i].ItemSpec); toolsVersions[i] = toolsVersion; // If the user specified a different set of global properties for this project, then @@ -563,7 +572,7 @@ internal static async Task ExecuteTargets( { if (!PropertyParser.GetTableWithEscaping( log, - ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("General.OverridingProperties", projectNames[i]), + ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("General.OverridingProperties", projects[i].ItemSpec), ItemMetadataNames.PropertiesMetadataName, projects[i].GetMetadata(ItemMetadataNames.PropertiesMetadataName).Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries), out Dictionary preProjectPropertiesTable)) @@ -592,7 +601,7 @@ internal static async Task ExecuteTargets( if (log != null && propertiesToUndefine.Length > 0) { - log.LogMessageFromResources(MessageImportance.Low, "General.ProjectUndefineProperties", projectNames[i]); + log.LogMessageFromResources(MessageImportance.Low, "General.ProjectUndefineProperties", projects[i].ItemSpec); foreach (string property in propertiesToUndefine) { undefinePropertiesPerProject[i].Add(property); @@ -607,7 +616,7 @@ internal static async Task ExecuteTargets( { if (!PropertyParser.GetTableWithEscaping( log, - ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("General.AdditionalProperties", projectNames[i]), + ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("General.AdditionalProperties", projects[i].ItemSpec), ItemMetadataNames.AdditionalPropertiesMetadataName, projects[i].GetMetadata(ItemMetadataNames.AdditionalPropertiesMetadataName).Split(MSBuildConstants.SemicolonChar, StringSplitOptions.RemoveEmptyEntries), out Dictionary additionalProjectPropertiesTable)) @@ -660,7 +669,7 @@ internal static async Task ExecuteTargets( // as the *calling* project file. var taskHost = (TaskHost)buildEngine; - BuildEngineResult result = await taskHost.InternalBuildProjects(projectNames, targetList, projectProperties, undefinePropertiesPerProject, toolsVersions, true /* ask that target outputs are returned in the buildengineresult */, skipNonexistentTargets); + BuildEngineResult result = await taskHost.InternalBuildProjects(projectNames.ToStringArray(), targetList, projectProperties, undefinePropertiesPerProject, toolsVersions, true /* ask that target outputs are returned in the buildengineresult */, skipNonexistentTargets); bool currentTargetResult = result.Result; IList> targetOutputsPerProject = result.TargetOutputsPerProject; diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs index c8d6a11deac..265844543f5 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -83,4 +83,20 @@ public AbsolutePath(string path, AbsolutePath basePath) /// The path as a string. public override string ToString() => Path; } + + /// + /// Extension methods for AbsolutePath. + /// + internal static class AbsolutePathExtensions + { + internal static string[] ToStringArray(this AbsolutePath[] absolutePaths) + { + string[] stringPaths = new string[absolutePaths.Length]; + for (int i = 0; i < absolutePaths.Length; i++) + { + stringPaths[i] = absolutePaths[i].Path; + } + return stringPaths; + } + } } diff --git a/src/Tasks/CallTarget.cs b/src/Tasks/CallTarget.cs index 74acb20e7c3..32cdce6d3e6 100644 --- a/src/Tasks/CallTarget.cs +++ b/src/Tasks/CallTarget.cs @@ -15,7 +15,7 @@ namespace Microsoft.Build.Tasks /// id validation checks to fail. /// [RunInMTA] - public class CallTarget : TaskExtension + public class CallTarget : TaskExtension, IMultiThreadableTask { #region Properties @@ -51,6 +51,11 @@ public class CallTarget : TaskExtension /// public bool UseResultsCache { get; set; } = false; + /// + /// Task environment for isolated execution. + /// + public TaskEnvironment TaskEnvironment { get; set; } + #endregion #region ITask Members @@ -87,7 +92,8 @@ public override bool Execute() Log, _targetOutputs, false, - null); // toolsVersion = null + null, // toolsVersion = null + TaskEnvironment); } #endregion diff --git a/src/Tasks/MSBuild.cs b/src/Tasks/MSBuild.cs index d3c24bd0f1d..c07ac7e351a 100644 --- a/src/Tasks/MSBuild.cs +++ b/src/Tasks/MSBuild.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using Microsoft.Build.Framework; +using Microsoft.Build.Framework.PathHelpers; using Microsoft.Build.Shared; using Microsoft.Build.Shared.FileSystem; using Microsoft.Build.Utilities; @@ -19,7 +20,7 @@ namespace Microsoft.Build.Tasks /// RequestBuilder which spawned them. /// [RunInMTA] - public class MSBuild : TaskExtension + public class MSBuild : TaskExtension, IMultiThreadableTask { /// /// Enum describing the behavior when a project doesn't exist on disk. @@ -187,6 +188,11 @@ public string SkipNonexistentProjects /// public string[] TargetAndPropertyListSeparators { get; set; } + /// + /// Task environment for isolated execution. + /// + public TaskEnvironment TaskEnvironment { get; set; } + #endregion #region ITask Members @@ -276,7 +282,7 @@ public override bool Execute() { ITaskItem project = Projects[i]; - string projectPath = FileUtilities.AttemptToShortenPath(project.ItemSpec); + AbsolutePath projectPath = TaskEnvironment.GetAbsolutePath(FileUtilities.AttemptToShortenPath(project.ItemSpec)); if (StopOnFirstFailure && !success) { @@ -334,7 +340,8 @@ public override bool Execute() Log, _targetOutputs, UnloadProjectsOnCompletion, - ToolsVersion)) + ToolsVersion, + TaskEnvironment)) { success = false; } @@ -398,7 +405,8 @@ private bool BuildProjectsInParallel(Dictionary propertiesTable, Log, _targetOutputs, UnloadProjectsOnCompletion, - ToolsVersion)) + ToolsVersion, + TaskEnvironment)) { success = false; } @@ -488,7 +496,8 @@ internal static bool ExecuteTargets( TaskLoggingHelper log, List targetOutputs, bool unloadProjectsOnCompletion, - string toolsVersion) + string toolsVersion, + TaskEnvironment taskEnvironment) { bool success = true; @@ -496,22 +505,21 @@ internal static bool ExecuteTargets( // build, because it'll all be in the immediately subsequent ProjectStarted event. var projectDirectory = new string[projects.Count]; - var projectNames = new string[projects.Count]; + var projectNames = new AbsolutePath[projects.Count]; var toolsVersions = new string[projects.Count]; var projectProperties = new Dictionary[projects.Count]; var undefinePropertiesPerProject = new IList[projects.Count]; for (int i = 0; i < projectNames.Length; i++) { - projectNames[i] = null; + projectNames[i] = default; projectProperties[i] = propertiesTable; if (projects[i] != null) { - // Retrieve projectDirectory only the first time. It never changes anyway. string projectPath = FileUtilities.AttemptToShortenPath(projects[i].ItemSpec); projectDirectory[i] = Path.GetDirectoryName(projectPath); - projectNames[i] = projects[i].ItemSpec; + projectNames[i] = taskEnvironment.GetAbsolutePath(projects[i].ItemSpec); toolsVersions[i] = toolsVersion; // If the user specified a different set of global properties for this project, then @@ -609,7 +617,7 @@ internal static bool ExecuteTargets( // as the *calling* project file. BuildEngineResult result = - buildEngine.BuildProjectFilesInParallel(projectNames, targetList, projectProperties, undefinePropertiesPerProject, toolsVersions, true /* ask that target outputs are returned in the buildengineresult */); + buildEngine.BuildProjectFilesInParallel(projectNames.ToStringArray(), targetList, projectProperties, undefinePropertiesPerProject, toolsVersions, true /* ask that target outputs are returned in the buildengineresult */); bool currentTargetResult = result.Result; IList> targetOutputsPerProject = result.TargetOutputsPerProject; From 3b9df087c01135f92c97679ada3c8bf7902a5f9b Mon Sep 17 00:00:00 2001 From: AR-May <67507805+AR-May@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:49:33 +0100 Subject: [PATCH 10/10] Fix merge errors. --- src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs | 4 ++-- src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs | 2 +- src/Framework/TaskEnvironment.cs | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs index 30c64c99159..81bc08994d8 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs @@ -1086,7 +1086,7 @@ private void SetProjectDirectory() { if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) { - _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.PathHelpers.AbsolutePath(_requestEntry.ProjectRootDirectory, ignoreRootedCheck: true); + _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.AbsolutePath(_requestEntry.ProjectRootDirectory, ignoreRootedCheck: true); } else { @@ -1429,7 +1429,7 @@ private void RestoreOperatingEnvironment() if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_0)) { - _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.PathHelpers.AbsolutePath(_requestEntry.RequestConfiguration.SavedCurrentDirectory, ignoreRootedCheck: true); + _requestEntry.TaskEnvironment.ProjectDirectory = new Framework.AbsolutePath(_requestEntry.RequestConfiguration.SavedCurrentDirectory, ignoreRootedCheck: true); } else { diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs index fe385481fd4..acba01171f6 100644 --- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs +++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs @@ -345,7 +345,7 @@ internal ITask CreateTaskInstance( ElementLocation taskLocation, TaskLoggingContext taskLoggingContext, IBuildComponentHost buildComponentHost, - IDictionary taskIdentityParameters, + in TaskHostParameters taskIdentityParameters, #if FEATURE_APPDOMAIN AppDomainSetup appDomainSetup, #endif diff --git a/src/Framework/TaskEnvironment.cs b/src/Framework/TaskEnvironment.cs index dff5ea2e349..e3d04ee55ba 100644 --- a/src/Framework/TaskEnvironment.cs +++ b/src/Framework/TaskEnvironment.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Diagnostics;