From c524af12fd539c27730707d45865cab6ec7657f0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:30:49 +0100 Subject: [PATCH 1/3] Migrate Exec task to TaskEnvironment API (#13171) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jan Provaznik --- src/Tasks.UnitTests/Exec_Tests.cs | 163 ++++++++++++++++++ src/Tasks/Exec.cs | 74 +++++++- src/UnitTests.Shared/TaskEnvironmentHelper.cs | 19 ++ 3 files changed, 249 insertions(+), 7 deletions(-) diff --git a/src/Tasks.UnitTests/Exec_Tests.cs b/src/Tasks.UnitTests/Exec_Tests.cs index 87fc5dc829c..0fdea7242f4 100644 --- a/src/Tasks.UnitTests/Exec_Tests.cs +++ b/src/Tasks.UnitTests/Exec_Tests.cs @@ -36,6 +36,7 @@ private Exec PrepareExec(string command) { IBuildEngine2 mockEngine = new MockEngine(_output); Exec exec = new Exec(); + exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); exec.BuildEngine = mockEngine; exec.Command = command; return exec; @@ -45,6 +46,7 @@ private ExecWrapper PrepareExecWrapper(string command) { IBuildEngine2 mockEngine = new MockEngine(_output); ExecWrapper exec = new ExecWrapper(); + exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); exec.BuildEngine = mockEngine; exec.Command = command; return exec; @@ -904,6 +906,7 @@ public void ValidateParametersNoCommand() public void SetEnvironmentVariableParameter() { Exec exec = new Exec(); + exec.TaskEnvironment = TaskEnvironmentHelper.CreateForTest(); exec.BuildEngine = new MockEngine(); exec.Command = NativeMethodsShared.IsWindows ? "echo [%MYENVVAR%]" : "echo [$myenvvar]"; exec.EnvironmentVariables = new[] { "myenvvar=myvalue" }; @@ -1068,6 +1071,166 @@ public void ConsoleOutputDoesNotTrimLeadingWhitespace() exec.ConsoleOutput[0].ItemSpec.ShouldBe(lineWithLeadingWhitespace); } } + + /// + /// Runs an Exec task that lists directory contents and asserts expected/unexpected files in the output. + /// + /// The TaskEnvironment to configure on the Exec task. + /// The WorkingDirectory to set, or null to use the default. + /// A filename that must appear in the output. + /// A filename that must NOT appear in the output, or null to skip. + private void ExecuteListCommandInDirectory( + TaskEnvironment taskEnvironment, + string workingDirectory, + string expectedFile, + string notExpectedFile = null) + { + Exec exec = new Exec(); + exec.TaskEnvironment = taskEnvironment; + exec.BuildEngine = new MockEngine(_output); + exec.Command = NativeMethodsShared.IsWindows ? "dir /b" : "ls"; + exec.ConsoleToMSBuild = true; + + if (workingDirectory != null) + { + exec.WorkingDirectory = workingDirectory; + } + + bool result = exec.Execute(); + + result.ShouldBeTrue(); + ((MockEngine)exec.BuildEngine).AssertLogContains(expectedFile); + if (notExpectedFile != null) + { + ((MockEngine)exec.BuildEngine).AssertLogDoesntContain(notExpectedFile); + } + } + + /// + /// Verify that Exec resolves relative WorkingDirectory via TaskEnvironment.GetAbsolutePath in multiprocess mode. + /// + [Fact] + public void ExecResolvesRelativeWorkingDirectoryWithMultiProcessDriver() + { + using (var testEnv = TestEnvironment.Create(_output)) + { + var projectDir = testEnv.CreateFolder(); + var subDir = Directory.CreateDirectory(Path.Combine(projectDir.Path, "subdir")); + File.WriteAllText(Path.Combine(subDir.FullName, "testfile.txt"), "test content"); + + var differentDir = testEnv.CreateFolder(); + var decoySubDir = Directory.CreateDirectory(Path.Combine(differentDir.Path, "subdir")); + File.WriteAllText(Path.Combine(decoySubDir.FullName, "decoyfile.txt"), "decoy content"); + + string originalDirectory = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(projectDir.Path); + + ExecuteListCommandInDirectory( + TaskEnvironmentHelper.CreateForTest(), + workingDirectory: "subdir", + expectedFile: "testfile.txt", + notExpectedFile: "decoyfile.txt"); + } + finally + { + Directory.SetCurrentDirectory(originalDirectory); + } + } + } + + /// + /// Verify that Exec uses TaskEnvironment.ProjectDirectory when WorkingDirectory is not specified. + /// Uses MultiThreadedTaskEnvironmentDriver so process CWD differs from project directory. + /// + [Fact] + public void ExecUsesProjectDirectoryAsDefaultWorkingDirectory() + { + using (var testEnv = TestEnvironment.Create(_output)) + { + var projectDir = testEnv.CreateFolder(); + File.WriteAllText(Path.Combine(projectDir.Path, "projectfile.txt"), "project content"); + + var differentCwd = testEnv.CreateFolder(); + File.WriteAllText(Path.Combine(differentCwd.Path, "decoyfile.txt"), "decoy content"); + + string originalDirectory = Directory.GetCurrentDirectory(); + TaskEnvironment taskEnvironment = null; + try + { + Directory.SetCurrentDirectory(differentCwd.Path); + + taskEnvironment = TaskEnvironmentHelper.CreateMultithreadedForTest(projectDir.Path); + ExecuteListCommandInDirectory( + taskEnvironment, + workingDirectory: null, + expectedFile: "projectfile.txt", + notExpectedFile: "decoyfile.txt"); + } + finally + { + taskEnvironment?.Dispose(); + Directory.SetCurrentDirectory(originalDirectory); + } + } + } + + /// + /// Verify that Exec correctly handles absolute WorkingDirectory paths. + /// + [Fact] + public void ExecHandlesAbsoluteWorkingDirectory() + { + using (var testEnv = TestEnvironment.Create(_output)) + { + var workDir = testEnv.CreateFolder(); + File.WriteAllText(Path.Combine(workDir.Path, "absolutedir.txt"), "absolute content"); + + ExecuteListCommandInDirectory( + TaskEnvironmentHelper.CreateForTest(), + workingDirectory: workDir.Path, + expectedFile: "absolutedir.txt"); + } + } + + /// + /// Verify that Exec resolves relative WorkingDirectory relative to TaskEnvironment.ProjectDirectory, + /// not the process current directory. Uses MultiThreadedTaskEnvironmentDriver to simulate + /// multithreaded mode where process CWD differs from project directory. + /// + [Fact] + public void ExecResolvesRelativeWorkingDirectoryRelativeToProjectDirectory() + { + using (var testEnv = TestEnvironment.Create(_output)) + { + var projectDir = testEnv.CreateFolder(); + var subDir = Directory.CreateDirectory(Path.Combine(projectDir.Path, "builddir")); + File.WriteAllText(Path.Combine(subDir.FullName, "multithreaded.txt"), "multithreaded content"); + + var differentCwd = testEnv.CreateFolder(); + File.WriteAllText(Path.Combine(differentCwd.Path, "decoyfile.txt"), "decoy content"); + + string originalDirectory = Directory.GetCurrentDirectory(); + TaskEnvironment taskEnvironment = null; + try + { + Directory.SetCurrentDirectory(differentCwd.Path); + + taskEnvironment = TaskEnvironmentHelper.CreateMultithreadedForTest(projectDir.Path); + ExecuteListCommandInDirectory( + taskEnvironment, + workingDirectory: "builddir", + expectedFile: "multithreaded.txt", + notExpectedFile: "decoyfile.txt"); + } + finally + { + taskEnvironment?.Dispose(); + Directory.SetCurrentDirectory(originalDirectory); + } + } + } } internal sealed class ExecWrapper : Exec diff --git a/src/Tasks/Exec.cs b/src/Tasks/Exec.cs index d59c05f4d04..3c557005442 100644 --- a/src/Tasks/Exec.cs +++ b/src/Tasks/Exec.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; @@ -21,7 +22,8 @@ namespace Microsoft.Build.Tasks /// for it to complete, and then returns True if the process completed successfully, and False if an error occurred. /// // UNDONE: ToolTask has a "UseCommandProcessor" flag that duplicates much of the code in this class. Remove the duplication. - public class Exec : ToolTaskExtension + [MSBuildMultiThreadableTask] + public class Exec : ToolTaskExtension, IMultiThreadableTask { #region Constructors @@ -46,7 +48,7 @@ public Exec() // Are the encodings for StdErr and StdOut streams valid private bool _encodingParametersValid = true; - private string _workingDirectory; + private AbsolutePath _workingDirectory; private ITaskItem[] _outputs; internal bool workingDirectoryIsUNC; // internal for unit testing private string _batchFile; @@ -82,6 +84,9 @@ public string Command public bool IgnoreExitCode { get; set; } + /// + public TaskEnvironment TaskEnvironment { get; set; } + /// /// Enable the pipe of the standard out to an item (StandardOutput). /// @@ -458,10 +463,12 @@ protected override bool ValidateParameters() } // determine what the working directory for the exec command is going to be -- if the user specified a working - // directory use that, otherwise it's the current directory + // directory use that, otherwise default to the project directory (TaskEnvironment.ProjectDirectory). Using the + // project directory instead of the process current directory is important for correctness in multithreaded (/mt) + // builds, where the process working directory may not match the project being built. _workingDirectory = !string.IsNullOrEmpty(WorkingDirectory) - ? WorkingDirectory - : Directory.GetCurrentDirectory(); + ? TaskEnvironment.GetAbsolutePath(WorkingDirectory) + : TaskEnvironment.ProjectDirectory; // check if the working directory we're going to use for the exec command is a UNC path workingDirectoryIsUNC = FileUtilitiesRegex.StartsWithUncPattern(_workingDirectory); @@ -470,7 +477,7 @@ protected override bool ValidateParameters() // will not be able to auto-map to the UNC path if (workingDirectoryIsUNC && NativeMethods.AllDrivesMapped()) { - Log.LogErrorWithCodeFromResources("Exec.AllDriveLettersMappedError", _workingDirectory); + Log.LogErrorWithCodeFromResources("Exec.AllDriveLettersMappedError", _workingDirectory.OriginalValue); return false; } @@ -533,7 +540,7 @@ protected override string GetWorkingDirectory() // So verify it's valid here. if (!FileSystems.Default.DirectoryExists(_workingDirectory)) { - throw new DirectoryNotFoundException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("Exec.InvalidWorkingDirectory", _workingDirectory)); + throw new DirectoryNotFoundException(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("Exec.InvalidWorkingDirectory", _workingDirectory.OriginalValue)); } if (workingDirectoryIsUNC) @@ -560,6 +567,59 @@ internal string GetWorkingDirectoryAccessor() return GetWorkingDirectory(); } + /// + /// Gets the ProcessStartInfo for the spawned process, with environment variables from TaskEnvironment. + /// In multithreaded mode, TaskEnvironment contains the virtualized environment for this project, + /// which must be passed to the spawned process since it won't inherit it from the (shared) process environment. + /// + protected override ProcessStartInfo GetProcessStartInfo( + string pathToTool, + string commandLineCommands, + string responseFileSwitch) + { + // Get the base ProcessStartInfo with all ToolTask settings (command line, redirections, encodings, etc.) + // This also applies EnvironmentVariables overrides from the task property. + ProcessStartInfo startInfo = base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + + // Replace the inherited process environment with the virtualized one from TaskEnvironment. + // TaskEnvironment.GetProcessStartInfo() already configures env vars and working directory correctly + // for both multithreaded (virtualized) and multi-process (inherited) modes. + ProcessStartInfo taskEnvStartInfo = TaskEnvironment.GetProcessStartInfo(); + startInfo.Environment.Clear(); + foreach (var kvp in taskEnvStartInfo.Environment) + { + startInfo.Environment[kvp.Key] = kvp.Value; + } + + // Re-apply obsolete EnvironmentOverride and EnvironmentVariables property overrides — + // they should take precedence over TaskEnvironment. The base class already applied these, + // but we cleared the environment above, so we need to re-apply them. +#pragma warning disable 0618 // obsolete + Dictionary envOverrides = EnvironmentOverride; + if (envOverrides != null) + { + foreach (KeyValuePair entry in envOverrides) + { + startInfo.Environment[entry.Key] = entry.Value; + } + } +#pragma warning restore 0618 + + if (EnvironmentVariables != null) + { + foreach (string entry in EnvironmentVariables) + { + string[] nameValuePair = entry.Split(['='], 2); + if (nameValuePair.Length == 2 && nameValuePair[0].Length > 0) + { + startInfo.Environment[nameValuePair[0]] = nameValuePair[1]; + } + } + } + + return startInfo; + } + /// /// Adds the arguments for cmd.exe /// diff --git a/src/UnitTests.Shared/TaskEnvironmentHelper.cs b/src/UnitTests.Shared/TaskEnvironmentHelper.cs index 172c48ceb5b..0194ed86669 100644 --- a/src/UnitTests.Shared/TaskEnvironmentHelper.cs +++ b/src/UnitTests.Shared/TaskEnvironmentHelper.cs @@ -20,5 +20,24 @@ public static TaskEnvironment CreateForTest() { return TaskEnvironment.Fallback; } + + /// + /// Creates a TaskEnvironment backed by the multi-threaded driver which virtualizes + /// environment variables and current directory. This allows testing of multithreaded mode + /// behavior where each project has its own isolated environment. + /// + /// The project directory to use for the task environment. + /// A TaskEnvironment suitable for testing multithreaded mode scenarios. + /// + /// The caller is responsible for disposing the TaskEnvironment via TaskEnvironment.Dispose(), + /// which will clean up the underlying driver's thread-local state. + /// + // CA2000 is suppressed because the driver is owned by the TaskEnvironment and disposed via TaskEnvironment.Dispose() +#pragma warning disable CA2000 + public static TaskEnvironment CreateMultithreadedForTest(string projectDirectory) + { + return new TaskEnvironment(new MultiThreadedTaskEnvironmentDriver(projectDirectory)); + } +#pragma warning restore CA2000 } } From 37642075aaff36cfda8fe2f6bce6a68934a1ac82 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 29 Apr 2026 15:11:48 +0200 Subject: [PATCH 2/3] Remove GetProcessStartInfo override --- src/Tasks/Exec.cs | 56 ----------------------------------------------- 1 file changed, 56 deletions(-) diff --git a/src/Tasks/Exec.cs b/src/Tasks/Exec.cs index 3c557005442..490a6deb378 100644 --- a/src/Tasks/Exec.cs +++ b/src/Tasks/Exec.cs @@ -84,9 +84,6 @@ public string Command public bool IgnoreExitCode { get; set; } - /// - public TaskEnvironment TaskEnvironment { get; set; } - /// /// Enable the pipe of the standard out to an item (StandardOutput). /// @@ -567,59 +564,6 @@ internal string GetWorkingDirectoryAccessor() return GetWorkingDirectory(); } - /// - /// Gets the ProcessStartInfo for the spawned process, with environment variables from TaskEnvironment. - /// In multithreaded mode, TaskEnvironment contains the virtualized environment for this project, - /// which must be passed to the spawned process since it won't inherit it from the (shared) process environment. - /// - protected override ProcessStartInfo GetProcessStartInfo( - string pathToTool, - string commandLineCommands, - string responseFileSwitch) - { - // Get the base ProcessStartInfo with all ToolTask settings (command line, redirections, encodings, etc.) - // This also applies EnvironmentVariables overrides from the task property. - ProcessStartInfo startInfo = base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); - - // Replace the inherited process environment with the virtualized one from TaskEnvironment. - // TaskEnvironment.GetProcessStartInfo() already configures env vars and working directory correctly - // for both multithreaded (virtualized) and multi-process (inherited) modes. - ProcessStartInfo taskEnvStartInfo = TaskEnvironment.GetProcessStartInfo(); - startInfo.Environment.Clear(); - foreach (var kvp in taskEnvStartInfo.Environment) - { - startInfo.Environment[kvp.Key] = kvp.Value; - } - - // Re-apply obsolete EnvironmentOverride and EnvironmentVariables property overrides — - // they should take precedence over TaskEnvironment. The base class already applied these, - // but we cleared the environment above, so we need to re-apply them. -#pragma warning disable 0618 // obsolete - Dictionary envOverrides = EnvironmentOverride; - if (envOverrides != null) - { - foreach (KeyValuePair entry in envOverrides) - { - startInfo.Environment[entry.Key] = entry.Value; - } - } -#pragma warning restore 0618 - - if (EnvironmentVariables != null) - { - foreach (string entry in EnvironmentVariables) - { - string[] nameValuePair = entry.Split(['='], 2); - if (nameValuePair.Length == 2 && nameValuePair[0].Length > 0) - { - startInfo.Environment[nameValuePair[0]] = nameValuePair[1]; - } - } - } - - return startInfo; - } - /// /// Adds the arguments for cmd.exe /// From 3a8ad2a4514b95df308a2d861e25e56e3ea0767b Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 30 Apr 2026 16:48:39 +0200 Subject: [PATCH 3/3] Remove interface --- src/Tasks/Exec.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Tasks/Exec.cs b/src/Tasks/Exec.cs index 490a6deb378..7017fdb3202 100644 --- a/src/Tasks/Exec.cs +++ b/src/Tasks/Exec.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; @@ -23,7 +22,7 @@ namespace Microsoft.Build.Tasks /// // UNDONE: ToolTask has a "UseCommandProcessor" flag that duplicates much of the code in this class. Remove the duplication. [MSBuildMultiThreadableTask] - public class Exec : ToolTaskExtension, IMultiThreadableTask + public class Exec : ToolTaskExtension { #region Constructors