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/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..4188ff97826 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs @@ -19,7 +19,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 +215,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 +315,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 +373,8 @@ public async Task ExecuteInternal() _targetOutputs, UnloadProjectsOnCompletion, ToolsVersion, - SkipNonexistentTargets); + SkipNonexistentTargets, + TaskEnvironment); if (!executeResult) { @@ -439,7 +445,8 @@ private async Task BuildProjectsInParallel(Dictionary prop _targetOutputs, UnloadProjectsOnCompletion, ToolsVersion, - SkipNonexistentTargets); + SkipNonexistentTargets, + TaskEnvironment); if (!executeResult) { @@ -531,7 +538,8 @@ internal static async Task ExecuteTargets( List targetOutputs, bool unloadProjectsOnCompletion, string toolsVersion, - bool skipNonexistentTargets) + bool skipNonexistentTargets, + TaskEnvironment taskEnvironment) { bool success = true; @@ -539,14 +547,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 +562,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 +571,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 +600,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 +615,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 +668,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/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs b/src/Build/BackEnd/Components/RequestBuilder/RequestBuilder.cs index 0b112221b9a..81bc08994d8 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.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.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 12890a9124b..89aa9a64c67 100644 --- a/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs +++ b/src/Build/BackEnd/Components/RequestBuilder/TaskBuilder.cs @@ -130,6 +130,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 +425,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 b49d1e78f87..3622a7f3fbf 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. /// @@ -964,7 +975,8 @@ private ITask InstantiateTask(int scheduledNodeId, in TaskHostParameters taskIde #endif IsOutOfProc, scheduledNodeId, - ProjectInstance.GetProperty); + ProjectInstance.GetProperty, + TaskEnvironment); } else { @@ -1804,7 +1816,8 @@ private ITask CreateTaskHostTaskForOutOfProcFactory( #if FEATURE_APPDOMAIN AppDomainSetup, #endif - scheduledNodeId); + scheduledNodeId, + TaskEnvironment); } } } diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs index 73f3c411e52..acba01171f6 100644 --- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs +++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs @@ -310,7 +310,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, @@ -323,6 +323,36 @@ internal ITask CreateTaskInstance( bool isOutOfProc, int scheduledNodeId, Func getProperty) + { + return CreateTaskInstance( + taskLocation, + taskLoggingContext, + buildComponentHost, + taskIdentityParameters, +#if FEATURE_APPDOMAIN + appDomainSetup, +#endif + isOutOfProc, + scheduledNodeId, + 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, + in TaskHostParameters taskIdentityParameters, +#if FEATURE_APPDOMAIN + AppDomainSetup appDomainSetup, +#endif + bool isOutOfProc, + int scheduledNodeId, + Func getProperty, + TaskEnvironment taskEnvironment) { // If the type was loaded via MetadataLoadContext, we MUST use TaskFactory since it didn't load any task assemblies in memory. bool useTaskFactory = _loadedType.LoadedViaMetadataLoadContext; @@ -374,7 +404,8 @@ internal ITask CreateTaskInstance( #if FEATURE_APPDOMAIN appDomainSetup, #endif - scheduledNodeId); + scheduledNodeId, + taskEnvironment: taskEnvironment); return task; } else diff --git a/src/Build/Instance/TaskFactories/TaskHostTask.cs b/src/Build/Instance/TaskFactories/TaskHostTask.cs index 3d76fba1e1e..df6e29847b6 100644 --- a/src/Build/Instance/TaskFactories/TaskHostTask.cs +++ b/src/Build/Instance/TaskFactories/TaskHostTask.cs @@ -149,6 +149,11 @@ internal class TaskHostTask : IGeneratedTask, ICancelableTask, INodePacketFactor /// private bool _useSidecarTaskHost = false; + /// + /// The task environment for virtualized environment operations. + /// + private TaskEnvironment _taskEnvironment; + /// /// Constructor. /// @@ -162,9 +167,11 @@ public TaskHostTask( #if FEATURE_APPDOMAIN AppDomainSetup appDomainSetup, #endif - int scheduledNodeId) + int scheduledNodeId, + TaskEnvironment taskEnvironment) { ErrorUtilities.VerifyThrowInternalNull(taskType); + ErrorUtilities.VerifyThrowInternalNull(taskEnvironment); _scheduledNodeId = scheduledNodeId; @@ -177,6 +184,7 @@ public TaskHostTask( #endif _taskHostParameters = taskHostParameters; _useSidecarTaskHost = useSidecarTaskHost; + _taskEnvironment = taskEnvironment; _packetFactory = new NodePacketFactory(); @@ -525,8 +533,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.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs new file mode 100644 index 00000000000..71f483336b1 --- /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; +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")] + [InlineData("deep/nested/path")] + [InlineData(".")] + [InlineData("")] + [InlineData("..")] + public void AbsolutePath_FromRelativePath_ShouldResolveAgainstBase(string relativePath) + { + // Arrange + string baseDirectory = Path.Combine(Path.GetTempPath(), "testfolder"); + var basePath = new AbsolutePath(baseDirectory); + + // Act + var absolutePath = new AbsolutePath(relativePath, basePath); + + // Assert + 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); + } + } + } +} diff --git a/src/Framework.UnitTests/TaskEnvironment_Tests.cs b/src/Framework.UnitTests/TaskEnvironment_Tests.cs new file mode 100644 index 00000000000..84eaf8c9707 --- /dev/null +++ b/src/Framework.UnitTests/TaskEnvironment_Tests.cs @@ -0,0 +1,333 @@ +// 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 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); + } + } + } +} 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/ITaskEnvironmentDriver.cs b/src/Framework/ITaskEnvironmentDriver.cs new file mode 100644 index 00000000000..3223a47984a --- /dev/null +++ b/src/Framework/ITaskEnvironmentDriver.cs @@ -0,0 +1,62 @@ +// 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; + +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 index 7b13ad2bed1..94b49b5d394 100644 --- a/src/Framework/MSBuildMultiThreadableTaskAttribute.cs +++ b/src/Framework/MSBuildMultiThreadableTaskAttribute.cs @@ -34,4 +34,4 @@ public MSBuildMultiThreadableTaskAttribute() { } } -} \ No newline at end of file +} diff --git a/src/Framework/MultithreadedTaskEnvironmentDriver.cs b/src/Framework/MultithreadedTaskEnvironmentDriver.cs new file mode 100644 index 00000000000..09117004e22 --- /dev/null +++ b/src/Framework/MultithreadedTaskEnvironmentDriver.cs @@ -0,0 +1,99 @@ +// 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; + +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 index 9b8c39143a3..696014930df 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -24,7 +24,8 @@ public readonly struct AbsolutePath /// The absolute path string. public AbsolutePath(string path) { - throw new NotImplementedException(); + ValidatePath(path); + Path = path; } /// @@ -34,7 +35,29 @@ public AbsolutePath(string path) /// If true, skips checking whether the path is rooted. internal AbsolutePath(string path, bool ignoreRootedCheck) { - throw new NotImplementedException(); + 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)); + } } /// @@ -44,19 +67,35 @@ internal AbsolutePath(string path, bool ignoreRootedCheck) /// The base path to combine with. public AbsolutePath(string path, AbsolutePath basePath) { - throw new NotImplementedException(); + 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) => throw new NotImplementedException(); + 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() => throw new NotImplementedException(); + 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/Framework/StubTaskEnvironmentDriver.cs b/src/Framework/StubTaskEnvironmentDriver.cs new file mode 100644 index 00000000000..f4f90d49efd --- /dev/null +++ b/src/Framework/StubTaskEnvironmentDriver.cs @@ -0,0 +1,106 @@ +// 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; + +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 index 3e3417f5172..e3d04ee55ba 100644 --- a/src/Framework/TaskEnvironment.cs +++ b/src/Framework/TaskEnvironment.cs @@ -1,25 +1,34 @@ // 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; namespace Microsoft.Build.Framework { /// - /// Provides an with access to a run-time execution environment including - /// environment variables, file paths, and process management capabilities. + /// 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 => throw new NotImplementedException(); - internal set => throw new NotImplementedException(); + get => _driver.ProjectDirectory; + internal set => _driver.ProjectDirectory = value; } /// @@ -28,32 +37,39 @@ public AbsolutePath ProjectDirectory /// /// The path to convert. /// An absolute path representation. - public AbsolutePath GetAbsolutePath(string path) => throw new NotImplementedException(); + 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) => throw new NotImplementedException(); + 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() => throw new NotImplementedException(); + 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) => throw new NotImplementedException(); + 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() => throw new NotImplementedException(); + public ProcessStartInfo GetProcessStartInfo() => _driver.GetProcessStartInfo(); } } 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(); + } + } +} 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..6dc355d62f5 100644 --- a/src/Tasks/MSBuild.cs +++ b/src/Tasks/MSBuild.cs @@ -19,7 +19,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 +187,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 +281,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 +339,8 @@ public override bool Execute() Log, _targetOutputs, UnloadProjectsOnCompletion, - ToolsVersion)) + ToolsVersion, + TaskEnvironment)) { success = false; } @@ -398,7 +404,8 @@ private bool BuildProjectsInParallel(Dictionary propertiesTable, Log, _targetOutputs, UnloadProjectsOnCompletion, - ToolsVersion)) + ToolsVersion, + TaskEnvironment)) { success = false; } @@ -488,7 +495,8 @@ internal static bool ExecuteTargets( TaskLoggingHelper log, List targetOutputs, bool unloadProjectsOnCompletion, - string toolsVersion) + string toolsVersion, + TaskEnvironment taskEnvironment) { bool success = true; @@ -496,22 +504,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 +616,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; 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;