diff --git a/src/Tasks.UnitTests/Al_Tests.cs b/src/Tasks.UnitTests/Al_Tests.cs
index b1a777002cc..72960a64e59 100644
--- a/src/Tasks.UnitTests/Al_Tests.cs
+++ b/src/Tasks.UnitTests/Al_Tests.cs
@@ -1,10 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics;
+using System.IO;
using Microsoft.Build.Framework;
using Microsoft.Build.Tasks;
using Microsoft.Build.Utilities;
+using Shouldly;
using Xunit;
+using Xunit.Abstractions;
#nullable disable
@@ -18,13 +22,19 @@ namespace Microsoft.Build.UnitTests
*/
public sealed class AlTests
{
+ private readonly ITestOutputHelper _output;
+
+ public AlTests(ITestOutputHelper output)
+ {
+ _output = output;
+ }
///
/// Tests the AlgorithmId parameter
///
[Fact]
public void AlgorithmId()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.AlgorithmId); // "Default value"
t.AlgorithmId = "whatisthis";
@@ -40,7 +50,7 @@ public void AlgorithmId()
[Fact]
public void BaseAddress()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.BaseAddress); // "Default value"
t.BaseAddress = "12345678";
@@ -56,7 +66,7 @@ public void BaseAddress()
[Fact]
public void CompanyName()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.CompanyName); // "Default value"
t.CompanyName = "Google";
@@ -72,7 +82,7 @@ public void CompanyName()
[Fact]
public void Configuration()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Configuration); // "Default value"
t.Configuration = "debug";
@@ -88,7 +98,7 @@ public void Configuration()
[Fact]
public void Copyright()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Copyright); // "Default value"
t.Copyright = "(C) 2005";
@@ -104,7 +114,7 @@ public void Copyright()
[Fact]
public void Culture()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Culture); // "Default value"
t.Culture = "aussie";
@@ -120,7 +130,7 @@ public void Culture()
[Fact]
public void DelaySign()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.False(t.DelaySign); // "Default value"
t.DelaySign = true;
@@ -136,7 +146,7 @@ public void DelaySign()
[Fact]
public void Description()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Description); // "Default value"
t.Description = "whatever";
@@ -152,7 +162,7 @@ public void Description()
[Fact]
public void EmbedResourcesWithPrivateAccess()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.EmbedResources); // "Default value"
@@ -177,7 +187,7 @@ public void EmbedResourcesWithPrivateAccess()
[Fact]
public void EvidenceFile()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.EvidenceFile); // "Default value"
t.EvidenceFile = "MyEvidenceFile";
@@ -193,7 +203,7 @@ public void EvidenceFile()
[Fact]
public void FileVersion()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.FileVersion); // "Default value"
t.FileVersion = "1.2.3.4";
@@ -209,7 +219,7 @@ public void FileVersion()
[Fact]
public void Flags()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Flags); // "Default value"
t.Flags = "0x8421";
@@ -225,7 +235,7 @@ public void Flags()
[Fact]
public void GenerateFullPaths()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.False(t.GenerateFullPaths); // "Default value"
t.GenerateFullPaths = true;
@@ -241,7 +251,7 @@ public void GenerateFullPaths()
[Fact]
public void KeyFile()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.KeyFile); // "Default value"
t.KeyFile = "mykey.snk";
@@ -257,7 +267,7 @@ public void KeyFile()
[Fact]
public void KeyContainer()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.KeyContainer); // "Default value"
t.KeyContainer = "MyKeyContainer";
@@ -273,7 +283,7 @@ public void KeyContainer()
[Fact]
public void LinkResourcesWithPrivateAccessAndTargetFile()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.LinkResources); // "Default value"
@@ -299,7 +309,7 @@ public void LinkResourcesWithPrivateAccessAndTargetFile()
[Fact]
public void LinkResourcesWithTwoItems()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.LinkResources); // "Default value"
@@ -332,7 +342,7 @@ public void LinkResourcesWithTwoItems()
[Fact]
public void MainEntryPoint()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.MainEntryPoint); // "Default value"
t.MainEntryPoint = "Class1.Main";
@@ -348,7 +358,7 @@ public void MainEntryPoint()
[Fact]
public void OutputAssembly()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.OutputAssembly); // "Default value"
t.OutputAssembly = new TaskItem("foo.dll");
@@ -364,7 +374,7 @@ public void OutputAssembly()
[Fact]
public void Platform()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Platform); // "Default value"
t.Platform = "x86";
@@ -380,26 +390,26 @@ public void Platform()
public void PlatformAndPrefer32Bit()
{
// Implicit "anycpu"
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
CommandLine.ValidateNoParameterStartsWith(t, @"/platform:");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Prefer32Bit = false;
CommandLine.ValidateNoParameterStartsWith(t, @"/platform:");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Prefer32Bit = true;
CommandLine.ValidateHasParameter(
t,
@"/platform:anycpu32bitpreferred");
// Explicit "anycpu"
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "anycpu";
CommandLine.ValidateHasParameter(t, @"/platform:anycpu");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "anycpu";
t.Prefer32Bit = false;
CommandLine.ValidateHasParameter(t, @"/platform:anycpu");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "anycpu";
t.Prefer32Bit = true;
CommandLine.ValidateHasParameter(
@@ -407,14 +417,14 @@ public void PlatformAndPrefer32Bit()
@"/platform:anycpu32bitpreferred");
// Explicit "x86"
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "x86";
CommandLine.ValidateHasParameter(t, @"/platform:x86");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "x86";
t.Prefer32Bit = false;
CommandLine.ValidateHasParameter(t, @"/platform:x86");
- t = new AL();
+ t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
t.Platform = "x86";
t.Prefer32Bit = true;
CommandLine.ValidateHasParameter(t, @"/platform:x86");
@@ -426,7 +436,7 @@ public void PlatformAndPrefer32Bit()
[Fact]
public void ProductName()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.ProductName); // "Default value"
t.ProductName = "VisualStudio";
@@ -442,7 +452,7 @@ public void ProductName()
[Fact]
public void ProductVersion()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.ProductVersion); // "Default value"
t.ProductVersion = "8.0";
@@ -458,7 +468,7 @@ public void ProductVersion()
[Fact]
public void ResponseFiles()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.ResponseFiles); // "Default value"
t.ResponseFiles = new string[2] { "one.rsp", "two.rsp" };
@@ -475,7 +485,7 @@ public void ResponseFiles()
[Fact]
public void SourceModules()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.SourceModules); // "Default value"
@@ -500,7 +510,7 @@ public void SourceModules()
[Fact]
public void TargetType()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.TargetType); // "Default value"
t.TargetType = "winexe";
@@ -516,7 +526,7 @@ public void TargetType()
[Fact]
public void TemplateFile()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.TemplateFile); // "Default value"
t.TemplateFile = "mymainassembly.dll";
@@ -534,7 +544,7 @@ public void TemplateFile()
[Fact]
public void Title()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Title); // "Default value"
t.Title = "WarAndPeace";
@@ -550,7 +560,7 @@ public void Title()
[Fact]
public void Trademark()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Trademark); // "Default value"
t.Trademark = "MyTrademark";
@@ -566,7 +576,7 @@ public void Trademark()
[Fact]
public void Version()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Version); // "Default value"
t.Version = "WowHowManyKindsOfVersionsAreThere";
@@ -584,7 +594,7 @@ public void Version()
[Fact]
public void Win32Icon()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Win32Icon); // "Default value"
t.Win32Icon = "foo.ico";
@@ -600,7 +610,7 @@ public void Win32Icon()
[Fact]
public void Win32Resource()
{
- AL t = new AL();
+ AL t = new AL() { TaskEnvironment = TaskEnvironmentHelper.CreateForTest() };
Assert.Null(t.Win32Resource); // "Default value"
t.Win32Resource = "foo.res";
@@ -609,5 +619,62 @@ public void Win32Resource()
// Check the parameters.
CommandLine.ValidateHasParameter(t, @"/win32res:foo.res");
}
+
+ ///
+ /// Verifies that GenerateFullPathToTool returns an absolute path (or null)
+ /// when called with a multithreaded TaskEnvironment, validating the
+ /// TaskEnvironment.GetAbsolutePath() integration.
+ ///
+ [WindowsFullFrameworkOnlyFact]
+ public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull()
+ {
+ string projectDir = Path.GetTempPath();
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ TestableAL t = new TestableAL();
+ t.TaskEnvironment = taskEnv;
+ t.BuildEngine = new MockEngine(_output);
+
+ string result = t.CallGenerateFullPathToTool();
+
+ if (result is not null)
+ {
+ Path.IsPathRooted(result).ShouldBeTrue(
+ $"GenerateFullPathToTool should return an absolute path, got: {result}");
+ }
+ }
+
+ ///
+ /// Verifies that the GetProcessStartInfo override routes through
+ /// GetProcessStartInfoMultiThreaded when TaskEnvironment is set,
+ /// and that the working directory comes from the TaskEnvironment.
+ ///
+ [WindowsFullFrameworkOnlyFact]
+ public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory()
+ {
+ string expectedWorkingDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
+ using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ TestableAL t = new TestableAL();
+ t.TaskEnvironment = taskEnv;
+ t.BuildEngine = new MockEngine(_output);
+
+ ProcessStartInfo startInfo = t.CallGetProcessStartInfo(@"C:\test\al.exe", "/nologo", null);
+
+ startInfo.WorkingDirectory.ShouldBe(expectedWorkingDir);
+ }
+
+ ///
+ /// Subclass that exposes protected methods for testing without reflection.
+ ///
+ private sealed class TestableAL : AL
+ {
+ public string CallGenerateFullPathToTool() => GenerateFullPathToTool();
+
+ public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch)
+ => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch);
+ }
}
}
diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs
index d1872e4fb39..4ff00e13399 100644
--- a/src/Tasks/Al.cs
+++ b/src/Tasks/Al.cs
@@ -3,7 +3,6 @@
#if NETFRAMEWORK
using System;
-
using Microsoft.Build.Shared.FileSystem;
using Microsoft.Build.Utilities;
#endif
@@ -20,6 +19,7 @@ namespace Microsoft.Build.Tasks
/// This class defines the "AL" XMake task, which enables using al.exe to link
/// modules and resource files into assemblies.
///
+ [MSBuildMultiThreadableTask]
public class AL : ToolTaskExtension, IALTaskContract
{
#region Properties
@@ -305,12 +305,12 @@ protected override string GenerateFullPathToTool()
// If COMPLUS_InstallRoot\COMPLUS_Version are set (the dogfood world), we want to find it there, instead of
// the SDK, which may or may not be installed. The following will look there.
- if (!String.IsNullOrEmpty(Environment.GetEnvironmentVariable("COMPLUS_InstallRoot")) || !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("COMPLUS_Version")))
+ if (!String.IsNullOrEmpty(TaskEnvironment.GetEnvironmentVariable("COMPLUS_InstallRoot")) || !String.IsNullOrEmpty(TaskEnvironment.GetEnvironmentVariable("COMPLUS_Version")))
{
pathToTool = ToolLocationHelper.GetPathToDotNetFrameworkFile(ToolExe, TargetDotNetFrameworkVersion.Latest);
}
- if (String.IsNullOrEmpty(pathToTool) || !FileSystems.Default.FileExists(pathToTool))
+ if (String.IsNullOrEmpty(pathToTool) || !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool)))
{
// The bitness of al.exe should match the platform being built
// Yoda condition prevents null reference exception if Platform is null.
@@ -318,10 +318,18 @@ protected override string GenerateFullPathToTool()
"x64".Equals(Platform, StringComparison.OrdinalIgnoreCase) ? ProcessorArchitecture.AMD64 : // x64 maps to AMD64 in GeneratePathToTool
ProcessorArchitecture.CurrentProcessArchitecture;
- pathToTool = SdkToolsPathUtility.GeneratePathToTool(f => SdkToolsPathUtility.FileInfoExists(f), archToLookFor, SdkToolsPath, ToolExe, Log, true);
+ pathToTool = SdkToolsPathUtility.GeneratePathToTool(
+ f => !string.IsNullOrEmpty(f)
+ ? SdkToolsPathUtility.FileInfoExists(TaskEnvironment.GetAbsolutePath(f))
+ : SdkToolsPathUtility.FileInfoExists(f),
+ archToLookFor,
+ SdkToolsPath,
+ ToolExe,
+ Log,
+ true);
}
- return pathToTool;
+ return string.IsNullOrEmpty(pathToTool) ? pathToTool : TaskEnvironment.GetAbsolutePath(pathToTool).Value;
}
///
@@ -400,6 +408,7 @@ public override bool Execute()
///
/// Stub AL task for .NET Core.
///
+ [MSBuildMultiThreadableTask]
public sealed class AL : TaskRequiresFramework, IALTaskContract
{
public AL()
diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs
index 463bacfc033..77af6815973 100644
--- a/src/Utilities.UnitTests/ToolTask_Tests.cs
+++ b/src/Utilities.UnitTests/ToolTask_Tests.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Resources;
@@ -754,6 +755,7 @@ public void ToolPathIsFoundWhenDirectoryExistsWithNameOfTool()
[Fact]
public void FindOnPathSucceeds()
{
+ using MyTool tool = new MyTool();
string[] expectedCmdPath;
string shellName;
string cmdPath;
@@ -761,13 +763,13 @@ public void FindOnPathSucceeds()
{
expectedCmdPath = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe").ToUpperInvariant() };
shellName = "cmd.exe";
- cmdPath = ToolTask.FindOnPath(shellName).ToUpperInvariant();
+ cmdPath = tool.FindOnPath(shellName).ToUpperInvariant();
}
else
{
expectedCmdPath = new[] { "/bin/sh", "/usr/bin/sh" };
shellName = "sh";
- cmdPath = ToolTask.FindOnPath(shellName);
+ cmdPath = tool.FindOnPath(shellName);
}
cmdPath.ShouldBeOneOf(expectedCmdPath);
@@ -1230,5 +1232,341 @@ public int TerminationTimeout
///
public override bool Execute() => true;
}
+
+ ///
+ /// A ToolTask subclass for testing GetProcessStartInfo with TaskEnvironment.
+ ///
+ private sealed class MultiThreadedToolTask : ToolTask, IDisposable
+ {
+ private readonly string _fullToolName;
+ private readonly string _workingDirectory;
+
+ public MultiThreadedToolTask(string fullToolName, string workingDirectory)
+ {
+ _fullToolName = fullToolName;
+ _workingDirectory = workingDirectory;
+ }
+
+ public void Dispose() { }
+
+ protected override string ToolName => Path.GetFileName(_fullToolName);
+
+ protected override string GenerateFullPathToTool() => _fullToolName;
+
+ protected override string GetWorkingDirectory() => _workingDirectory;
+
+ ///
+ /// Exposes the protected GetProcessStartInfo for test verification.
+ ///
+ public ProcessStartInfo CallGetProcessStart(TaskEnvironment taskEnvironment)
+ {
+ TaskEnvironment = taskEnvironment;
+ return GetProcessStartInfo(
+ _fullToolName,
+ commandLineCommands: "/nologo",
+ responseFileSwitch: null);
+ }
+
+ ///
+ /// Exposes the protected DeleteTempFile for test verification.
+ ///
+ public void CallDeleteTempFile(string fileName) => DeleteTempFile(fileName);
+
+ ///
+ /// Exposes the protected GetProcessStartInfo for test verification.
+ ///
+ public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch)
+ => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch);
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_NoWorkingDirectoryOverride_UsesProjectDirectory()
+ {
+ // Arrange: no GetWorkingDirectory() override — WorkingDirectory should come from TaskEnvironment.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert
+ result.WorkingDirectory.ShouldBe(projectDir,
+ "Without a GetWorkingDirectory() override, WorkingDirectory should fall back to taskEnvironment.ProjectDirectory");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_PropagatesSpecificEnvironmentVariable()
+ {
+ // Arrange: create a driver with a known env var and verify it appears in ProcessStartInfo.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
+ var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["MY_CUSTOM_VAR"] = "custom_value"
+ };
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert
+ result.Environment["MY_CUSTOM_VAR"].ShouldBe("custom_value",
+ "Environment variables from TaskEnvironment should be propagated to ProcessStartInfo");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_RelativeWorkingDirectory_AbsolutizedAgainstProjectDir()
+ {
+ // Arrange: GetWorkingDirectory() returns a relative path — should be absolutized against project dir.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp";
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, "subdir");
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert: relative path should be combined with the project directory.
+ string expected = Path.Combine(projectDir, "subdir");
+ result.WorkingDirectory.ShouldBe(expected,
+ "A relative GetWorkingDirectory() result should be absolutized against taskEnvironment.ProjectDirectory");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_AbsoluteWorkingDirectory_UsesOverridePath()
+ {
+ // Arrange: GetWorkingDirectory() returns an absolute path — should be used directly.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/projects/myapp" : @"C:\Projects\MyApp";
+ string overrideDir = NativeMethodsShared.IsUnixLike ? "/custom/workdir" : @"D:\Custom\WorkDir";
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, overrideDir);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert: absolute path should be used as-is (Path.Combine with absolute second arg returns it).
+ result.WorkingDirectory.ShouldBe(overrideDir,
+ "An absolute GetWorkingDirectory() result should be used directly, not combined with project directory");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_TaskEnvironmentVariablesOverride()
+ {
+ // Arrange: create a driver with a custom env var.
+ string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
+ var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["MY_VAR"] = "from_driver",
+ ["PATH"] = "driver_path"
+ };
+ using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir, envVars);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Set EnvironmentVariables on the task (should override the driver's value).
+ tool.EnvironmentVariables = ["MY_VAR=from_task_override"];
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert: task-level override should win.
+ result.Environment["MY_VAR"].ShouldBe("from_task_override",
+ "EnvironmentVariables property on the task should override TaskEnvironment values");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_MultiProcessDriver_BackwardCompat()
+ {
+ // Arrange: use the default MultiProcessTaskEnvironmentDriver (non-multithreaded mode).
+ // With the default driver, no working directory is set
+ // (the process inherits the parent's CWD), and process environment is inherited.
+ var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert: with MultiProcessTaskEnvironmentDriver, WorkingDirectory should be empty
+ // (process inherits parent CWD) — matching pre-migration behavior.
+ result.WorkingDirectory.ShouldBeEmpty(
+ "MultiProcessTaskEnvironmentDriver should not set WorkingDirectory, preserving old inherit-from-parent behavior");
+ result.FileName.ShouldBe(toolPath);
+ result.Arguments.ShouldContain("/nologo");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_EmptyWorkingDirectory_KeepsProjectDirectory()
+ {
+ // Arrange: GetWorkingDirectory() returns empty string — should NOT override project dir.
+ // GetProcessStartInfo checks !string.IsNullOrEmpty, so empty string should leave
+ // the project directory from TaskEnvironment intact.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, string.Empty);
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStart(taskEnv);
+
+ // Assert: empty-string GetWorkingDirectory() must not overwrite the project directory.
+ result.WorkingDirectory.ShouldBe(projectDir,
+ "Empty-string from GetWorkingDirectory() should not override the project directory from TaskEnvironment");
+ }
+
+ [Fact]
+ public void FindOnPath_UsesTaskEnvironmentPath()
+ {
+ // Arrange: create a temp dir with a dummy file, set TaskEnvironment PATH to that dir.
+ using var env = TestEnvironment.Create(_output);
+ string tempDir = env.CreateFolder().Path;
+ string toolName = NativeMethodsShared.IsWindows ? "mytesttool.exe" : "mytesttool";
+ File.WriteAllText(Path.Combine(tempDir, toolName), "dummy");
+
+ string projectDir = tempDir;
+ var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["PATH"] = tempDir
+ };
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir, envVars);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string fullToolName = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(fullToolName, null);
+ tool.TaskEnvironment = taskEnv;
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ string result = tool.FindOnPath(toolName);
+
+ // Assert: should find the tool via TaskEnvironment's PATH.
+ result.ShouldNotBeNull("FindOnPath should find the tool via TaskEnvironment's PATH");
+ result.ShouldBe(Path.Combine(tempDir, toolName));
+ }
+
+ [Fact]
+ public void DeleteTempFile_UsesTaskEnvironmentForAbsolutePath()
+ {
+ // Arrange: create a temp file in the project directory, use relative path for deletion.
+ using var env = TestEnvironment.Create(_output);
+ string projectDir = env.CreateFolder().Path;
+ string fileName = "tempfile.rsp";
+ string fullPath = Path.Combine(projectDir, fileName);
+ File.WriteAllText(fullPath, "test content");
+
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.TaskEnvironment = taskEnv;
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act: delete using a relative path — TaskEnvironment should absolutize it.
+ tool.CallDeleteTempFile(fileName);
+
+ // Assert
+ File.Exists(fullPath).ShouldBeFalse(
+ "DeleteTempFile should have deleted the file using TaskEnvironment-absolutized path");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_MultiThreadedDriver_SetsWorkingDirectoryAndEnvironment()
+ {
+ // Arrange: when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver,
+ // GetProcessStartInfo should set WorkingDirectory from the driver's ProjectDirectory
+ // and propagate environment variables.
+ string projectDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir";
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.TaskEnvironment = taskEnv;
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act: call through the virtual GetProcessStartInfo (the normal entry point).
+ ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null);
+
+ // Assert: WorkingDirectory should be set to project directory
+ // and environment variables should be propagated from the driver.
+ result.WorkingDirectory.ShouldBe(projectDir,
+ "MultiThreadedDriver should set WorkingDirectory to ProjectDirectory");
+ result.Environment.Count.ShouldBeGreaterThan(0,
+ "MultiThreadedDriver should propagate environment variables");
+ }
+
+ [Fact]
+ public void GetProcessStartInfo_MultiProcessDriver_DoesNotSetWorkingDirectory()
+ {
+ // Arrange: when TaskEnvironment uses the default MultiProcessTaskEnvironmentDriver,
+ // WorkingDirectory should not be set (the process inherits the parent's CWD).
+ var taskEnv = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance);
+
+ string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe";
+ using var tool = new MultiThreadedToolTask(toolPath, null);
+ tool.TaskEnvironment = taskEnv;
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act
+ ProcessStartInfo result = tool.CallGetProcessStartInfo(toolPath, "/nologo", null);
+
+ // Assert: WorkingDirectory should be empty (inherits from parent process).
+ result.WorkingDirectory.ShouldBeNullOrEmpty(
+ "MultiProcessDriver should not set WorkingDirectory, preserving pre-migration behavior");
+ }
+
+ [Fact]
+ public void ComputePathToTool_UsesTaskEnvironmentForFileExistence()
+ {
+ // Arrange: create a temp dir with a dummy tool, set up TaskEnvironment pointing there.
+ using var env = TestEnvironment.Create(_output);
+ string projectDir = env.CreateFolder().Path;
+ string toolDir = env.CreateFolder().Path;
+ string toolName = NativeMethodsShared.IsWindows ? "mytool.exe" : "mytool";
+ string toolFullPath = Path.Combine(toolDir, toolName);
+ File.WriteAllText(toolFullPath, "dummy");
+
+ using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir);
+ var taskEnv = new TaskEnvironment(driver);
+
+ // Use MyTool pointing to the actual tool location.
+ using var tool = new MyTool();
+ tool.FullToolName = toolFullPath;
+ tool.TaskEnvironment = taskEnv;
+ tool.BuildEngine = new MockEngine(_output);
+
+ // Act: Execute triggers ComputePathToTool which uses TaskEnvironment.GetAbsolutePath
+ // for file existence checks. The tool exists at an absolute path, so this should succeed.
+ bool result = tool.Execute();
+
+ // Assert: the tool should have been found and executed.
+ tool.ExecuteCalled.ShouldBeTrue(
+ "ComputePathToTool should find the tool using TaskEnvironment-absolutized path for existence check");
+ }
}
}
diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs
index 3d4d773bf07..05ed6aca646 100644
--- a/src/Utilities/ToolTask.cs
+++ b/src/Utilities/ToolTask.cs
@@ -58,7 +58,7 @@ public enum HostObjectInitializationStatus
///
// INTERNAL WARNING: DO NOT USE the Log property in this class! Log points to resources in the task assembly itself, and
// we want to use resources from Utilities. Use LogPrivate (for private Utilities resources) and LogShared (for shared MSBuild resources)
- public abstract class ToolTask : Task, IIncrementalTask, ICancelableTask
+ public abstract class ToolTask : Task, IIncrementalTask, ICancelableTask, IMultiThreadableTask
{
private static readonly bool s_preserveTempFiles = string.Equals(Environment.GetEnvironmentVariable("MSBUILDPRESERVETOOLTEMPFILES"), "1", StringComparison.Ordinal);
@@ -214,6 +214,8 @@ public virtual string ToolExe
///
public string[] EnvironmentVariables { get; set; }
+ public virtual TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback;
+
///
/// Project visible property that allows the user to specify an amount of time after which the task executable
/// is terminated.
@@ -529,7 +531,7 @@ private string ComputePathToTool()
pathToTool = Path.Combine(ToolPath, ToolExe);
}
- if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(pathToTool)))
+ if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool))))
{
// Otherwise, try to find the tool ourselves.
pathToTool = GenerateFullPathToTool();
@@ -550,7 +552,7 @@ private string ComputePathToTool()
bool isOnlyFileName = Path.GetFileName(pathToTool).Length == pathToTool.Length;
if (!isOnlyFileName)
{
- bool isExistingFile = FileSystems.Default.FileExists(pathToTool);
+ bool isExistingFile = FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool));
if (!isExistingFile)
{
LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolExecutableNotFound", pathToTool);
@@ -657,7 +659,9 @@ protected virtual ProcessStartInfo GetProcessStartInfo(
LogPrivate.LogWarningWithCodeFromResources("ToolTask.CommandTooLong", GetType().Name);
}
- ProcessStartInfo startInfo = new ProcessStartInfo(pathToTool, commandLine);
+ ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo();
+ startInfo.FileName = pathToTool;
+ startInfo.Arguments = commandLine;
startInfo.CreateNoWindow = true;
startInfo.UseShellExecute = false;
startInfo.RedirectStandardError = true;
@@ -676,11 +680,15 @@ protected virtual ProcessStartInfo GetProcessStartInfo(
// Generally we won't set a working directory, and it will use the current directory
string workingDirectory = GetWorkingDirectory();
- if (workingDirectory != null)
+ if (!string.IsNullOrEmpty(workingDirectory))
{
- startInfo.WorkingDirectory = workingDirectory;
+ startInfo.WorkingDirectory = TaskEnvironment.GetAbsolutePath(workingDirectory);
}
+ // Apply task-level environment variable overrides (both the obsolete EnvironmentOverride
+ // and the current EnvironmentVariables). Prefers the pre-parsed _environmentVariablePairs
+ // populated by Execute(), falling back to parsing EnvironmentVariables directly for
+ // callers outside the normal Execute() path.
// Old style environment overrides
#pragma warning disable 0618 // obsolete
Dictionary envOverrides = EnvironmentOverride;
@@ -701,6 +709,19 @@ protected virtual ProcessStartInfo GetProcessStartInfo(
startInfo.Environment[variable.Key] = variable.Value;
}
}
+ else if (EnvironmentVariables != null)
+ {
+ // Fallback for callers outside the normal Execute() path
+ // where _environmentVariablePairs hasn't been populated yet.
+ foreach (string entry in EnvironmentVariables)
+ {
+ string[] nameValuePair = entry.Split(s_equalsSplitter, 2);
+ if (nameValuePair.Length == 2 && nameValuePair[0].Length > 0)
+ {
+ startInfo.Environment[nameValuePair[0]] = nameValuePair[1];
+ }
+ }
+ }
return startInfo;
}
@@ -868,24 +889,36 @@ protected virtual int ExecuteTool(
///
/// File to delete
protected void DeleteTempFile(string fileName)
+ {
+ AbsolutePath filePath = !string.IsNullOrEmpty(fileName) ? TaskEnvironment.GetAbsolutePath(fileName) : new AbsolutePath(fileName, ignoreRootedCheck: true);
+ DeleteTempFile(filePath);
+ }
+
+ ///
+ /// Overload of that accepts an .
+ /// If the delete fails for some reason (e.g. file locked by anti-virus) then
+ /// the call will not throw an exception. Instead a warning will be logged, but the build will not fail.
+ ///
+ /// Absolute path to file to delete
+ protected void DeleteTempFile(AbsolutePath filePath)
{
if (s_preserveTempFiles)
{
- Log.LogMessageFromText($"Preserving temporary file '{fileName}'", MessageImportance.Low);
+ Log.LogMessageFromText($"Preserving temporary file '{filePath.OriginalValue}'", MessageImportance.Low);
return;
}
try
{
- File.Delete(fileName);
+ File.Delete(filePath);
}
catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
{
- string lockedFileMessage = LockCheck.GetLockedFileMessage(fileName);
+ string lockedFileMessage = LockCheck.GetLockedFileMessage(filePath);
// Warn only -- occasionally temp files fail to delete because of virus checkers; we
// don't want the build to fail in such cases
- LogShared.LogWarningWithCodeFromResources("Shared.FailedDeletingTempFile", fileName, e.Message, lockedFileMessage);
+ LogShared.LogWarningWithCodeFromResources("Shared.FailedDeletingTempFile", filePath.OriginalValue, e.Message, lockedFileMessage);
}
}
@@ -1027,7 +1060,7 @@ private void KillToolProcessOnTimeout(Process proc, bool isBeingCancelled)
}
int timeout = TaskProcessTerminationTimeout >= -1 ? TaskProcessTerminationTimeout : 5000;
- string timeoutFromEnvironment = Environment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT");
+ string timeoutFromEnvironment = TaskEnvironment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT");
if (timeoutFromEnvironment != null)
{
if (int.TryParse(timeoutFromEnvironment, out int result) && result >= 0)
@@ -1380,17 +1413,22 @@ private bool AssignStandardStreamLoggingImportance()
///
///
/// The location of the file, or null if file not found.
- internal static string FindOnPath(string filename)
+ internal string FindOnPath(string filename)
{
// Get path from the environment and split path separator
- return Environment.GetEnvironmentVariable("PATH")?
+ return TaskEnvironment.GetEnvironmentVariable("PATH")?
.Split(MSBuildConstants.PathSeparatorChar)?
.Where(path =>
{
+ if (string.IsNullOrEmpty(path))
+ {
+ return false;
+ }
+
try
{
// The PATH can contain anything, including bad characters
- return FileSystems.Default.DirectoryExists(path);
+ return FileSystems.Default.DirectoryExists(TaskEnvironment.GetAbsolutePath(path));
}
catch (Exception)
{
@@ -1398,7 +1436,7 @@ internal static string FindOnPath(string filename)
}
})
.Select(folderPath => Path.Combine(folderPath, filename))
- .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(fullPath));
+ .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(fullPath)));
}
#endregion