From ab5fdde7e5e0f2ad31a5a5ef7c570235166c0a46 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 20 Mar 2026 13:56:20 +0100 Subject: [PATCH 01/19] Migrate Al task to TaskEnvironment API --- src/Tasks.UnitTests/Al_Tests.cs | 149 ++++++++++++++++------ src/Tasks/Al.cs | 35 +++-- src/Utilities.UnitTests/ToolTask_Tests.cs | 83 ++++++++++++ src/Utilities/ToolTask.cs | 65 ++++++++++ 4 files changed, 284 insertions(+), 48 deletions(-) diff --git a/src/Tasks.UnitTests/Al_Tests.cs b/src/Tasks.UnitTests/Al_Tests.cs index b1a777002cc..e7681cc9226 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,64 @@ public void Win32Resource() // Check the parameters. CommandLine.ValidateHasParameter(t, @"/win32res:foo.res"); } + +#if NETFRAMEWORK + /// + /// Verifies that GenerateFullPathToTool returns an absolute path (or null) + /// when called with a multithreaded TaskEnvironment, validating the + /// TaskEnvironment.GetAbsolutePath() integration. + /// + [Fact] + 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. + /// + [Fact] + 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); + } +#endif } } diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs index d1872e4fb39..816df766e07 100644 --- a/src/Tasks/Al.cs +++ b/src/Tasks/Al.cs @@ -3,7 +3,7 @@ #if NETFRAMEWORK using System; - +using System.Diagnostics; using Microsoft.Build.Shared.FileSystem; using Microsoft.Build.Utilities; #endif @@ -20,9 +20,12 @@ namespace Microsoft.Build.Tasks /// This class defines the "AL" XMake task, which enables using al.exe to link /// modules and resource files into assemblies. /// - public class AL : ToolTaskExtension, IALTaskContract + [MSBuildMultiThreadableTask] + public class AL : ToolTaskExtension, IALTaskContract, IMultiThreadableTask { #region Properties + public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance); + /* Microsoft (R) Assembly Linker version 7.10.2175 for Microsoft (R) .NET Framework version 1.2 @@ -297,7 +300,7 @@ public string SdkToolsPath protected override string ToolName => "al.exe"; /// - /// Return the path of the tool to execute + /// Return the path of the tool to execute. /// protected override string GenerateFullPathToTool() { @@ -305,12 +308,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,12 +321,27 @@ 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; } + protected override ProcessStartInfo GetProcessStartInfo( + string pathToTool, + string commandLineCommands, + string responseFileSwitch) => TaskEnvironment != null + ? GetProcessStartInfoMultiThreaded(pathToTool, commandLineCommands, responseFileSwitch, TaskEnvironment) + : base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + /// /// Fills the provided CommandLineBuilderExtension with those switches and other information that can go into a response file. /// @@ -369,7 +387,7 @@ protected internal override void AddResponseFileCommands(CommandLineBuilderExten ["LogicalName", "TargetFile", "Access"]); // It's a good idea for the response file to be the very last switch passed, just - // from a predictability perspective. This is also consistent with the compiler + // from a predictability perspective. This is also consistent with the compiler // tasks (Csc, etc.) if (ResponseFiles != null) { @@ -400,6 +418,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 daccdae7fa6..85be85caf88 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; @@ -1168,5 +1169,87 @@ public int TerminationTimeout /// public override bool Execute() => true; } + + /// + /// A ToolTask subclass that exposes GetProcessStartInforMultiThreaded for testing. + /// + private sealed class MultiThreadedToolTask : ToolTask, IDisposable + { + private readonly string _fullToolName; + + public MultiThreadedToolTask(string fullToolName) + { + _fullToolName = fullToolName; + } + + public void Dispose() { } + + protected override string ToolName => Path.GetFileName(_fullToolName); + + protected override string GenerateFullPathToTool() => _fullToolName; + + /// + /// Exposes the protected GetProcessStartInforMultiThreaded for test verification. + /// + public ProcessStartInfo CallGetProcessStartInforMultiThreaded(TaskEnvironment taskEnvironment) + { + return GetProcessStartInfoMultiThreaded( + _fullToolName, + commandLineCommands: "/nologo", + responseFileSwitch: null, + taskEnvironment); + } + } + + [Fact] + public void GetProcessStartInforMultiThreaded_ShouldPropagateWorkingDirectory() + { + // Arrange: create a MultiThreadedTaskEnvironmentDriver with a known project directory. + string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; + using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir); + var taskEnv = new TaskEnvironment(driver); + + string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; + using var tool = new MultiThreadedToolTask(toolPath); + tool.BuildEngine = new MockEngine(_output); + + // Act + ProcessStartInfo result = tool.CallGetProcessStartInforMultiThreaded(taskEnv); + + // Assert: verify env vars from the driver are present + result.Environment.Count.ShouldBeGreaterThan(0, "Environment variables should be propagated from TaskEnvironment"); + + // Assert: verify WorkingDirectory + result.WorkingDirectory.ShouldBe(expectedWorkingDir, + "WorkingDirectory from MultiThreadedTaskEnvironmentDriver should be propagated to the child process ProcessStartInfo"); + } + + [Fact] + public void GetProcessStartInforMultiThreaded_EnvironmentVariablesOverride() + { + // Arrange: create a multithreaded 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); + 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.CallGetProcessStartInforMultiThreaded(taskEnv); + + // Assert: task-level override should win. + result.Environment["MY_VAR"].ShouldBe("from_task_override", + "EnvironmentVariables property on the task should override TaskEnvironment values"); + } } } diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index 1aaff6ca0ec..8c6ffe9513f 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -633,6 +633,14 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string pathToTool, string commandLineCommands, string responseFileSwitch) + { + return CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + } + + private ProcessStartInfo CreateBaseProcessStartInfo( + string pathToTool, + string commandLineCommands, + string responseFileSwitch) { // Build up the command line that will be spawned. string commandLine = commandLineCommands; @@ -705,6 +713,63 @@ protected virtual ProcessStartInfo GetProcessStartInfo( return startInfo; } + protected ProcessStartInfo GetProcessStartInfoMultiThreaded( + string pathToTool, + string commandLineCommands, + string responseFileSwitch, + TaskEnvironment taskEnvironment) + { + // Call the non-virtual helper to get base ProcessStartInfo with all ToolTask settings + // (command line, redirections, encodings, etc.). Using CreateBaseProcessStartInfo instead + // of the virtual GetProcessStartInfo avoids infinite recursion when a subclass overrides + // GetProcessStartInfo to route through this method. + ProcessStartInfo startInfo = CreateBaseProcessStartInfo(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; + } + + string workingDirectory = taskEnvStartInfo.WorkingDirectory; + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + // 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; + } + /// /// We expect tasks to override this method if they need information about the tool process or its process events during task execution. /// Implementation should make sure that the task is started in this method. From 64509ed0c19c69194759984ebaccc1fff70d9b7c Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 20 Mar 2026 14:34:10 +0100 Subject: [PATCH 02/19] fix typo --- src/Utilities.UnitTests/ToolTask_Tests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 85be85caf88..970274fc98a 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -1171,7 +1171,7 @@ public int TerminationTimeout } /// - /// A ToolTask subclass that exposes GetProcessStartInforMultiThreaded for testing. + /// A ToolTask subclass that exposes GetProcessStartInfoMultiThreaded for testing. /// private sealed class MultiThreadedToolTask : ToolTask, IDisposable { @@ -1189,9 +1189,9 @@ public void Dispose() { } protected override string GenerateFullPathToTool() => _fullToolName; /// - /// Exposes the protected GetProcessStartInforMultiThreaded for test verification. + /// Exposes the protected GetProcessStartInfoMultiThreaded for test verification. /// - public ProcessStartInfo CallGetProcessStartInforMultiThreaded(TaskEnvironment taskEnvironment) + public ProcessStartInfo CallGetProcessStartInfoMultiThreaded(TaskEnvironment taskEnvironment) { return GetProcessStartInfoMultiThreaded( _fullToolName, @@ -1202,7 +1202,7 @@ public ProcessStartInfo CallGetProcessStartInforMultiThreaded(TaskEnvironment ta } [Fact] - public void GetProcessStartInforMultiThreaded_ShouldPropagateWorkingDirectory() + public void GetProcessStartInfoMultiThreaded_ShouldPropagateWorkingDirectory() { // Arrange: create a MultiThreadedTaskEnvironmentDriver with a known project directory. string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; @@ -1214,7 +1214,7 @@ public void GetProcessStartInforMultiThreaded_ShouldPropagateWorkingDirectory() tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInforMultiThreaded(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStartInfoMultiThreaded(taskEnv); // Assert: verify env vars from the driver are present result.Environment.Count.ShouldBeGreaterThan(0, "Environment variables should be propagated from TaskEnvironment"); @@ -1225,7 +1225,7 @@ public void GetProcessStartInforMultiThreaded_ShouldPropagateWorkingDirectory() } [Fact] - public void GetProcessStartInforMultiThreaded_EnvironmentVariablesOverride() + public void GetProcessStartInfoMultiThreaded_EnvironmentVariablesOverride() { // Arrange: create a multithreaded driver with a custom env var. string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; @@ -1245,7 +1245,7 @@ public void GetProcessStartInforMultiThreaded_EnvironmentVariablesOverride() tool.EnvironmentVariables = ["MY_VAR=from_task_override"]; // Act - ProcessStartInfo result = tool.CallGetProcessStartInforMultiThreaded(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStartInfoMultiThreaded(taskEnv); // Assert: task-level override should win. result.Environment["MY_VAR"].ShouldBe("from_task_override", From 174266241357b0c07e26d3bc15942e8507559821 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 20 Mar 2026 16:07:27 +0100 Subject: [PATCH 03/19] Renaming, comment fix, remove nullablity check of TaskEnvironment --- src/Tasks/Al.cs | 4 +--- src/Utilities.UnitTests/ToolTask_Tests.cs | 2 +- src/Utilities/ToolTask.cs | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs index 816df766e07..eafc0d9fbe9 100644 --- a/src/Tasks/Al.cs +++ b/src/Tasks/Al.cs @@ -338,9 +338,7 @@ protected override string GenerateFullPathToTool() protected override ProcessStartInfo GetProcessStartInfo( string pathToTool, string commandLineCommands, - string responseFileSwitch) => TaskEnvironment != null - ? GetProcessStartInfoMultiThreaded(pathToTool, commandLineCommands, responseFileSwitch, TaskEnvironment) - : base.GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + string responseFileSwitch) => GetProcessStartInfoMultithreadable(pathToTool, commandLineCommands, responseFileSwitch, TaskEnvironment); /// /// Fills the provided CommandLineBuilderExtension with those switches and other information that can go into a response file. diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 970274fc98a..2e08e2f1c77 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -1193,7 +1193,7 @@ public void Dispose() { } /// public ProcessStartInfo CallGetProcessStartInfoMultiThreaded(TaskEnvironment taskEnvironment) { - return GetProcessStartInfoMultiThreaded( + return GetProcessStartInfoMultithreadable( _fullToolName, commandLineCommands: "/nologo", responseFileSwitch: null, diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index 8c6ffe9513f..7c21ee3441c 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -713,7 +713,7 @@ private ProcessStartInfo CreateBaseProcessStartInfo( return startInfo; } - protected ProcessStartInfo GetProcessStartInfoMultiThreaded( + protected ProcessStartInfo GetProcessStartInfoMultithreadable( string pathToTool, string commandLineCommands, string responseFileSwitch, @@ -742,7 +742,7 @@ protected ProcessStartInfo GetProcessStartInfoMultiThreaded( } // Re-apply obsolete EnvironmentOverride and EnvironmentVariables property overrides — - // they should take precedence over TaskEnvironment. The base class already applied these, + // they should take precedence over TaskEnvironment. CreateBaseProcessStartInfo already applied these, // but we cleared the environment above, so we need to re-apply them. #pragma warning disable 0618 // obsolete Dictionary envOverrides = EnvironmentOverride; From 1d9746992d30805cc4339f55cdc001058672875f Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Mon, 23 Mar 2026 13:54:52 +0100 Subject: [PATCH 04/19] refactor common code --- src/Utilities/ToolTask.cs | 64 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index 7c21ee3441c..40a4e0b2409 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -634,7 +634,9 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string commandLineCommands, string responseFileSwitch) { - return CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + ProcessStartInfo startInfo = CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + ApplyEnvironmentOverrides(startInfo); + return startInfo; } private ProcessStartInfo CreateBaseProcessStartInfo( @@ -689,6 +691,18 @@ private ProcessStartInfo CreateBaseProcessStartInfo( startInfo.WorkingDirectory = workingDirectory; } + return startInfo; + } + + /// + /// Applies task-level environment variable (both the obsolete + /// and the current ) to the given . + /// Prefers the pre-parsed populated by , + /// falling back to parsing directly for callers outside + /// the normal Execute() path. + /// + private void ApplyEnvironmentOverrides(ProcessStartInfo startInfo) + { // Old style environment overrides #pragma warning disable 0618 // obsolete Dictionary envOverrides = EnvironmentOverride; @@ -709,8 +723,19 @@ private ProcessStartInfo CreateBaseProcessStartInfo( startInfo.Environment[variable.Key] = variable.Value; } } - - return startInfo; + 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]; + } + } + } } protected ProcessStartInfo GetProcessStartInfoMultithreadable( @@ -735,37 +760,10 @@ protected ProcessStartInfo GetProcessStartInfoMultithreadable( startInfo.Environment[kvp.Key] = kvp.Value; } - string workingDirectory = taskEnvStartInfo.WorkingDirectory; - if (workingDirectory != null) - { - startInfo.WorkingDirectory = workingDirectory; - } - - // Re-apply obsolete EnvironmentOverride and EnvironmentVariables property overrides — - // they should take precedence over TaskEnvironment. CreateBaseProcessStartInfo 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 + startInfo.WorkingDirectory = taskEnvStartInfo.WorkingDirectory; - 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]; - } - } - } + // Apply task-level environment overrides — they should take precedence over TaskEnvironment. + ApplyEnvironmentOverrides(startInfo); return startInfo; } From 23a3ede4a80a70b1a78afd080a928eee6460dd4a Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Tue, 24 Mar 2026 09:15:55 +0100 Subject: [PATCH 05/19] Refactor --- ToolTask_original.txt | 1855 +++++++++++++++++++++++++++++++++++++ src/Utilities/ToolTask.cs | 44 +- 2 files changed, 1875 insertions(+), 24 deletions(-) create mode 100644 ToolTask_original.txt diff --git a/ToolTask_original.txt b/ToolTask_original.txt new file mode 100644 index 00000000000..4fd8a2320fe --- /dev/null +++ b/ToolTask_original.txt @@ -0,0 +1,1855 @@ +// 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; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Resources; +using System.Text; +using System.Threading; +using Microsoft.Build.Framework; +using Microsoft.Build.Shared; +using Microsoft.Build.Shared.FileSystem; + +#nullable disable + +namespace Microsoft.Build.Utilities +{ + /// + /// The return value from InitializeHostObject. This enumeration defines what action the ToolTask + /// should take next, after we've tried to initialize the host object. + /// + public enum HostObjectInitializationStatus + { + /// + /// This means that there exists an appropriate host object for this task, it can support + /// all of the parameters passed in, and it should be invoked to do the real work of the task. + /// + UseHostObjectToExecute, + + /// + /// This means that either there is no host object available, or that the host object is + /// not capable of supporting all of the features required for this build. Therefore, + /// ToolTask should fallback to an alternate means of doing the build, such as invoking + /// the command-line tool. + /// + UseAlternateToolToExecute, + + /// + /// This means that the host object is already up-to-date, and no further action is necessary. + /// + NoActionReturnSuccess, + + /// + /// This means that some of the parameters being passed into the task are invalid, and the + /// task should fail immediately. + /// + NoActionReturnFailure + } + + /// + /// Base class used for tasks that spawn an executable. This class implements the ToolPath property which can be used to + /// override the default path. + /// + // 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 + { + private static readonly bool s_preserveTempFiles = string.Equals(Environment.GetEnvironmentVariable("MSBUILDPRESERVETOOLTEMPFILES"), "1", StringComparison.Ordinal); + + #region Constructors + + /// + /// Protected constructor + /// + protected ToolTask() + { + LogPrivate = new TaskLoggingHelper(this) + { + TaskResources = AssemblyResources.PrimaryResources, + HelpKeywordPrefix = "MSBuild." + }; + + LogShared = new TaskLoggingHelper(this) + { + TaskResources = AssemblyResources.SharedResources, + HelpKeywordPrefix = "MSBuild." + }; + + // 5 second is the default termination timeout. + TaskProcessTerminationTimeout = 5000; + ToolCanceled = new ManualResetEvent(false); + } + + /// + /// Protected constructor + /// + /// The resource manager for task resources + protected ToolTask(ResourceManager taskResources) + : this() + { + TaskResources = taskResources; + } + + /// + /// Protected constructor + /// + /// The resource manager for task resources + /// The help keyword prefix for task's messages + protected ToolTask(ResourceManager taskResources, string helpKeywordPrefix) + : this(taskResources) + { + HelpKeywordPrefix = helpKeywordPrefix; + } + + #endregion + + #region Properties + + /// + /// The return code of the spawned process. If the task logged any errors, but the process + /// had an exit code of 0 (success), this will be set to -1. + /// + [Output] + public int ExitCode { get; private set; } + + /// + /// True when ExitCode was overridden from 0 to -1 because the task logged errors + /// despite the tool reporting success (exit code 0). + /// + protected bool ExitCodeOverriddenToIndicateErrors { get; private set; } + + /// + /// When set to true, this task will yield the node when its task is executing. + /// + public bool YieldDuringToolExecution { get; set; } + + /// + /// When set to true, the tool task will create a batch file for the command-line and execute that using the command-processor, + /// rather than executing the command directly. + /// + public bool UseCommandProcessor { get; set; } + + /// + /// When set to true, it passes /Q to the cmd.exe command line such that the command line does not get echo-ed on stdout + /// + public bool EchoOff { get; set; } + + /// + /// A timeout to wait for a task to terminate before killing it. In milliseconds. + /// + protected int TaskProcessTerminationTimeout { get; set; } + + /// + /// Used to signal when a tool has been cancelled. + /// + protected ManualResetEvent ToolCanceled { get; private set; } + + /// + /// This is the batch file created when UseCommandProcessor is set to true. + /// + private string _temporaryBatchFile; + + /// + /// The encoding set to the console code page. + /// + private Encoding _encoding; + + /// + /// Implemented by the derived class. Returns a string which is the name of the underlying .EXE to run e.g. "resgen.exe" + /// Only used by the ToolExe getter. + /// + /// Name of tool. + protected abstract string ToolName { get; } + + /// + /// Projects may set this to override a task's ToolName. + /// Tasks may override this to prevent that. + /// + public virtual string ToolExe + { + get + { + if (!string.IsNullOrEmpty(_toolExe)) + { + // If the ToolExe has been overridden then return the value + return _toolExe; + } + else + { + // We have no override, so simply delegate to ToolName + return ToolName; + } + } + set => _toolExe = value; + } + + /// + /// Project-visible property allows the user to override the path to the executable. + /// + /// Path to tool. + public string ToolPath { set; get; } + + /// + /// Whether or not to use UTF8 encoding for the cmd file and console window. + /// Values: Always, Never, Detect + /// If set to Detect, the current code page will be used unless it cannot represent + /// the Command string. In that case, UTF-8 is used. + /// + public string UseUtf8Encoding { get; set; } = EncodingUtilities.UseUtf8Detect; + + /// + /// Array of equals-separated pairs of environment + /// variables that should be passed to the spawned executable, + /// in addition to (or selectively overriding) the regular environment block. + /// + /// + /// Using this instead of EnvironmentOverride as that takes a Dictionary, + /// which cannot be set from an MSBuild project. + /// + public string[] EnvironmentVariables { get; set; } + + /// + /// Project visible property that allows the user to specify an amount of time after which the task executable + /// is terminated. + /// + /// Time-out in milliseconds. Default is (no time-out). + public virtual int Timeout { set; get; } = System.Threading.Timeout.Infinite; + + /// + /// Overridable property specifying the encoding of the response file, UTF8 by default + /// + protected virtual Encoding ResponseFileEncoding => Encoding.UTF8; + + /// + /// Overridable method to escape content of the response file + /// + [SuppressMessage("Microsoft.Naming", "CA1720:IdentifiersShouldNotContainTypeNames", MessageId = "string", Justification = "Shipped this way in Dev11 Beta (go-live)")] + protected virtual string ResponseFileEscape(string responseString) => responseString; + + /// + /// Overridable property specifying the encoding of the captured task standard output stream + /// + /// + /// Console-based output uses the current system OEM code page by default. Note that we should not use Console.OutputEncoding + /// here since processes we run don't really have much to do with our console window (and also Console.OutputEncoding + /// doesn't return the OEM code page if the running application that hosts MSBuild is not a console application). + /// + protected virtual Encoding StandardOutputEncoding + { + get + { + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) + { + if (_encoding != null) + { + // Keep the encoding of standard output & error consistent with the console code page. + return _encoding; + } + } + return EncodingUtilities.CurrentSystemOemEncoding; + } + } + + /// + /// Overridable property specifying the encoding of the captured task standard error stream + /// + /// + /// Console-based output uses the current system OEM code page by default. Note that we should not use Console.OutputEncoding + /// here since processes we run don't really have much to do with our console window (and also Console.OutputEncoding + /// doesn't return the OEM code page if the running application that hosts MSBuild is not a console application). + /// + protected virtual Encoding StandardErrorEncoding + { + get + { + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) + { + if (_encoding != null) + { + // Keep the encoding of standard output & error consistent with the console code page. + return _encoding; + } + } + return EncodingUtilities.CurrentSystemOemEncoding; + } + } + + /// + /// Gets the Path override value. + /// + /// The new value for the Environment for the task. + [Obsolete("Use EnvironmentVariables property")] + protected virtual Dictionary EnvironmentOverride => null; + + /// + /// Importance with which to log text from the + /// standard error stream. + /// + protected virtual MessageImportance StandardErrorLoggingImportance => MessageImportance.Normal; + + /// + /// Whether this ToolTask has logged any errors + /// + protected virtual bool HasLoggedErrors => Log.HasLoggedErrors || LogPrivate.HasLoggedErrors || LogShared.HasLoggedErrors; + + /// + /// Task Parameter: Importance with which to log text from the + /// standard out stream. + /// + public string StandardOutputImportance { get; set; } = null; + + /// + /// Task Parameter: Importance with which to log text from the + /// standard error stream. + /// + public string StandardErrorImportance { get; set; } = null; + + /// + /// Should ALL messages received on the standard error stream be logged as errors. + /// + public bool LogStandardErrorAsError { get; set; } = false; + + /// + /// Importance with which to log text from in the + /// standard out stream. + /// + protected virtual MessageImportance StandardOutputLoggingImportance => MessageImportance.Low; + + /// + /// The actual importance at which standard out messages will be logged. + /// + protected MessageImportance StandardOutputImportanceToUse => _standardOutputImportanceToUse; + + /// + /// The actual importance at which standard error messages will be logged. + /// + protected MessageImportance StandardErrorImportanceToUse => _standardErrorImportanceToUse; + + #endregion + + #region Private properties + + /// + /// Gets an instance of a private TaskLoggingHelper class containing task logging methods. + /// This is necessary because ToolTask lives in a different assembly than the task inheriting from it + /// and needs its own separate resources. + /// + /// The logging helper object. + private TaskLoggingHelper LogPrivate { get; } + + // the private logging helper + + /// + /// Gets an instance of a shared resources TaskLoggingHelper class containing task logging methods. + /// This is necessary because ToolTask lives in a different assembly than the task inheriting from it + /// and needs its own separate resources. + /// + /// The logging helper object. + private TaskLoggingHelper LogShared { get; } + + // the shared resources logging helper + + #endregion + + #region Overridable methods + + /// + /// Overridable function called after in + /// + protected virtual void ProcessStarted() { } + + /// + /// Gets the fully qualified tool name. Should return ToolExe if ToolTask should search for the tool + /// in the system path. If ToolPath is set, this is ignored. + /// + /// Path string. + protected abstract string GenerateFullPathToTool(); + + /// + /// Gets the working directory to use for the process. Should return null if ToolTask should use the + /// current directory. + /// + /// This is a method rather than a property so that derived classes (like Exec) can choose to + /// expose a public WorkingDirectory property, and it would be confusing to have two properties. + /// + protected virtual string GetWorkingDirectory() => null; + + /// + /// Implemented in the derived class + /// + /// true, if successful + protected internal virtual bool ValidateParameters() + { + if (TaskProcessTerminationTimeout < -1) + { + Log.LogWarningWithCodeFromResources("ToolTask.InvalidTerminationTimeout", TaskProcessTerminationTimeout); + return false; + } + + return true; + } + + /// + /// Returns true if task execution is not necessary. Executed after ValidateParameters + /// + /// + protected virtual bool SkipTaskExecution() { canBeIncremental = false; return false; } + + /// + /// ToolTask is not incremental by default. When a derived class overrides SkipTaskExecution, then Question feature can take into effect. + /// + protected bool canBeIncremental { get; set; } = true; + + public bool FailIfNotIncremental { get; set; } + + /// + /// Returns a string with those switches and other information that can go into a response file. + /// Called after ValidateParameters and SkipTaskExecution + /// + /// + protected virtual string GenerateResponseFileCommands() => string.Empty; // Default is nothing. This is useful for tools that don't need or support response files. + + /// + /// Returns a string with those switches and other information that can't go into a response file and + /// must go directly onto the command line. + /// Called after ValidateParameters and SkipTaskExecution + /// + /// + protected virtual string GenerateCommandLineCommands() => string.Empty; // Default is nothing. This is useful for tools where all the parameters can go into a response file. + + /// + /// Returns the command line switch used by the tool executable to specify the response file. + /// Will only be called if the task returned a non empty string from GetResponseFileCommands + /// Called after ValidateParameters, SkipTaskExecution and GetResponseFileCommands + /// + /// full path to the temporarily created response file + /// + protected virtual string GetResponseFileSwitch(string responseFilePath) => "@\"" + responseFilePath + "\""; // by default, return @"" + + /// + /// Allows tool to handle the return code. + /// This method will only be called with non-zero exitCode. + /// + /// The return value of this method will be used as the task return value + protected virtual bool HandleTaskExecutionErrors() + { + Debug.Assert(ExitCode != 0, "HandleTaskExecutionErrors should only be called if there were problems executing the task"); + + if (HasLoggedErrors) + { + if (ExitCodeOverriddenToIndicateErrors) + { + // The tool finished with a zero exit code but errors were logged, causing ExitCode to be set to -1. + LogPrivate.LogMessageFromResources(MessageImportance.Low, "ToolTask.ToolCommandExitedZeroWithErrors"); + } + else + { + // The tool failed with a non-zero exit code and already logged its own errors. + LogPrivate.LogMessageFromResources(MessageImportance.Low, "General.ToolCommandFailedNoErrorCode", ExitCode); + } + } + else + { + // If the tool itself did not log any errors on its own, then we log one now simply saying + // that the tool exited with a non-zero exit code. This way, the customer nevers sees + // "Build failed" without at least one error being logged. + LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolCommandFailed", ToolExe, ExitCode); + } + + // by default, always fail the task + return false; + } + + /// + /// We expect the tasks to override this method, if they support host objects. The implementation should call into the + /// host object to perform the real work of the task. For example, for compiler tasks like Csc and Vbc, this method would + /// call Compile() on the host object. + /// + /// The return value indicates success (true) or failure (false) if the host object was actually called to do the work. + protected virtual bool CallHostObjectToExecute() => false; + + /// + /// We expect tasks to override this method if they support host objects. The implementation should + /// make sure that the host object is ready to perform the real work of the task. + /// + /// The return value indicates what steps to take next. The default is to assume that there + /// is no host object provided, and therefore we should fallback to calling the command-line tool. + protected virtual HostObjectInitializationStatus InitializeHostObject() => HostObjectInitializationStatus.UseAlternateToolToExecute; + + /// + /// Logs the actual command line about to be executed (or what the task wants the log to show) + /// + /// + /// Descriptive message about what is happening - usually the command line to be executed. + /// + protected virtual void LogToolCommand(string message) => LogPrivate.LogCommandLine(MessageImportance.High, message); // Log a descriptive message about what's happening. + + /// + /// Logs the tool name and the path from where it is being run. + /// + /// + /// The tool to Log. This is the actual tool being used, ie. if ToolExe has been specified it will be used, otherwise it will be ToolName + /// + /// + /// The path from where the tool is being run. + /// + protected virtual void LogPathToTool(string toolName, string pathToTool) + { + // We don't do anything here any more, as it was just duplicative and noise. + // The method only remains for backwards compatibility - to avoid breaking tasks that override it + } + + #endregion + + #region Methods + + /// + /// Figures out the path to the tool (including the .exe), either by using the ToolPath + /// parameter, or by asking the derived class to tell us where it should be located. + /// + /// path to the tool, or null + private string ComputePathToTool() + { + string pathToTool = null; + + if (UseCommandProcessor) + { + return ToolExe; + } + + if (!string.IsNullOrEmpty(ToolPath)) + { + // If the project author passed in a ToolPath, always use that. + pathToTool = Path.Combine(ToolPath, ToolExe); + } + + if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(pathToTool))) + { + // Otherwise, try to find the tool ourselves. + pathToTool = GenerateFullPathToTool(); + + // We have no toolpath, but we have been given an override + // for the tool exe, fix up the path, assuming that the tool is in the same location + if (pathToTool != null && !string.IsNullOrEmpty(_toolExe)) + { + string directory = Path.GetDirectoryName(pathToTool); + pathToTool = Path.Combine(directory, ToolExe); + } + } + + // only look for the file if we have a path to it. If we have just the file name, we'll + // look for it in the path + if (pathToTool != null) + { + bool isOnlyFileName = Path.GetFileName(pathToTool).Length == pathToTool.Length; + if (!isOnlyFileName) + { + bool isExistingFile = FileSystems.Default.FileExists(pathToTool); + if (!isExistingFile) + { + LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolExecutableNotFound", pathToTool); + return null; + } + } + else + { + // if we just have the file name, search for the file on the system path + string actualPathToTool = FindOnPath(pathToTool); + + // if we find the file + if (actualPathToTool != null) + { + // point to it + pathToTool = actualPathToTool; + } + else + { + // if we cannot find the file, we'll probably error out later on when + // we try to launch the tool; so do nothing for now + } + } + } + + return pathToTool; + } + + /// + /// Creates a temporary response file for the given command line arguments. + /// We put as many command line arguments as we can into a response file to + /// prevent the command line from getting too long. An overly long command + /// line can cause the process creation to fail. + /// + /// + /// Command line arguments that cannot be put into response files, and which + /// must appear on the command line, should not be passed to this method. + /// + /// The command line arguments that need + /// to go into the temporary response file. + /// [out] The command line switch for using + /// the temporary response file, or null if the response file is not needed. + /// + /// The path to the temporary response file, or null if the response + /// file is not needed. + private string GetTemporaryResponseFile(string responseFileCommands, out string responseFileSwitch) + { + string responseFile = null; + responseFileSwitch = null; + + // if this tool supports response files + if (!string.IsNullOrEmpty(responseFileCommands)) + { + // put all the parameters into a temporary response file so we don't + // have to worry about how long the command-line is going to be + + // May throw IO-related exceptions + responseFile = FileUtilities.GetTemporaryFileName(".rsp"); + + // Use the encoding specified by the overridable ResponseFileEncoding property + using (StreamWriter responseFileStream = FileUtilities.OpenWrite(responseFile, false, ResponseFileEncoding)) + { + responseFileStream.Write(ResponseFileEscape(responseFileCommands)); + } + + responseFileSwitch = GetResponseFileSwitch(responseFile); + } + + return responseFile; + } + + /// + /// Initializes the information required to spawn the process executing the tool. + /// + /// + /// + /// + /// The information required to start the process. + protected virtual ProcessStartInfo GetProcessStartInfo( + string pathToTool, + string commandLineCommands, + string responseFileSwitch) + { + return CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + } + + private ProcessStartInfo CreateBaseProcessStartInfo( + string pathToTool, + string commandLineCommands, + string responseFileSwitch) + { + // Build up the command line that will be spawned. + string commandLine = commandLineCommands; + + if (!UseCommandProcessor) + { + if (!string.IsNullOrEmpty(responseFileSwitch)) + { + commandLine += " " + responseFileSwitch; + } + } + + // If the command is too long, it will most likely fail. The command line + // arguments passed into any process cannot exceed 32768 characters, but + // depending on the structure of the command (e.g. if it contains embedded + // environment variables that will be expanded), longer commands might work, + // or shorter commands might fail -- to play it safe, we warn at 32000. + // NOTE: cmd.exe has a buffer limit of 8K, but we're not using cmd.exe here, + // so we can go past 8K easily. + if (commandLine.Length > 32000) + { + LogPrivate.LogWarningWithCodeFromResources("ToolTask.CommandTooLong", GetType().Name); + } + + ProcessStartInfo startInfo = new ProcessStartInfo(pathToTool, commandLine); + startInfo.CreateNoWindow = true; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardError = true; + startInfo.RedirectStandardOutput = true; + // ensure the redirected streams have the encoding we want + startInfo.StandardErrorEncoding = StandardErrorEncoding; + startInfo.StandardOutputEncoding = StandardOutputEncoding; + + if (NativeMethodsShared.IsWindows) + { + // Some applications such as xcopy.exe fail without error if there's no stdin stream. + // We only do it under Windows, we get Pipe Broken IO exception on other systems if + // the program terminates very fast. + startInfo.RedirectStandardInput = true; + } + + // Generally we won't set a working directory, and it will use the current directory + string workingDirectory = GetWorkingDirectory(); + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + // Old style environment overrides +#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 + } + + // New style environment overrides + if (_environmentVariablePairs != null) + { + foreach (KeyValuePair variable in _environmentVariablePairs) + { + startInfo.Environment[variable.Key] = variable.Value; + } + } + + return startInfo; + } + + protected ProcessStartInfo GetProcessStartInfoMultiThreaded( + string pathToTool, + string commandLineCommands, + string responseFileSwitch, + TaskEnvironment taskEnvironment) + { + // Call the non-virtual helper to get base ProcessStartInfo with all ToolTask settings + // (command line, redirections, encodings, etc.). Using CreateBaseProcessStartInfo instead + // of the virtual GetProcessStartInfo avoids infinite recursion when a subclass overrides + // GetProcessStartInfo to route through this method. + ProcessStartInfo startInfo = CreateBaseProcessStartInfo(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; + } + + string workingDirectory = taskEnvStartInfo.WorkingDirectory; + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + // 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; + } + + /// + /// We expect tasks to override this method if they need information about the tool process or its process events during task execution. + /// Implementation should make sure that the task is started in this method. + /// Starts the process during task execution. + /// + /// Fully populated instance representing the tool process to be started. + /// A started process. This could be or another instance. + protected virtual Process StartToolProcess(Process proc) + { + proc.Start(); + return proc; + } + + /// + /// Writes out a temporary response file and shell-executes the tool requested. Enables concurrent + /// logging of the output of the tool. + /// + /// The computed path to tool executable on disk + /// Command line arguments that should go into a temporary response file + /// Command line arguments that should be passed to the tool executable directly + /// exit code from the tool - if errors were logged and the tool has an exit code of zero, then we sit it to -1 + protected virtual int ExecuteTool( + string pathToTool, + string responseFileCommands, + string commandLineCommands) + { + if (!UseCommandProcessor) + { + LogPathToTool(ToolExe, pathToTool); + } + + string responseFile = null; + Process proc = null; + + _standardErrorData = new Queue(); + _standardOutputData = new Queue(); + + _standardErrorDataAvailable = new ManualResetEvent(false); + _standardOutputDataAvailable = new ManualResetEvent(false); + + _toolExited = new ManualResetEvent(false); + _terminatedTool = false; + _toolTimeoutExpired = new ManualResetEvent(false); + + _eventsDisposed = false; + + try + { + responseFile = GetTemporaryResponseFile(responseFileCommands, out string responseFileSwitch); + + // create/initialize the process to run the tool + proc = new Process(); + proc.StartInfo = GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + + // turn on the Process.Exited event + proc.EnableRaisingEvents = true; + // sign up for the exit notification + proc.Exited += ReceiveExitNotification; + + // turn on async stderr notifications + proc.ErrorDataReceived += ReceiveStandardErrorData; + // turn on async stdout notifications + proc.OutputDataReceived += ReceiveStandardOutputData; + + // if we've got this far, we expect to get an exit code from the process. If we don't + // get one from the process, we want to use an exit code value of -1. + ExitCode = -1; + + // Start the process + proc = StartToolProcess(proc); + + // Close the input stream. This is done to prevent commands from + // blocking the build waiting for input from the user. + if (NativeMethodsShared.IsWindows) + { + proc.StandardInput.Dispose(); + } + + // Call user-provided hook for code that should execute immediately after the process starts + this.ProcessStarted(); + + // sign up for stderr callbacks + proc.BeginErrorReadLine(); + // sign up for stdout callbacks + proc.BeginOutputReadLine(); + + // start the time-out timer + _toolTimer = new Timer(ReceiveTimeoutNotification, null, Timeout, System.Threading.Timeout.Infinite /* no periodic timeouts */); + + // deal with the various notifications + HandleToolNotifications(proc); + } + finally + { + // Delete the temp file used for the response file. + if (responseFile != null) + { + DeleteTempFile(responseFile); + } + + // get the exit code and release the process handle + if (proc != null) + { + try + { + ExitCode = proc.ExitCode; + } + catch (InvalidOperationException) + { + // The process was never launched successfully. + // Leave the exit code at -1. + } + + proc.Dispose(); + proc = null; + } + + // If the tool exited cleanly, but logged errors then assign a failing exit code (-1) + if (ExitCode == 0 && HasLoggedErrors) + { + ExitCode = -1; + ExitCodeOverriddenToIndicateErrors = true; + } + + // release all the OS resources + // setting a bool to make sure tardy notification threads + // don't try to set the event after this point + lock (_eventCloseLock) + { + _eventsDisposed = true; + _standardErrorDataAvailable.Dispose(); + _standardOutputDataAvailable.Dispose(); + + _toolExited.Dispose(); + _toolTimeoutExpired.Dispose(); + + _toolTimer?.Dispose(); + } + } + + return ExitCode; + } + + /// + /// Cancels the process executing the task by asking it to close nicely, then after a short period, forcing termination. + /// + public virtual void Cancel() => ToolCanceled.Set(); + + /// + /// Delete temporary file. 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. + /// + /// File to delete + protected void DeleteTempFile(string fileName) + { + if (s_preserveTempFiles) + { + Log.LogMessageFromText($"Preserving temporary file '{fileName}'", MessageImportance.Low); + return; + } + + try + { + File.Delete(fileName); + } + catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) + { + string lockedFileMessage = LockCheck.GetLockedFileMessage(fileName); + + // 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); + } + } + + /// + /// Handles all the notifications sent while the tool is executing. The + /// notifications can be for tool output, tool time-out, or tool completion. + /// + /// + /// The slightly convoluted use of the async stderr/stdout streams of the + /// Process class is necessary because we want to log all our messages from + /// the main thread, instead of from a worker or callback thread. + /// + /// + private void HandleToolNotifications(Process proc) + { + // NOTE: the ordering of this array is deliberate -- if multiple + // notifications are sent simultaneously, we want to handle them + // in the order specified by the array, so that we can observe the + // following rules: + // 1) if a tool times-out we want to abort it immediately regardless + // of whether its stderr/stdout queues are empty + // 2) if a tool exits, we first want to flush its stderr/stdout queues + // 3) if a tool exits and times-out at the same time, we want to let + // it exit gracefully + WaitHandle[] notifications = + { + _toolTimeoutExpired, + ToolCanceled, + _standardErrorDataAvailable, + _standardOutputDataAvailable, + _toolExited + }; + + bool isToolRunning = true; + + if (YieldDuringToolExecution) + { + BuildEngine3.Yield(); + } + + try + { + while (isToolRunning) + { + // wait for something to happen -- we block the main thread here + // because we don't want to uselessly consume CPU cycles; in theory + // we could poll the stdout and stderr queues, but polling is not + // good for performance, and so we use ManualResetEvents to wake up + // the main thread only when necessary + // NOTE: the return value from WaitAny() is the array index of the + // notification that was sent; if multiple notifications are sent + // simultaneously, the return value is the index of the notification + // with the smallest index value of all the sent notifications + int notificationIndex = WaitHandle.WaitAny(notifications); + + switch (notificationIndex) + { + // tool timed-out + case 0: + // tool was canceled + case 1: + TerminateToolProcess(proc, notificationIndex == 1); + _terminatedTool = true; + isToolRunning = false; + break; + // tool wrote to stderr (and maybe stdout also) + case 2: + LogMessagesFromStandardError(); + // if stderr and stdout notifications were sent simultaneously, we + // must alternate between the queues, and not starve the stdout queue + LogMessagesFromStandardOutput(); + break; + + // tool wrote to stdout + case 3: + LogMessagesFromStandardOutput(); + break; + + // tool exited + case 4: + // We need to do this to guarantee the stderr/stdout streams + // are empty -- there seems to be no other way of telling when the + // process is done sending its async stderr/stdout notifications; why + // is the Process class sending the exit notification prematurely? + WaitForProcessExit(proc); + + // flush the stderr and stdout queues to clear out the data placed + // in them while we were waiting for the process to exit + LogMessagesFromStandardError(); + LogMessagesFromStandardOutput(); + isToolRunning = false; + break; + + default: + ErrorUtilities.ThrowInternalError("Unknown tool notification."); + break; + } + } + } + finally + { + if (YieldDuringToolExecution) + { + BuildEngine3.Reacquire(); + } + } + } + + /// + /// Kills the given process that is executing the tool, because the tool's + /// time-out period expired. + /// + private void KillToolProcessOnTimeout(Process proc, bool isBeingCancelled) + { + // kill the process if it's not finished yet + if (!proc.HasExited) + { + string processName; + try + { + processName = proc.ProcessName; + } + catch (InvalidOperationException) + { + // Process exited in the small interval since we checked HasExited + return; + } + + if (!isBeingCancelled) + { + ErrorUtilities.VerifyThrow(Timeout != System.Threading.Timeout.Infinite, + "A time-out value must have been specified or the task must be cancelled."); + + LogShared.LogWarningWithCodeFromResources("Shared.KillingProcess", processName, Timeout); + } + else + { + LogShared.LogWarningWithCodeFromResources("Shared.KillingProcessByCancellation", processName); + } + + int timeout = TaskProcessTerminationTimeout >= -1 ? TaskProcessTerminationTimeout : 5000; + string timeoutFromEnvironment = Environment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT"); + if (timeoutFromEnvironment != null) + { + if (int.TryParse(timeoutFromEnvironment, out int result) && result >= 0) + { + timeout = result; + } + } + proc.KillTree(timeout); + } + } + + /// + /// Kills the specified process + /// + private void TerminateToolProcess(Process proc, bool isBeingCancelled) + { + if (proc != null) + { + if (proc.HasExited) + { + return; + } + + if (isBeingCancelled) + { + try + { + proc.CancelOutputRead(); + proc.CancelErrorRead(); + } + catch (InvalidOperationException) + { + // The task possibly never started. + } + } + + KillToolProcessOnTimeout(proc, isBeingCancelled); + } + } + + /// + /// Confirms that the given process has really and truly exited. If the + /// process is still finishing up, this method waits until it is done. + /// + /// + /// This method is a hack, but it needs to be called after both + /// Process.WaitForExit() and Process.Kill(). + /// + /// + private static void WaitForProcessExit(Process proc) + { + proc.WaitForExit(); + + // Process.WaitForExit() may return prematurely. We need to check to be sure. + while (!proc.HasExited) + { + Thread.Sleep(50); + } + } + + /// + /// Logs all the messages that the tool wrote to stderr. The messages + /// are read out of the stderr data queue. + /// + private void LogMessagesFromStandardError() + => LogMessagesFromStandardErrorOrOutput(_standardErrorData, _standardErrorDataAvailable, _standardErrorImportanceToUse, StandardOutputOrErrorQueueType.StandardError); + + /// + /// Logs all the messages that the tool wrote to stdout. The messages + /// are read out of the stdout data queue. + /// + private void LogMessagesFromStandardOutput() + => LogMessagesFromStandardErrorOrOutput(_standardOutputData, _standardOutputDataAvailable, _standardOutputImportanceToUse, StandardOutputOrErrorQueueType.StandardOutput); + + /// + /// Logs all the messages that the tool wrote to either stderr or stdout. + /// The messages are read out of the given data queue. This method is a + /// helper for the () and () methods. + /// + /// + /// + /// + /// + private void LogMessagesFromStandardErrorOrOutput( + Queue dataQueue, + ManualResetEvent dataAvailableSignal, + MessageImportance messageImportance, + StandardOutputOrErrorQueueType queueType) + { + ErrorUtilities.VerifyThrow(dataQueue != null, + "The data queue must be available."); + + // synchronize access to the queue -- this is a producer-consumer problem + // NOTE: the synchronization problem here is actually not about the queue + // at all -- if we only cared about reading from and writing to the queue, + // we could use a synchronized wrapper around the queue, and things would + // work perfectly; the synchronization problem here is actually around the + // ManualResetEvent -- while a ManualResetEvent itself is a thread-safe + // type, the information we infer from the state of a ManualResetEvent is + // not thread-safe; because a ManualResetEvent does not have a ref count, + // we cannot safely set (or reset) it outside of a synchronization block; + // therefore instead of using synchronized queue wrappers, we just lock the + // entire queue, empty it, and reset the ManualResetEvent before releasing + // the lock; this also allows proper alternation between the stderr and + // stdout queues -- otherwise we would continuously read from one queue and + // starve the other; locking out the producer allows the consumer to + // alternate between the queues + lock (dataQueue.SyncRoot) + { + while (dataQueue.Count > 0) + { + string errorOrOutMessage = dataQueue.Dequeue() as string; + if (!LogStandardErrorAsError || queueType == StandardOutputOrErrorQueueType.StandardOutput) + { + LogEventsFromTextOutput(errorOrOutMessage, messageImportance); + } + else if (LogStandardErrorAsError && queueType == StandardOutputOrErrorQueueType.StandardError) + { + Log.LogError(errorOrOutMessage); + } + } + + ErrorUtilities.VerifyThrow(dataAvailableSignal != null, + "The signalling event must be available."); + + // the queue is empty, so reset the notification + // NOTE: intentionally, do the reset inside the lock, because + // ManualResetEvents don't have ref counts, and we want to make + // sure we don't reset the notification just after the producer + // signals it + dataAvailableSignal.Reset(); + } + } + + /// + /// Calls a method on the TaskLoggingHelper to parse a single line of text to + /// see if there are any errors or warnings in canonical format. This can + /// be overridden by the derived class if necessary. + /// + /// + /// + protected virtual void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance) => Log.LogMessageFromText(singleLine, messageImportance); + + /// + /// Signals when the tool times-out. The tool timer calls this method + /// when the time-out period on the tool expires. + /// + /// This method is used as a System.Threading.TimerCallback delegate. + /// + private void ReceiveTimeoutNotification(object unused) + { + ErrorUtilities.VerifyThrow(_toolTimeoutExpired != null, + "The signalling event for tool time-out must be available."); + lock (_eventCloseLock) + { + if (!_eventsDisposed) + { + _toolTimeoutExpired.Set(); + } + } + } + + /// + /// Signals when the tool exits. The Process object executing the tool + /// calls this method when the tool exits. + /// + /// This method is used as a System.EventHandler delegate. + /// + /// + protected void ReceiveExitNotification(object sender, EventArgs e) + { + ErrorUtilities.VerifyThrow(_toolExited != null, + "The signalling event for tool exit must be available."); + + lock (_eventCloseLock) + { + if (!_eventsDisposed) + { + _toolExited.Set(); + } + } + } + + /// + /// Queues up the output from the stderr stream of the process executing + /// the tool, and signals the availability of the data. The Process object + /// executing the tool calls this method for every line of text that the + /// tool writes to stderr. + /// + /// This method is used as a System.Diagnostics.DataReceivedEventHandler delegate. + /// + /// + protected void ReceiveStandardErrorData(object sender, DataReceivedEventArgs e) => ReceiveStandardErrorOrOutputData(e, _standardErrorData, _standardErrorDataAvailable); + + /// + /// Queues up the output from the stdout stream of the process executing + /// the tool, and signals the availability of the data. The Process object + /// executing the tool calls this method for every line of text that the + /// tool writes to stdout. + /// + /// This method is used as a System.Diagnostics.DataReceivedEventHandler delegate. + /// + /// + protected void ReceiveStandardOutputData(object sender, DataReceivedEventArgs e) => ReceiveStandardErrorOrOutputData(e, _standardOutputData, _standardOutputDataAvailable); + + /// + /// Queues up the output from either the stderr or stdout stream of the + /// process executing the tool, and signals the availability of the data. + /// This method is a helper for the () + /// and () methods. + /// + /// + /// + /// + private void ReceiveStandardErrorOrOutputData(DataReceivedEventArgs e, Queue dataQueue, ManualResetEvent dataAvailableSignal) + { + // NOTE: don't ignore empty string, because we need to log that + if (e.Data != null) + { + ErrorUtilities.VerifyThrow(dataQueue != null, + "The data queue must be available."); + + // synchronize access to the queue -- this is a producer-consumer problem + // NOTE: we lock the entire queue instead of using synchronized queue + // wrappers, because ManualResetEvents don't have ref counts, and it's + // difficult to discretely signal the availability of each instance of + // data in the queue -- so instead we let the consumer lock and empty + // the queue and reset the ManualResetEvent, before we add more data + // into the queue, and signal the ManualResetEvent again + lock (dataQueue.SyncRoot) + { + dataQueue.Enqueue(e.Data); + + ErrorUtilities.VerifyThrow(dataAvailableSignal != null, + "The signalling event must be available."); + + // signal the availability of data + // NOTE: intentionally, do the signalling inside the lock, because + // ManualResetEvents don't have ref counts, and we want to make sure + // we don't signal the notification just before the consumer resets it + lock (_eventCloseLock) + { + if (!_eventsDisposed) + { + dataAvailableSignal.Set(); + } + } + } + } + } + + /// + /// Assign the importances that will be used for stdout/stderr logging of messages from this tool task. + /// This takes into account (1 is highest precedence): + /// 1. the override value supplied as a task parameter. + /// 2. those overridden by any derived class and + /// 3. the defaults given by tooltask + /// + private bool AssignStandardStreamLoggingImportance() + { + // Gather the importance for the Standard Error stream: + if (string.IsNullOrEmpty(StandardErrorImportance)) + { + // If we have no task parameter override then ask the task for its default + _standardErrorImportanceToUse = StandardErrorLoggingImportance; + } + else + { + try + { + // Parse the raw importance string into a strongly typed enumeration. + _standardErrorImportanceToUse = (MessageImportance)Enum.Parse(typeof(MessageImportance), StandardErrorImportance, true /* case-insensitive */); + } + catch (ArgumentException) + { + Log.LogErrorWithCodeFromResources("Message.InvalidImportance", StandardErrorImportance); + return false; + } + } + + // Gather the importance for the Standard Output stream: + if (string.IsNullOrEmpty(StandardOutputImportance)) + { + // If we have no task parameter override then ask the task for its default + _standardOutputImportanceToUse = StandardOutputLoggingImportance; + } + else + { + try + { + // Parse the raw importance string into a strongly typed enumeration. + _standardOutputImportanceToUse = (MessageImportance)Enum.Parse(typeof(MessageImportance), StandardOutputImportance, true /* case-insensitive */); + } + catch (ArgumentException) + { + Log.LogErrorWithCodeFromResources("Message.InvalidImportance", StandardOutputImportance); + return false; + } + } + + return true; + } + + /// + /// Looks for the given file in the system path i.e. all locations in the %PATH% environment variable. + /// + /// + /// The location of the file, or null if file not found. + internal static string FindOnPath(string filename) + { + // Get path from the environment and split path separator + return Environment.GetEnvironmentVariable("PATH")? + .Split(MSBuildConstants.PathSeparatorChar)? + .Where(path => + { + try + { + // The PATH can contain anything, including bad characters + return FileSystems.Default.DirectoryExists(path); + } + catch (Exception) + { + return false; + } + }) + .Select(folderPath => Path.Combine(folderPath, filename)) + .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(fullPath)); + } + + #endregion + + #region ITask Members + + /// + /// This method invokes the tool with the given parameters. + /// + /// true, if task executes successfully + public override bool Execute() + { + // Let the tool validate its parameters. + if (!ValidateParameters()) + { + // The ToolTask is responsible for logging useful information about what was wrong with the + // parameters; if it didn't, at least emit a generic message. + if (!Log.HasLoggedErrors) + { + LogPrivate.LogErrorWithCodeFromResources("ToolTask.ValidateParametersFailed", this.GetType().FullName); + } + return false; + } + + if (EnvironmentVariables != null) + { + _environmentVariablePairs = new List>(EnvironmentVariables.Length); + + foreach (string entry in EnvironmentVariables) + { + string[] nameValuePair = entry.Split(s_equalsSplitter, 2); + + if (nameValuePair.Length == 1 || (nameValuePair.Length == 2 && nameValuePair[0].Length == 0)) + { + LogPrivate.LogErrorWithCodeFromResources("ToolTask.InvalidEnvironmentParameter", nameValuePair[0]); + return false; + } + + _environmentVariablePairs.Add(new KeyValuePair(nameValuePair[0], nameValuePair[1])); + } + } + + // Assign standard stream logging importances + if (!AssignStandardStreamLoggingImportance()) + { + return false; + } + + try + { + if (SkipTaskExecution()) + { + // the task has said there's no command-line that we need to run, so + // return true to indicate this task completed successfully (without + // doing any actual work). + return true; + } + else if (canBeIncremental && FailIfNotIncremental) + { + LogPrivate.LogErrorWithCodeFromResources("ToolTask.NotUpToDate"); + return false; + } + + string commandLineCommands = GenerateCommandLineCommands(); + // If there are response file commands, then we need a response file later. + string batchFileContents = commandLineCommands; + string responseFileCommands = GenerateResponseFileCommands(); + bool runningOnWindows = NativeMethodsShared.IsWindows; + + if (UseCommandProcessor) + { + if (runningOnWindows) // we are Windows + { + ToolExe = "cmd.exe"; + // Generate the temporary batch file + // May throw IO-related exceptions + _temporaryBatchFile = FileUtilities.GetTemporaryFile(".cmd"); + } + else + { + ToolExe = "/bin/sh"; + // Generate the temporary batch file + // May throw IO-related exceptions + _temporaryBatchFile = FileUtilities.GetTemporaryFile(".sh"); + } + + if (!runningOnWindows) + { + // Use sh rather than bash, as not all 'nix systems necessarily have Bash installed + File.AppendAllText(_temporaryBatchFile, "#!/bin/sh\n"); // first line for UNIX is ANSI + // This is a hack..! + File.AppendAllText(_temporaryBatchFile, AdjustCommandsForOperatingSystem(commandLineCommands), EncodingUtilities.CurrentSystemOemEncoding); + + commandLineCommands = $"\"{_temporaryBatchFile}\""; + } + else + { + Encoding encoding; + + if (Traits.Instance.EscapeHatches.AvoidUnicodeWhenWritingToolTaskBatch) + { + encoding = EncodingUtilities.CurrentSystemOemEncoding; + } + else + { + encoding = EncodingUtilities.BatchFileEncoding(commandLineCommands + _temporaryBatchFile, UseUtf8Encoding); + + if (encoding.CodePage != EncodingUtilities.CurrentSystemOemEncoding.CodePage) + { + // cmd.exe reads the first line in the console CP, + // which for a new console (as here) is OEMCP + // this string should ideally always be ASCII + // and the same in any OEMCP. + File.AppendAllText(_temporaryBatchFile, + $@"%SystemRoot%\System32\chcp.com {encoding.CodePage}>nul{Environment.NewLine}", + EncodingUtilities.CurrentSystemOemEncoding); + } + } + + File.AppendAllText(_temporaryBatchFile, commandLineCommands, encoding); + if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) + { + _encoding = encoding; + } + + string batchFileForCommandLine = _temporaryBatchFile; + + // If for some reason the path has a & character and a space in it + // then get the short path of the temp path, which should not have spaces in it + // and then escape the & + if (batchFileForCommandLine.Contains("&") && !batchFileForCommandLine.Contains("^&")) + { + batchFileForCommandLine = NativeMethodsShared.GetShortFilePath(batchFileForCommandLine); + batchFileForCommandLine = batchFileForCommandLine.Replace("&", "^&"); + } + + // /D: Do not load AutoRun configuration from the registry (perf) + commandLineCommands = $"{(Traits.Instance.EscapeHatches.UseAutoRunWhenLaunchingProcessUnderCmd ? string.Empty : "/D ")}/C \"{batchFileForCommandLine}\""; + + if (EchoOff) + { + commandLineCommands = "/Q " + commandLineCommands; + } + } + } + + // ensure the command line arguments string is not null + if (string.IsNullOrEmpty(commandLineCommands)) + { + commandLineCommands = string.Empty; + } + // add a leading space to the command line arguments (if any) to + // separate them from the tool path + else + { + commandLineCommands = " " + commandLineCommands; + } + + // Initialize the host object. At this point, the task may elect + // to not proceed. Compiler tasks do this for purposes of up-to-date + // checking in the IDE. + HostObjectInitializationStatus nextAction = InitializeHostObject(); + if (nextAction == HostObjectInitializationStatus.NoActionReturnSuccess) + { + return true; + } + else if (nextAction == HostObjectInitializationStatus.NoActionReturnFailure) + { + ExitCode = 1; + return HandleTaskExecutionErrors(); + } + + string pathToTool = ComputePathToTool(); + if (pathToTool == null) + { + // An appropriate error should have been logged already. + return false; + } + + // Log the environment. We do this up here, + // rather than later where the environment is set, + // so that it appears before the command line is logged. + bool alreadyLoggedEnvironmentHeader = false; + + // Old style environment overrides +#pragma warning disable 0618 // obsolete + Dictionary envOverrides = EnvironmentOverride; + if (envOverrides != null) + { + foreach (KeyValuePair entry in envOverrides) + { + alreadyLoggedEnvironmentHeader = LogEnvironmentVariable(alreadyLoggedEnvironmentHeader, entry.Key, entry.Value); + } +#pragma warning restore 0618 + } + + // New style environment overrides + if (_environmentVariablePairs != null) + { + foreach (KeyValuePair variable in _environmentVariablePairs) + { + alreadyLoggedEnvironmentHeader = LogEnvironmentVariable(alreadyLoggedEnvironmentHeader, variable.Key, variable.Value); + } + } + + commandLineCommands = AdjustCommandsForOperatingSystem(commandLineCommands); + responseFileCommands = AdjustCommandsForOperatingSystem(responseFileCommands); + + if (UseCommandProcessor) + { + // Log that we are about to invoke the specified command. + LogToolCommand(pathToTool + commandLineCommands); + LogToolCommand(batchFileContents); + } + else + { + // Log that we are about to invoke the specified command. + LogToolCommand(pathToTool + commandLineCommands + " " + responseFileCommands); + } + ExitCode = 0; + ExitCodeOverriddenToIndicateErrors = false; + + if (nextAction == HostObjectInitializationStatus.UseHostObjectToExecute) + { + // The hosting IDE passed in a host object to this task. Give the task + // a chance to call this host object to do the actual work. + try + { + if (!CallHostObjectToExecute()) + { + ExitCode = 1; + } + } + catch (Exception e) + { + LogPrivate.LogErrorFromException(e); + return false; + } + } + else + { + ErrorUtilities.VerifyThrow(nextAction == HostObjectInitializationStatus.UseAlternateToolToExecute, + "Invalid return status"); + + // No host object was provided, or at least not one that supports all of the + // switches/parameters we need. So shell out to the command-line tool. + ExitCode = ExecuteTool(pathToTool, responseFileCommands, commandLineCommands); + } + + // Raise a comment event to notify that the process completed + if (_terminatedTool) + { + return false; + } + else if (ExitCode != 0) + { + return HandleTaskExecutionErrors(); + } + else + { + return true; + } + } + catch (ArgumentException e) + { + if (!_terminatedTool) + { + LogPrivate.LogErrorWithCodeFromResources("General.InvalidToolSwitch", ToolExe, e.ToString()); + } + return false; + } + catch (Exception e) when (e is Win32Exception || e is IOException || e is UnauthorizedAccessException) + { + if (!_terminatedTool) + { + LogPrivate.LogErrorWithCodeFromResources("ToolTask.CouldNotStartToolExecutable", ToolExe, e.ToString()); + } + return false; + } + finally + { + // Clean up after ourselves. + if (_temporaryBatchFile != null && FileSystems.Default.FileExists(_temporaryBatchFile)) + { + DeleteTempFile(_temporaryBatchFile); + } + } + } // Execute() + + /// + /// Replace backslashes with OS-specific path separators, + /// except when likely that the backslash is intentional. + /// + /// + /// Not a static method so that an implementation can + /// override with more-specific knowledge of what backslashes + /// are likely to be correct. + /// + protected virtual string AdjustCommandsForOperatingSystem(string input) + { + if (NativeMethodsShared.IsWindows) + { + return input; + } + + StringBuilder sb = new StringBuilder(input); + + int length = sb.Length; + + for (int i = 0; i < length; i++) + { + // Backslashes must be swapped, because we don't + // know what inputs are paths or path fragments. + // But it's a common pattern to have backslash-escaped + // quotes inside quotes--especially for VB that has a default like + // + // /define:"CONFIG=\"Debug\",DEBUG=-1,TRACE=-1,_MyType=\"Console\",PLATFORM=\"AnyCPU\"" + // + // So don't replace a backslash immediately + // followed by a quote. + if (sb[i] == '\\' && (i == length - 1 || sb[i + 1] != '"')) + { + sb[i] = Path.DirectorySeparatorChar; + } + } + + return sb.ToString(); + } + + /// + /// Log a single environment variable that's about to be applied to the tool + /// + private bool LogEnvironmentVariable(bool alreadyLoggedEnvironmentHeader, string key, string value) + { + if (!alreadyLoggedEnvironmentHeader) + { + LogPrivate.LogMessageFromResources(MessageImportance.Low, "ToolTask.EnvironmentVariableHeader"); + alreadyLoggedEnvironmentHeader = true; + } + + Log.LogMessage(MessageImportance.Low, " {0}={1}", key, value); + + return alreadyLoggedEnvironmentHeader; + } + + #endregion + + #region Member data + + /// + /// An object to hold the event shutdown lock + /// + private readonly LockType _eventCloseLock = new LockType(); + + /// + /// Splitter for environment variables + /// + private static readonly char[] s_equalsSplitter = MSBuildConstants.EqualsChar; + + /// + /// The actual importance at which standard out messages will be logged + /// + private MessageImportance _standardOutputImportanceToUse = MessageImportance.Low; + + /// + /// The actual importance at which standard error messages will be logged + /// + private MessageImportance _standardErrorImportanceToUse = MessageImportance.Normal; + + /// + /// Holds the stderr output from the tool. + /// + /// This collection is NOT thread-safe. + private Queue _standardErrorData; + + /// + /// Holds the stdout output from the tool. + /// + /// This collection is NOT thread-safe. + private Queue _standardOutputData; + + /// + /// Used for signalling when the tool writes to stderr. + /// + private ManualResetEvent _standardErrorDataAvailable; + + /// + /// Used for signalling when the tool writes to stdout. + /// + private ManualResetEvent _standardOutputDataAvailable; + + /// + /// Used for signalling when the tool exits. + /// + private ManualResetEvent _toolExited; + + /// + /// Set to true if the tool process was terminated, + /// either because the timeout was reached or it was canceled. + /// + private bool _terminatedTool; + + /// + /// Used for signalling when the tool times-out. + /// + private ManualResetEvent _toolTimeoutExpired; + + /// + /// Used for timing-out the tool. + /// + private Timer _toolTimer; + + /// + /// Used to support overriding the toolExe name. + /// + private string _toolExe; + + /// + /// Set when the events are about to be disposed, so that tardy + /// calls on the event handlers don't try to reset a disposed event + /// + private bool _eventsDisposed; + + /// + /// List of name, value pairs to be passed to the spawned tool's environment. + /// May be null. + /// + private List> _environmentVariablePairs; + + /// + /// Enumeration which indicates what kind of queue is being passed + /// + private enum StandardOutputOrErrorQueueType + { + StandardError = 0, + StandardOutput = 1 + } + + #endregion + } +} diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index 40a4e0b2409..c94a4897857 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -634,12 +634,22 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string commandLineCommands, string responseFileSwitch) { - ProcessStartInfo startInfo = CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + ProcessStartInfo startInfo = new ProcessStartInfo(); + + // Generally we won't set a working directory, and it will use the current directory + string workingDirectory = GetWorkingDirectory(); + if (workingDirectory != null) + { + startInfo.WorkingDirectory = workingDirectory; + } + + SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); ApplyEnvironmentOverrides(startInfo); return startInfo; } - private ProcessStartInfo CreateBaseProcessStartInfo( + private void SetUpProcessStartInfo( + ProcessStartInfo startInfo, string pathToTool, string commandLineCommands, string responseFileSwitch) @@ -667,7 +677,8 @@ private ProcessStartInfo CreateBaseProcessStartInfo( LogPrivate.LogWarningWithCodeFromResources("ToolTask.CommandTooLong", GetType().Name); } - ProcessStartInfo startInfo = new ProcessStartInfo(pathToTool, commandLine); + startInfo.FileName = pathToTool; + startInfo.Arguments = commandLine; startInfo.CreateNoWindow = true; startInfo.UseShellExecute = false; startInfo.RedirectStandardError = true; @@ -683,15 +694,6 @@ private ProcessStartInfo CreateBaseProcessStartInfo( // the program terminates very fast. startInfo.RedirectStandardInput = true; } - - // Generally we won't set a working directory, and it will use the current directory - string workingDirectory = GetWorkingDirectory(); - if (workingDirectory != null) - { - startInfo.WorkingDirectory = workingDirectory; - } - - return startInfo; } /// @@ -744,23 +746,17 @@ protected ProcessStartInfo GetProcessStartInfoMultithreadable( string responseFileSwitch, TaskEnvironment taskEnvironment) { - // Call the non-virtual helper to get base ProcessStartInfo with all ToolTask settings - // (command line, redirections, encodings, etc.). Using CreateBaseProcessStartInfo instead - // of the virtual GetProcessStartInfo avoids infinite recursion when a subclass overrides - // GetProcessStartInfo to route through this method. - ProcessStartInfo startInfo = CreateBaseProcessStartInfo(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(); + ProcessStartInfo startInfo = new ProcessStartInfo(); + + startInfo.WorkingDirectory = taskEnvironment.ProjectDirectory; + startInfo.Environment.Clear(); - foreach (var kvp in taskEnvStartInfo.Environment) + foreach (var kvp in taskEnvironment.GetEnvironmentVariables()) { startInfo.Environment[kvp.Key] = kvp.Value; } - startInfo.WorkingDirectory = taskEnvStartInfo.WorkingDirectory; + SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); // Apply task-level environment overrides — they should take precedence over TaskEnvironment. ApplyEnvironmentOverrides(startInfo); From cfd690eb4f43b67023b6a80c1bacd446bf5877d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Provazn=C3=ADk?= Date: Tue, 24 Mar 2026 14:13:05 +0100 Subject: [PATCH 06/19] Delete ToolTask_original.txt --- ToolTask_original.txt | 1855 ----------------------------------------- 1 file changed, 1855 deletions(-) delete mode 100644 ToolTask_original.txt diff --git a/ToolTask_original.txt b/ToolTask_original.txt deleted file mode 100644 index 4fd8a2320fe..00000000000 --- a/ToolTask_original.txt +++ /dev/null @@ -1,1855 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Resources; -using System.Text; -using System.Threading; -using Microsoft.Build.Framework; -using Microsoft.Build.Shared; -using Microsoft.Build.Shared.FileSystem; - -#nullable disable - -namespace Microsoft.Build.Utilities -{ - /// - /// The return value from InitializeHostObject. This enumeration defines what action the ToolTask - /// should take next, after we've tried to initialize the host object. - /// - public enum HostObjectInitializationStatus - { - /// - /// This means that there exists an appropriate host object for this task, it can support - /// all of the parameters passed in, and it should be invoked to do the real work of the task. - /// - UseHostObjectToExecute, - - /// - /// This means that either there is no host object available, or that the host object is - /// not capable of supporting all of the features required for this build. Therefore, - /// ToolTask should fallback to an alternate means of doing the build, such as invoking - /// the command-line tool. - /// - UseAlternateToolToExecute, - - /// - /// This means that the host object is already up-to-date, and no further action is necessary. - /// - NoActionReturnSuccess, - - /// - /// This means that some of the parameters being passed into the task are invalid, and the - /// task should fail immediately. - /// - NoActionReturnFailure - } - - /// - /// Base class used for tasks that spawn an executable. This class implements the ToolPath property which can be used to - /// override the default path. - /// - // 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 - { - private static readonly bool s_preserveTempFiles = string.Equals(Environment.GetEnvironmentVariable("MSBUILDPRESERVETOOLTEMPFILES"), "1", StringComparison.Ordinal); - - #region Constructors - - /// - /// Protected constructor - /// - protected ToolTask() - { - LogPrivate = new TaskLoggingHelper(this) - { - TaskResources = AssemblyResources.PrimaryResources, - HelpKeywordPrefix = "MSBuild." - }; - - LogShared = new TaskLoggingHelper(this) - { - TaskResources = AssemblyResources.SharedResources, - HelpKeywordPrefix = "MSBuild." - }; - - // 5 second is the default termination timeout. - TaskProcessTerminationTimeout = 5000; - ToolCanceled = new ManualResetEvent(false); - } - - /// - /// Protected constructor - /// - /// The resource manager for task resources - protected ToolTask(ResourceManager taskResources) - : this() - { - TaskResources = taskResources; - } - - /// - /// Protected constructor - /// - /// The resource manager for task resources - /// The help keyword prefix for task's messages - protected ToolTask(ResourceManager taskResources, string helpKeywordPrefix) - : this(taskResources) - { - HelpKeywordPrefix = helpKeywordPrefix; - } - - #endregion - - #region Properties - - /// - /// The return code of the spawned process. If the task logged any errors, but the process - /// had an exit code of 0 (success), this will be set to -1. - /// - [Output] - public int ExitCode { get; private set; } - - /// - /// True when ExitCode was overridden from 0 to -1 because the task logged errors - /// despite the tool reporting success (exit code 0). - /// - protected bool ExitCodeOverriddenToIndicateErrors { get; private set; } - - /// - /// When set to true, this task will yield the node when its task is executing. - /// - public bool YieldDuringToolExecution { get; set; } - - /// - /// When set to true, the tool task will create a batch file for the command-line and execute that using the command-processor, - /// rather than executing the command directly. - /// - public bool UseCommandProcessor { get; set; } - - /// - /// When set to true, it passes /Q to the cmd.exe command line such that the command line does not get echo-ed on stdout - /// - public bool EchoOff { get; set; } - - /// - /// A timeout to wait for a task to terminate before killing it. In milliseconds. - /// - protected int TaskProcessTerminationTimeout { get; set; } - - /// - /// Used to signal when a tool has been cancelled. - /// - protected ManualResetEvent ToolCanceled { get; private set; } - - /// - /// This is the batch file created when UseCommandProcessor is set to true. - /// - private string _temporaryBatchFile; - - /// - /// The encoding set to the console code page. - /// - private Encoding _encoding; - - /// - /// Implemented by the derived class. Returns a string which is the name of the underlying .EXE to run e.g. "resgen.exe" - /// Only used by the ToolExe getter. - /// - /// Name of tool. - protected abstract string ToolName { get; } - - /// - /// Projects may set this to override a task's ToolName. - /// Tasks may override this to prevent that. - /// - public virtual string ToolExe - { - get - { - if (!string.IsNullOrEmpty(_toolExe)) - { - // If the ToolExe has been overridden then return the value - return _toolExe; - } - else - { - // We have no override, so simply delegate to ToolName - return ToolName; - } - } - set => _toolExe = value; - } - - /// - /// Project-visible property allows the user to override the path to the executable. - /// - /// Path to tool. - public string ToolPath { set; get; } - - /// - /// Whether or not to use UTF8 encoding for the cmd file and console window. - /// Values: Always, Never, Detect - /// If set to Detect, the current code page will be used unless it cannot represent - /// the Command string. In that case, UTF-8 is used. - /// - public string UseUtf8Encoding { get; set; } = EncodingUtilities.UseUtf8Detect; - - /// - /// Array of equals-separated pairs of environment - /// variables that should be passed to the spawned executable, - /// in addition to (or selectively overriding) the regular environment block. - /// - /// - /// Using this instead of EnvironmentOverride as that takes a Dictionary, - /// which cannot be set from an MSBuild project. - /// - public string[] EnvironmentVariables { get; set; } - - /// - /// Project visible property that allows the user to specify an amount of time after which the task executable - /// is terminated. - /// - /// Time-out in milliseconds. Default is (no time-out). - public virtual int Timeout { set; get; } = System.Threading.Timeout.Infinite; - - /// - /// Overridable property specifying the encoding of the response file, UTF8 by default - /// - protected virtual Encoding ResponseFileEncoding => Encoding.UTF8; - - /// - /// Overridable method to escape content of the response file - /// - [SuppressMessage("Microsoft.Naming", "CA1720:IdentifiersShouldNotContainTypeNames", MessageId = "string", Justification = "Shipped this way in Dev11 Beta (go-live)")] - protected virtual string ResponseFileEscape(string responseString) => responseString; - - /// - /// Overridable property specifying the encoding of the captured task standard output stream - /// - /// - /// Console-based output uses the current system OEM code page by default. Note that we should not use Console.OutputEncoding - /// here since processes we run don't really have much to do with our console window (and also Console.OutputEncoding - /// doesn't return the OEM code page if the running application that hosts MSBuild is not a console application). - /// - protected virtual Encoding StandardOutputEncoding - { - get - { - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) - { - if (_encoding != null) - { - // Keep the encoding of standard output & error consistent with the console code page. - return _encoding; - } - } - return EncodingUtilities.CurrentSystemOemEncoding; - } - } - - /// - /// Overridable property specifying the encoding of the captured task standard error stream - /// - /// - /// Console-based output uses the current system OEM code page by default. Note that we should not use Console.OutputEncoding - /// here since processes we run don't really have much to do with our console window (and also Console.OutputEncoding - /// doesn't return the OEM code page if the running application that hosts MSBuild is not a console application). - /// - protected virtual Encoding StandardErrorEncoding - { - get - { - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) - { - if (_encoding != null) - { - // Keep the encoding of standard output & error consistent with the console code page. - return _encoding; - } - } - return EncodingUtilities.CurrentSystemOemEncoding; - } - } - - /// - /// Gets the Path override value. - /// - /// The new value for the Environment for the task. - [Obsolete("Use EnvironmentVariables property")] - protected virtual Dictionary EnvironmentOverride => null; - - /// - /// Importance with which to log text from the - /// standard error stream. - /// - protected virtual MessageImportance StandardErrorLoggingImportance => MessageImportance.Normal; - - /// - /// Whether this ToolTask has logged any errors - /// - protected virtual bool HasLoggedErrors => Log.HasLoggedErrors || LogPrivate.HasLoggedErrors || LogShared.HasLoggedErrors; - - /// - /// Task Parameter: Importance with which to log text from the - /// standard out stream. - /// - public string StandardOutputImportance { get; set; } = null; - - /// - /// Task Parameter: Importance with which to log text from the - /// standard error stream. - /// - public string StandardErrorImportance { get; set; } = null; - - /// - /// Should ALL messages received on the standard error stream be logged as errors. - /// - public bool LogStandardErrorAsError { get; set; } = false; - - /// - /// Importance with which to log text from in the - /// standard out stream. - /// - protected virtual MessageImportance StandardOutputLoggingImportance => MessageImportance.Low; - - /// - /// The actual importance at which standard out messages will be logged. - /// - protected MessageImportance StandardOutputImportanceToUse => _standardOutputImportanceToUse; - - /// - /// The actual importance at which standard error messages will be logged. - /// - protected MessageImportance StandardErrorImportanceToUse => _standardErrorImportanceToUse; - - #endregion - - #region Private properties - - /// - /// Gets an instance of a private TaskLoggingHelper class containing task logging methods. - /// This is necessary because ToolTask lives in a different assembly than the task inheriting from it - /// and needs its own separate resources. - /// - /// The logging helper object. - private TaskLoggingHelper LogPrivate { get; } - - // the private logging helper - - /// - /// Gets an instance of a shared resources TaskLoggingHelper class containing task logging methods. - /// This is necessary because ToolTask lives in a different assembly than the task inheriting from it - /// and needs its own separate resources. - /// - /// The logging helper object. - private TaskLoggingHelper LogShared { get; } - - // the shared resources logging helper - - #endregion - - #region Overridable methods - - /// - /// Overridable function called after in - /// - protected virtual void ProcessStarted() { } - - /// - /// Gets the fully qualified tool name. Should return ToolExe if ToolTask should search for the tool - /// in the system path. If ToolPath is set, this is ignored. - /// - /// Path string. - protected abstract string GenerateFullPathToTool(); - - /// - /// Gets the working directory to use for the process. Should return null if ToolTask should use the - /// current directory. - /// - /// This is a method rather than a property so that derived classes (like Exec) can choose to - /// expose a public WorkingDirectory property, and it would be confusing to have two properties. - /// - protected virtual string GetWorkingDirectory() => null; - - /// - /// Implemented in the derived class - /// - /// true, if successful - protected internal virtual bool ValidateParameters() - { - if (TaskProcessTerminationTimeout < -1) - { - Log.LogWarningWithCodeFromResources("ToolTask.InvalidTerminationTimeout", TaskProcessTerminationTimeout); - return false; - } - - return true; - } - - /// - /// Returns true if task execution is not necessary. Executed after ValidateParameters - /// - /// - protected virtual bool SkipTaskExecution() { canBeIncremental = false; return false; } - - /// - /// ToolTask is not incremental by default. When a derived class overrides SkipTaskExecution, then Question feature can take into effect. - /// - protected bool canBeIncremental { get; set; } = true; - - public bool FailIfNotIncremental { get; set; } - - /// - /// Returns a string with those switches and other information that can go into a response file. - /// Called after ValidateParameters and SkipTaskExecution - /// - /// - protected virtual string GenerateResponseFileCommands() => string.Empty; // Default is nothing. This is useful for tools that don't need or support response files. - - /// - /// Returns a string with those switches and other information that can't go into a response file and - /// must go directly onto the command line. - /// Called after ValidateParameters and SkipTaskExecution - /// - /// - protected virtual string GenerateCommandLineCommands() => string.Empty; // Default is nothing. This is useful for tools where all the parameters can go into a response file. - - /// - /// Returns the command line switch used by the tool executable to specify the response file. - /// Will only be called if the task returned a non empty string from GetResponseFileCommands - /// Called after ValidateParameters, SkipTaskExecution and GetResponseFileCommands - /// - /// full path to the temporarily created response file - /// - protected virtual string GetResponseFileSwitch(string responseFilePath) => "@\"" + responseFilePath + "\""; // by default, return @"" - - /// - /// Allows tool to handle the return code. - /// This method will only be called with non-zero exitCode. - /// - /// The return value of this method will be used as the task return value - protected virtual bool HandleTaskExecutionErrors() - { - Debug.Assert(ExitCode != 0, "HandleTaskExecutionErrors should only be called if there were problems executing the task"); - - if (HasLoggedErrors) - { - if (ExitCodeOverriddenToIndicateErrors) - { - // The tool finished with a zero exit code but errors were logged, causing ExitCode to be set to -1. - LogPrivate.LogMessageFromResources(MessageImportance.Low, "ToolTask.ToolCommandExitedZeroWithErrors"); - } - else - { - // The tool failed with a non-zero exit code and already logged its own errors. - LogPrivate.LogMessageFromResources(MessageImportance.Low, "General.ToolCommandFailedNoErrorCode", ExitCode); - } - } - else - { - // If the tool itself did not log any errors on its own, then we log one now simply saying - // that the tool exited with a non-zero exit code. This way, the customer nevers sees - // "Build failed" without at least one error being logged. - LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolCommandFailed", ToolExe, ExitCode); - } - - // by default, always fail the task - return false; - } - - /// - /// We expect the tasks to override this method, if they support host objects. The implementation should call into the - /// host object to perform the real work of the task. For example, for compiler tasks like Csc and Vbc, this method would - /// call Compile() on the host object. - /// - /// The return value indicates success (true) or failure (false) if the host object was actually called to do the work. - protected virtual bool CallHostObjectToExecute() => false; - - /// - /// We expect tasks to override this method if they support host objects. The implementation should - /// make sure that the host object is ready to perform the real work of the task. - /// - /// The return value indicates what steps to take next. The default is to assume that there - /// is no host object provided, and therefore we should fallback to calling the command-line tool. - protected virtual HostObjectInitializationStatus InitializeHostObject() => HostObjectInitializationStatus.UseAlternateToolToExecute; - - /// - /// Logs the actual command line about to be executed (or what the task wants the log to show) - /// - /// - /// Descriptive message about what is happening - usually the command line to be executed. - /// - protected virtual void LogToolCommand(string message) => LogPrivate.LogCommandLine(MessageImportance.High, message); // Log a descriptive message about what's happening. - - /// - /// Logs the tool name and the path from where it is being run. - /// - /// - /// The tool to Log. This is the actual tool being used, ie. if ToolExe has been specified it will be used, otherwise it will be ToolName - /// - /// - /// The path from where the tool is being run. - /// - protected virtual void LogPathToTool(string toolName, string pathToTool) - { - // We don't do anything here any more, as it was just duplicative and noise. - // The method only remains for backwards compatibility - to avoid breaking tasks that override it - } - - #endregion - - #region Methods - - /// - /// Figures out the path to the tool (including the .exe), either by using the ToolPath - /// parameter, or by asking the derived class to tell us where it should be located. - /// - /// path to the tool, or null - private string ComputePathToTool() - { - string pathToTool = null; - - if (UseCommandProcessor) - { - return ToolExe; - } - - if (!string.IsNullOrEmpty(ToolPath)) - { - // If the project author passed in a ToolPath, always use that. - pathToTool = Path.Combine(ToolPath, ToolExe); - } - - if (string.IsNullOrWhiteSpace(pathToTool) || (ToolPath == null && !FileSystems.Default.FileExists(pathToTool))) - { - // Otherwise, try to find the tool ourselves. - pathToTool = GenerateFullPathToTool(); - - // We have no toolpath, but we have been given an override - // for the tool exe, fix up the path, assuming that the tool is in the same location - if (pathToTool != null && !string.IsNullOrEmpty(_toolExe)) - { - string directory = Path.GetDirectoryName(pathToTool); - pathToTool = Path.Combine(directory, ToolExe); - } - } - - // only look for the file if we have a path to it. If we have just the file name, we'll - // look for it in the path - if (pathToTool != null) - { - bool isOnlyFileName = Path.GetFileName(pathToTool).Length == pathToTool.Length; - if (!isOnlyFileName) - { - bool isExistingFile = FileSystems.Default.FileExists(pathToTool); - if (!isExistingFile) - { - LogPrivate.LogErrorWithCodeFromResources("ToolTask.ToolExecutableNotFound", pathToTool); - return null; - } - } - else - { - // if we just have the file name, search for the file on the system path - string actualPathToTool = FindOnPath(pathToTool); - - // if we find the file - if (actualPathToTool != null) - { - // point to it - pathToTool = actualPathToTool; - } - else - { - // if we cannot find the file, we'll probably error out later on when - // we try to launch the tool; so do nothing for now - } - } - } - - return pathToTool; - } - - /// - /// Creates a temporary response file for the given command line arguments. - /// We put as many command line arguments as we can into a response file to - /// prevent the command line from getting too long. An overly long command - /// line can cause the process creation to fail. - /// - /// - /// Command line arguments that cannot be put into response files, and which - /// must appear on the command line, should not be passed to this method. - /// - /// The command line arguments that need - /// to go into the temporary response file. - /// [out] The command line switch for using - /// the temporary response file, or null if the response file is not needed. - /// - /// The path to the temporary response file, or null if the response - /// file is not needed. - private string GetTemporaryResponseFile(string responseFileCommands, out string responseFileSwitch) - { - string responseFile = null; - responseFileSwitch = null; - - // if this tool supports response files - if (!string.IsNullOrEmpty(responseFileCommands)) - { - // put all the parameters into a temporary response file so we don't - // have to worry about how long the command-line is going to be - - // May throw IO-related exceptions - responseFile = FileUtilities.GetTemporaryFileName(".rsp"); - - // Use the encoding specified by the overridable ResponseFileEncoding property - using (StreamWriter responseFileStream = FileUtilities.OpenWrite(responseFile, false, ResponseFileEncoding)) - { - responseFileStream.Write(ResponseFileEscape(responseFileCommands)); - } - - responseFileSwitch = GetResponseFileSwitch(responseFile); - } - - return responseFile; - } - - /// - /// Initializes the information required to spawn the process executing the tool. - /// - /// - /// - /// - /// The information required to start the process. - protected virtual ProcessStartInfo GetProcessStartInfo( - string pathToTool, - string commandLineCommands, - string responseFileSwitch) - { - return CreateBaseProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); - } - - private ProcessStartInfo CreateBaseProcessStartInfo( - string pathToTool, - string commandLineCommands, - string responseFileSwitch) - { - // Build up the command line that will be spawned. - string commandLine = commandLineCommands; - - if (!UseCommandProcessor) - { - if (!string.IsNullOrEmpty(responseFileSwitch)) - { - commandLine += " " + responseFileSwitch; - } - } - - // If the command is too long, it will most likely fail. The command line - // arguments passed into any process cannot exceed 32768 characters, but - // depending on the structure of the command (e.g. if it contains embedded - // environment variables that will be expanded), longer commands might work, - // or shorter commands might fail -- to play it safe, we warn at 32000. - // NOTE: cmd.exe has a buffer limit of 8K, but we're not using cmd.exe here, - // so we can go past 8K easily. - if (commandLine.Length > 32000) - { - LogPrivate.LogWarningWithCodeFromResources("ToolTask.CommandTooLong", GetType().Name); - } - - ProcessStartInfo startInfo = new ProcessStartInfo(pathToTool, commandLine); - startInfo.CreateNoWindow = true; - startInfo.UseShellExecute = false; - startInfo.RedirectStandardError = true; - startInfo.RedirectStandardOutput = true; - // ensure the redirected streams have the encoding we want - startInfo.StandardErrorEncoding = StandardErrorEncoding; - startInfo.StandardOutputEncoding = StandardOutputEncoding; - - if (NativeMethodsShared.IsWindows) - { - // Some applications such as xcopy.exe fail without error if there's no stdin stream. - // We only do it under Windows, we get Pipe Broken IO exception on other systems if - // the program terminates very fast. - startInfo.RedirectStandardInput = true; - } - - // Generally we won't set a working directory, and it will use the current directory - string workingDirectory = GetWorkingDirectory(); - if (workingDirectory != null) - { - startInfo.WorkingDirectory = workingDirectory; - } - - // Old style environment overrides -#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 - } - - // New style environment overrides - if (_environmentVariablePairs != null) - { - foreach (KeyValuePair variable in _environmentVariablePairs) - { - startInfo.Environment[variable.Key] = variable.Value; - } - } - - return startInfo; - } - - protected ProcessStartInfo GetProcessStartInfoMultiThreaded( - string pathToTool, - string commandLineCommands, - string responseFileSwitch, - TaskEnvironment taskEnvironment) - { - // Call the non-virtual helper to get base ProcessStartInfo with all ToolTask settings - // (command line, redirections, encodings, etc.). Using CreateBaseProcessStartInfo instead - // of the virtual GetProcessStartInfo avoids infinite recursion when a subclass overrides - // GetProcessStartInfo to route through this method. - ProcessStartInfo startInfo = CreateBaseProcessStartInfo(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; - } - - string workingDirectory = taskEnvStartInfo.WorkingDirectory; - if (workingDirectory != null) - { - startInfo.WorkingDirectory = workingDirectory; - } - - // 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; - } - - /// - /// We expect tasks to override this method if they need information about the tool process or its process events during task execution. - /// Implementation should make sure that the task is started in this method. - /// Starts the process during task execution. - /// - /// Fully populated instance representing the tool process to be started. - /// A started process. This could be or another instance. - protected virtual Process StartToolProcess(Process proc) - { - proc.Start(); - return proc; - } - - /// - /// Writes out a temporary response file and shell-executes the tool requested. Enables concurrent - /// logging of the output of the tool. - /// - /// The computed path to tool executable on disk - /// Command line arguments that should go into a temporary response file - /// Command line arguments that should be passed to the tool executable directly - /// exit code from the tool - if errors were logged and the tool has an exit code of zero, then we sit it to -1 - protected virtual int ExecuteTool( - string pathToTool, - string responseFileCommands, - string commandLineCommands) - { - if (!UseCommandProcessor) - { - LogPathToTool(ToolExe, pathToTool); - } - - string responseFile = null; - Process proc = null; - - _standardErrorData = new Queue(); - _standardOutputData = new Queue(); - - _standardErrorDataAvailable = new ManualResetEvent(false); - _standardOutputDataAvailable = new ManualResetEvent(false); - - _toolExited = new ManualResetEvent(false); - _terminatedTool = false; - _toolTimeoutExpired = new ManualResetEvent(false); - - _eventsDisposed = false; - - try - { - responseFile = GetTemporaryResponseFile(responseFileCommands, out string responseFileSwitch); - - // create/initialize the process to run the tool - proc = new Process(); - proc.StartInfo = GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); - - // turn on the Process.Exited event - proc.EnableRaisingEvents = true; - // sign up for the exit notification - proc.Exited += ReceiveExitNotification; - - // turn on async stderr notifications - proc.ErrorDataReceived += ReceiveStandardErrorData; - // turn on async stdout notifications - proc.OutputDataReceived += ReceiveStandardOutputData; - - // if we've got this far, we expect to get an exit code from the process. If we don't - // get one from the process, we want to use an exit code value of -1. - ExitCode = -1; - - // Start the process - proc = StartToolProcess(proc); - - // Close the input stream. This is done to prevent commands from - // blocking the build waiting for input from the user. - if (NativeMethodsShared.IsWindows) - { - proc.StandardInput.Dispose(); - } - - // Call user-provided hook for code that should execute immediately after the process starts - this.ProcessStarted(); - - // sign up for stderr callbacks - proc.BeginErrorReadLine(); - // sign up for stdout callbacks - proc.BeginOutputReadLine(); - - // start the time-out timer - _toolTimer = new Timer(ReceiveTimeoutNotification, null, Timeout, System.Threading.Timeout.Infinite /* no periodic timeouts */); - - // deal with the various notifications - HandleToolNotifications(proc); - } - finally - { - // Delete the temp file used for the response file. - if (responseFile != null) - { - DeleteTempFile(responseFile); - } - - // get the exit code and release the process handle - if (proc != null) - { - try - { - ExitCode = proc.ExitCode; - } - catch (InvalidOperationException) - { - // The process was never launched successfully. - // Leave the exit code at -1. - } - - proc.Dispose(); - proc = null; - } - - // If the tool exited cleanly, but logged errors then assign a failing exit code (-1) - if (ExitCode == 0 && HasLoggedErrors) - { - ExitCode = -1; - ExitCodeOverriddenToIndicateErrors = true; - } - - // release all the OS resources - // setting a bool to make sure tardy notification threads - // don't try to set the event after this point - lock (_eventCloseLock) - { - _eventsDisposed = true; - _standardErrorDataAvailable.Dispose(); - _standardOutputDataAvailable.Dispose(); - - _toolExited.Dispose(); - _toolTimeoutExpired.Dispose(); - - _toolTimer?.Dispose(); - } - } - - return ExitCode; - } - - /// - /// Cancels the process executing the task by asking it to close nicely, then after a short period, forcing termination. - /// - public virtual void Cancel() => ToolCanceled.Set(); - - /// - /// Delete temporary file. 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. - /// - /// File to delete - protected void DeleteTempFile(string fileName) - { - if (s_preserveTempFiles) - { - Log.LogMessageFromText($"Preserving temporary file '{fileName}'", MessageImportance.Low); - return; - } - - try - { - File.Delete(fileName); - } - catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) - { - string lockedFileMessage = LockCheck.GetLockedFileMessage(fileName); - - // 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); - } - } - - /// - /// Handles all the notifications sent while the tool is executing. The - /// notifications can be for tool output, tool time-out, or tool completion. - /// - /// - /// The slightly convoluted use of the async stderr/stdout streams of the - /// Process class is necessary because we want to log all our messages from - /// the main thread, instead of from a worker or callback thread. - /// - /// - private void HandleToolNotifications(Process proc) - { - // NOTE: the ordering of this array is deliberate -- if multiple - // notifications are sent simultaneously, we want to handle them - // in the order specified by the array, so that we can observe the - // following rules: - // 1) if a tool times-out we want to abort it immediately regardless - // of whether its stderr/stdout queues are empty - // 2) if a tool exits, we first want to flush its stderr/stdout queues - // 3) if a tool exits and times-out at the same time, we want to let - // it exit gracefully - WaitHandle[] notifications = - { - _toolTimeoutExpired, - ToolCanceled, - _standardErrorDataAvailable, - _standardOutputDataAvailable, - _toolExited - }; - - bool isToolRunning = true; - - if (YieldDuringToolExecution) - { - BuildEngine3.Yield(); - } - - try - { - while (isToolRunning) - { - // wait for something to happen -- we block the main thread here - // because we don't want to uselessly consume CPU cycles; in theory - // we could poll the stdout and stderr queues, but polling is not - // good for performance, and so we use ManualResetEvents to wake up - // the main thread only when necessary - // NOTE: the return value from WaitAny() is the array index of the - // notification that was sent; if multiple notifications are sent - // simultaneously, the return value is the index of the notification - // with the smallest index value of all the sent notifications - int notificationIndex = WaitHandle.WaitAny(notifications); - - switch (notificationIndex) - { - // tool timed-out - case 0: - // tool was canceled - case 1: - TerminateToolProcess(proc, notificationIndex == 1); - _terminatedTool = true; - isToolRunning = false; - break; - // tool wrote to stderr (and maybe stdout also) - case 2: - LogMessagesFromStandardError(); - // if stderr and stdout notifications were sent simultaneously, we - // must alternate between the queues, and not starve the stdout queue - LogMessagesFromStandardOutput(); - break; - - // tool wrote to stdout - case 3: - LogMessagesFromStandardOutput(); - break; - - // tool exited - case 4: - // We need to do this to guarantee the stderr/stdout streams - // are empty -- there seems to be no other way of telling when the - // process is done sending its async stderr/stdout notifications; why - // is the Process class sending the exit notification prematurely? - WaitForProcessExit(proc); - - // flush the stderr and stdout queues to clear out the data placed - // in them while we were waiting for the process to exit - LogMessagesFromStandardError(); - LogMessagesFromStandardOutput(); - isToolRunning = false; - break; - - default: - ErrorUtilities.ThrowInternalError("Unknown tool notification."); - break; - } - } - } - finally - { - if (YieldDuringToolExecution) - { - BuildEngine3.Reacquire(); - } - } - } - - /// - /// Kills the given process that is executing the tool, because the tool's - /// time-out period expired. - /// - private void KillToolProcessOnTimeout(Process proc, bool isBeingCancelled) - { - // kill the process if it's not finished yet - if (!proc.HasExited) - { - string processName; - try - { - processName = proc.ProcessName; - } - catch (InvalidOperationException) - { - // Process exited in the small interval since we checked HasExited - return; - } - - if (!isBeingCancelled) - { - ErrorUtilities.VerifyThrow(Timeout != System.Threading.Timeout.Infinite, - "A time-out value must have been specified or the task must be cancelled."); - - LogShared.LogWarningWithCodeFromResources("Shared.KillingProcess", processName, Timeout); - } - else - { - LogShared.LogWarningWithCodeFromResources("Shared.KillingProcessByCancellation", processName); - } - - int timeout = TaskProcessTerminationTimeout >= -1 ? TaskProcessTerminationTimeout : 5000; - string timeoutFromEnvironment = Environment.GetEnvironmentVariable("MSBUILDTOOLTASKCANCELPROCESSWAITTIMEOUT"); - if (timeoutFromEnvironment != null) - { - if (int.TryParse(timeoutFromEnvironment, out int result) && result >= 0) - { - timeout = result; - } - } - proc.KillTree(timeout); - } - } - - /// - /// Kills the specified process - /// - private void TerminateToolProcess(Process proc, bool isBeingCancelled) - { - if (proc != null) - { - if (proc.HasExited) - { - return; - } - - if (isBeingCancelled) - { - try - { - proc.CancelOutputRead(); - proc.CancelErrorRead(); - } - catch (InvalidOperationException) - { - // The task possibly never started. - } - } - - KillToolProcessOnTimeout(proc, isBeingCancelled); - } - } - - /// - /// Confirms that the given process has really and truly exited. If the - /// process is still finishing up, this method waits until it is done. - /// - /// - /// This method is a hack, but it needs to be called after both - /// Process.WaitForExit() and Process.Kill(). - /// - /// - private static void WaitForProcessExit(Process proc) - { - proc.WaitForExit(); - - // Process.WaitForExit() may return prematurely. We need to check to be sure. - while (!proc.HasExited) - { - Thread.Sleep(50); - } - } - - /// - /// Logs all the messages that the tool wrote to stderr. The messages - /// are read out of the stderr data queue. - /// - private void LogMessagesFromStandardError() - => LogMessagesFromStandardErrorOrOutput(_standardErrorData, _standardErrorDataAvailable, _standardErrorImportanceToUse, StandardOutputOrErrorQueueType.StandardError); - - /// - /// Logs all the messages that the tool wrote to stdout. The messages - /// are read out of the stdout data queue. - /// - private void LogMessagesFromStandardOutput() - => LogMessagesFromStandardErrorOrOutput(_standardOutputData, _standardOutputDataAvailable, _standardOutputImportanceToUse, StandardOutputOrErrorQueueType.StandardOutput); - - /// - /// Logs all the messages that the tool wrote to either stderr or stdout. - /// The messages are read out of the given data queue. This method is a - /// helper for the () and () methods. - /// - /// - /// - /// - /// - private void LogMessagesFromStandardErrorOrOutput( - Queue dataQueue, - ManualResetEvent dataAvailableSignal, - MessageImportance messageImportance, - StandardOutputOrErrorQueueType queueType) - { - ErrorUtilities.VerifyThrow(dataQueue != null, - "The data queue must be available."); - - // synchronize access to the queue -- this is a producer-consumer problem - // NOTE: the synchronization problem here is actually not about the queue - // at all -- if we only cared about reading from and writing to the queue, - // we could use a synchronized wrapper around the queue, and things would - // work perfectly; the synchronization problem here is actually around the - // ManualResetEvent -- while a ManualResetEvent itself is a thread-safe - // type, the information we infer from the state of a ManualResetEvent is - // not thread-safe; because a ManualResetEvent does not have a ref count, - // we cannot safely set (or reset) it outside of a synchronization block; - // therefore instead of using synchronized queue wrappers, we just lock the - // entire queue, empty it, and reset the ManualResetEvent before releasing - // the lock; this also allows proper alternation between the stderr and - // stdout queues -- otherwise we would continuously read from one queue and - // starve the other; locking out the producer allows the consumer to - // alternate between the queues - lock (dataQueue.SyncRoot) - { - while (dataQueue.Count > 0) - { - string errorOrOutMessage = dataQueue.Dequeue() as string; - if (!LogStandardErrorAsError || queueType == StandardOutputOrErrorQueueType.StandardOutput) - { - LogEventsFromTextOutput(errorOrOutMessage, messageImportance); - } - else if (LogStandardErrorAsError && queueType == StandardOutputOrErrorQueueType.StandardError) - { - Log.LogError(errorOrOutMessage); - } - } - - ErrorUtilities.VerifyThrow(dataAvailableSignal != null, - "The signalling event must be available."); - - // the queue is empty, so reset the notification - // NOTE: intentionally, do the reset inside the lock, because - // ManualResetEvents don't have ref counts, and we want to make - // sure we don't reset the notification just after the producer - // signals it - dataAvailableSignal.Reset(); - } - } - - /// - /// Calls a method on the TaskLoggingHelper to parse a single line of text to - /// see if there are any errors or warnings in canonical format. This can - /// be overridden by the derived class if necessary. - /// - /// - /// - protected virtual void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance) => Log.LogMessageFromText(singleLine, messageImportance); - - /// - /// Signals when the tool times-out. The tool timer calls this method - /// when the time-out period on the tool expires. - /// - /// This method is used as a System.Threading.TimerCallback delegate. - /// - private void ReceiveTimeoutNotification(object unused) - { - ErrorUtilities.VerifyThrow(_toolTimeoutExpired != null, - "The signalling event for tool time-out must be available."); - lock (_eventCloseLock) - { - if (!_eventsDisposed) - { - _toolTimeoutExpired.Set(); - } - } - } - - /// - /// Signals when the tool exits. The Process object executing the tool - /// calls this method when the tool exits. - /// - /// This method is used as a System.EventHandler delegate. - /// - /// - protected void ReceiveExitNotification(object sender, EventArgs e) - { - ErrorUtilities.VerifyThrow(_toolExited != null, - "The signalling event for tool exit must be available."); - - lock (_eventCloseLock) - { - if (!_eventsDisposed) - { - _toolExited.Set(); - } - } - } - - /// - /// Queues up the output from the stderr stream of the process executing - /// the tool, and signals the availability of the data. The Process object - /// executing the tool calls this method for every line of text that the - /// tool writes to stderr. - /// - /// This method is used as a System.Diagnostics.DataReceivedEventHandler delegate. - /// - /// - protected void ReceiveStandardErrorData(object sender, DataReceivedEventArgs e) => ReceiveStandardErrorOrOutputData(e, _standardErrorData, _standardErrorDataAvailable); - - /// - /// Queues up the output from the stdout stream of the process executing - /// the tool, and signals the availability of the data. The Process object - /// executing the tool calls this method for every line of text that the - /// tool writes to stdout. - /// - /// This method is used as a System.Diagnostics.DataReceivedEventHandler delegate. - /// - /// - protected void ReceiveStandardOutputData(object sender, DataReceivedEventArgs e) => ReceiveStandardErrorOrOutputData(e, _standardOutputData, _standardOutputDataAvailable); - - /// - /// Queues up the output from either the stderr or stdout stream of the - /// process executing the tool, and signals the availability of the data. - /// This method is a helper for the () - /// and () methods. - /// - /// - /// - /// - private void ReceiveStandardErrorOrOutputData(DataReceivedEventArgs e, Queue dataQueue, ManualResetEvent dataAvailableSignal) - { - // NOTE: don't ignore empty string, because we need to log that - if (e.Data != null) - { - ErrorUtilities.VerifyThrow(dataQueue != null, - "The data queue must be available."); - - // synchronize access to the queue -- this is a producer-consumer problem - // NOTE: we lock the entire queue instead of using synchronized queue - // wrappers, because ManualResetEvents don't have ref counts, and it's - // difficult to discretely signal the availability of each instance of - // data in the queue -- so instead we let the consumer lock and empty - // the queue and reset the ManualResetEvent, before we add more data - // into the queue, and signal the ManualResetEvent again - lock (dataQueue.SyncRoot) - { - dataQueue.Enqueue(e.Data); - - ErrorUtilities.VerifyThrow(dataAvailableSignal != null, - "The signalling event must be available."); - - // signal the availability of data - // NOTE: intentionally, do the signalling inside the lock, because - // ManualResetEvents don't have ref counts, and we want to make sure - // we don't signal the notification just before the consumer resets it - lock (_eventCloseLock) - { - if (!_eventsDisposed) - { - dataAvailableSignal.Set(); - } - } - } - } - } - - /// - /// Assign the importances that will be used for stdout/stderr logging of messages from this tool task. - /// This takes into account (1 is highest precedence): - /// 1. the override value supplied as a task parameter. - /// 2. those overridden by any derived class and - /// 3. the defaults given by tooltask - /// - private bool AssignStandardStreamLoggingImportance() - { - // Gather the importance for the Standard Error stream: - if (string.IsNullOrEmpty(StandardErrorImportance)) - { - // If we have no task parameter override then ask the task for its default - _standardErrorImportanceToUse = StandardErrorLoggingImportance; - } - else - { - try - { - // Parse the raw importance string into a strongly typed enumeration. - _standardErrorImportanceToUse = (MessageImportance)Enum.Parse(typeof(MessageImportance), StandardErrorImportance, true /* case-insensitive */); - } - catch (ArgumentException) - { - Log.LogErrorWithCodeFromResources("Message.InvalidImportance", StandardErrorImportance); - return false; - } - } - - // Gather the importance for the Standard Output stream: - if (string.IsNullOrEmpty(StandardOutputImportance)) - { - // If we have no task parameter override then ask the task for its default - _standardOutputImportanceToUse = StandardOutputLoggingImportance; - } - else - { - try - { - // Parse the raw importance string into a strongly typed enumeration. - _standardOutputImportanceToUse = (MessageImportance)Enum.Parse(typeof(MessageImportance), StandardOutputImportance, true /* case-insensitive */); - } - catch (ArgumentException) - { - Log.LogErrorWithCodeFromResources("Message.InvalidImportance", StandardOutputImportance); - return false; - } - } - - return true; - } - - /// - /// Looks for the given file in the system path i.e. all locations in the %PATH% environment variable. - /// - /// - /// The location of the file, or null if file not found. - internal static string FindOnPath(string filename) - { - // Get path from the environment and split path separator - return Environment.GetEnvironmentVariable("PATH")? - .Split(MSBuildConstants.PathSeparatorChar)? - .Where(path => - { - try - { - // The PATH can contain anything, including bad characters - return FileSystems.Default.DirectoryExists(path); - } - catch (Exception) - { - return false; - } - }) - .Select(folderPath => Path.Combine(folderPath, filename)) - .FirstOrDefault(fullPath => !string.IsNullOrEmpty(fullPath) && FileSystems.Default.FileExists(fullPath)); - } - - #endregion - - #region ITask Members - - /// - /// This method invokes the tool with the given parameters. - /// - /// true, if task executes successfully - public override bool Execute() - { - // Let the tool validate its parameters. - if (!ValidateParameters()) - { - // The ToolTask is responsible for logging useful information about what was wrong with the - // parameters; if it didn't, at least emit a generic message. - if (!Log.HasLoggedErrors) - { - LogPrivate.LogErrorWithCodeFromResources("ToolTask.ValidateParametersFailed", this.GetType().FullName); - } - return false; - } - - if (EnvironmentVariables != null) - { - _environmentVariablePairs = new List>(EnvironmentVariables.Length); - - foreach (string entry in EnvironmentVariables) - { - string[] nameValuePair = entry.Split(s_equalsSplitter, 2); - - if (nameValuePair.Length == 1 || (nameValuePair.Length == 2 && nameValuePair[0].Length == 0)) - { - LogPrivate.LogErrorWithCodeFromResources("ToolTask.InvalidEnvironmentParameter", nameValuePair[0]); - return false; - } - - _environmentVariablePairs.Add(new KeyValuePair(nameValuePair[0], nameValuePair[1])); - } - } - - // Assign standard stream logging importances - if (!AssignStandardStreamLoggingImportance()) - { - return false; - } - - try - { - if (SkipTaskExecution()) - { - // the task has said there's no command-line that we need to run, so - // return true to indicate this task completed successfully (without - // doing any actual work). - return true; - } - else if (canBeIncremental && FailIfNotIncremental) - { - LogPrivate.LogErrorWithCodeFromResources("ToolTask.NotUpToDate"); - return false; - } - - string commandLineCommands = GenerateCommandLineCommands(); - // If there are response file commands, then we need a response file later. - string batchFileContents = commandLineCommands; - string responseFileCommands = GenerateResponseFileCommands(); - bool runningOnWindows = NativeMethodsShared.IsWindows; - - if (UseCommandProcessor) - { - if (runningOnWindows) // we are Windows - { - ToolExe = "cmd.exe"; - // Generate the temporary batch file - // May throw IO-related exceptions - _temporaryBatchFile = FileUtilities.GetTemporaryFile(".cmd"); - } - else - { - ToolExe = "/bin/sh"; - // Generate the temporary batch file - // May throw IO-related exceptions - _temporaryBatchFile = FileUtilities.GetTemporaryFile(".sh"); - } - - if (!runningOnWindows) - { - // Use sh rather than bash, as not all 'nix systems necessarily have Bash installed - File.AppendAllText(_temporaryBatchFile, "#!/bin/sh\n"); // first line for UNIX is ANSI - // This is a hack..! - File.AppendAllText(_temporaryBatchFile, AdjustCommandsForOperatingSystem(commandLineCommands), EncodingUtilities.CurrentSystemOemEncoding); - - commandLineCommands = $"\"{_temporaryBatchFile}\""; - } - else - { - Encoding encoding; - - if (Traits.Instance.EscapeHatches.AvoidUnicodeWhenWritingToolTaskBatch) - { - encoding = EncodingUtilities.CurrentSystemOemEncoding; - } - else - { - encoding = EncodingUtilities.BatchFileEncoding(commandLineCommands + _temporaryBatchFile, UseUtf8Encoding); - - if (encoding.CodePage != EncodingUtilities.CurrentSystemOemEncoding.CodePage) - { - // cmd.exe reads the first line in the console CP, - // which for a new console (as here) is OEMCP - // this string should ideally always be ASCII - // and the same in any OEMCP. - File.AppendAllText(_temporaryBatchFile, - $@"%SystemRoot%\System32\chcp.com {encoding.CodePage}>nul{Environment.NewLine}", - EncodingUtilities.CurrentSystemOemEncoding); - } - } - - File.AppendAllText(_temporaryBatchFile, commandLineCommands, encoding); - if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10)) - { - _encoding = encoding; - } - - string batchFileForCommandLine = _temporaryBatchFile; - - // If for some reason the path has a & character and a space in it - // then get the short path of the temp path, which should not have spaces in it - // and then escape the & - if (batchFileForCommandLine.Contains("&") && !batchFileForCommandLine.Contains("^&")) - { - batchFileForCommandLine = NativeMethodsShared.GetShortFilePath(batchFileForCommandLine); - batchFileForCommandLine = batchFileForCommandLine.Replace("&", "^&"); - } - - // /D: Do not load AutoRun configuration from the registry (perf) - commandLineCommands = $"{(Traits.Instance.EscapeHatches.UseAutoRunWhenLaunchingProcessUnderCmd ? string.Empty : "/D ")}/C \"{batchFileForCommandLine}\""; - - if (EchoOff) - { - commandLineCommands = "/Q " + commandLineCommands; - } - } - } - - // ensure the command line arguments string is not null - if (string.IsNullOrEmpty(commandLineCommands)) - { - commandLineCommands = string.Empty; - } - // add a leading space to the command line arguments (if any) to - // separate them from the tool path - else - { - commandLineCommands = " " + commandLineCommands; - } - - // Initialize the host object. At this point, the task may elect - // to not proceed. Compiler tasks do this for purposes of up-to-date - // checking in the IDE. - HostObjectInitializationStatus nextAction = InitializeHostObject(); - if (nextAction == HostObjectInitializationStatus.NoActionReturnSuccess) - { - return true; - } - else if (nextAction == HostObjectInitializationStatus.NoActionReturnFailure) - { - ExitCode = 1; - return HandleTaskExecutionErrors(); - } - - string pathToTool = ComputePathToTool(); - if (pathToTool == null) - { - // An appropriate error should have been logged already. - return false; - } - - // Log the environment. We do this up here, - // rather than later where the environment is set, - // so that it appears before the command line is logged. - bool alreadyLoggedEnvironmentHeader = false; - - // Old style environment overrides -#pragma warning disable 0618 // obsolete - Dictionary envOverrides = EnvironmentOverride; - if (envOverrides != null) - { - foreach (KeyValuePair entry in envOverrides) - { - alreadyLoggedEnvironmentHeader = LogEnvironmentVariable(alreadyLoggedEnvironmentHeader, entry.Key, entry.Value); - } -#pragma warning restore 0618 - } - - // New style environment overrides - if (_environmentVariablePairs != null) - { - foreach (KeyValuePair variable in _environmentVariablePairs) - { - alreadyLoggedEnvironmentHeader = LogEnvironmentVariable(alreadyLoggedEnvironmentHeader, variable.Key, variable.Value); - } - } - - commandLineCommands = AdjustCommandsForOperatingSystem(commandLineCommands); - responseFileCommands = AdjustCommandsForOperatingSystem(responseFileCommands); - - if (UseCommandProcessor) - { - // Log that we are about to invoke the specified command. - LogToolCommand(pathToTool + commandLineCommands); - LogToolCommand(batchFileContents); - } - else - { - // Log that we are about to invoke the specified command. - LogToolCommand(pathToTool + commandLineCommands + " " + responseFileCommands); - } - ExitCode = 0; - ExitCodeOverriddenToIndicateErrors = false; - - if (nextAction == HostObjectInitializationStatus.UseHostObjectToExecute) - { - // The hosting IDE passed in a host object to this task. Give the task - // a chance to call this host object to do the actual work. - try - { - if (!CallHostObjectToExecute()) - { - ExitCode = 1; - } - } - catch (Exception e) - { - LogPrivate.LogErrorFromException(e); - return false; - } - } - else - { - ErrorUtilities.VerifyThrow(nextAction == HostObjectInitializationStatus.UseAlternateToolToExecute, - "Invalid return status"); - - // No host object was provided, or at least not one that supports all of the - // switches/parameters we need. So shell out to the command-line tool. - ExitCode = ExecuteTool(pathToTool, responseFileCommands, commandLineCommands); - } - - // Raise a comment event to notify that the process completed - if (_terminatedTool) - { - return false; - } - else if (ExitCode != 0) - { - return HandleTaskExecutionErrors(); - } - else - { - return true; - } - } - catch (ArgumentException e) - { - if (!_terminatedTool) - { - LogPrivate.LogErrorWithCodeFromResources("General.InvalidToolSwitch", ToolExe, e.ToString()); - } - return false; - } - catch (Exception e) when (e is Win32Exception || e is IOException || e is UnauthorizedAccessException) - { - if (!_terminatedTool) - { - LogPrivate.LogErrorWithCodeFromResources("ToolTask.CouldNotStartToolExecutable", ToolExe, e.ToString()); - } - return false; - } - finally - { - // Clean up after ourselves. - if (_temporaryBatchFile != null && FileSystems.Default.FileExists(_temporaryBatchFile)) - { - DeleteTempFile(_temporaryBatchFile); - } - } - } // Execute() - - /// - /// Replace backslashes with OS-specific path separators, - /// except when likely that the backslash is intentional. - /// - /// - /// Not a static method so that an implementation can - /// override with more-specific knowledge of what backslashes - /// are likely to be correct. - /// - protected virtual string AdjustCommandsForOperatingSystem(string input) - { - if (NativeMethodsShared.IsWindows) - { - return input; - } - - StringBuilder sb = new StringBuilder(input); - - int length = sb.Length; - - for (int i = 0; i < length; i++) - { - // Backslashes must be swapped, because we don't - // know what inputs are paths or path fragments. - // But it's a common pattern to have backslash-escaped - // quotes inside quotes--especially for VB that has a default like - // - // /define:"CONFIG=\"Debug\",DEBUG=-1,TRACE=-1,_MyType=\"Console\",PLATFORM=\"AnyCPU\"" - // - // So don't replace a backslash immediately - // followed by a quote. - if (sb[i] == '\\' && (i == length - 1 || sb[i + 1] != '"')) - { - sb[i] = Path.DirectorySeparatorChar; - } - } - - return sb.ToString(); - } - - /// - /// Log a single environment variable that's about to be applied to the tool - /// - private bool LogEnvironmentVariable(bool alreadyLoggedEnvironmentHeader, string key, string value) - { - if (!alreadyLoggedEnvironmentHeader) - { - LogPrivate.LogMessageFromResources(MessageImportance.Low, "ToolTask.EnvironmentVariableHeader"); - alreadyLoggedEnvironmentHeader = true; - } - - Log.LogMessage(MessageImportance.Low, " {0}={1}", key, value); - - return alreadyLoggedEnvironmentHeader; - } - - #endregion - - #region Member data - - /// - /// An object to hold the event shutdown lock - /// - private readonly LockType _eventCloseLock = new LockType(); - - /// - /// Splitter for environment variables - /// - private static readonly char[] s_equalsSplitter = MSBuildConstants.EqualsChar; - - /// - /// The actual importance at which standard out messages will be logged - /// - private MessageImportance _standardOutputImportanceToUse = MessageImportance.Low; - - /// - /// The actual importance at which standard error messages will be logged - /// - private MessageImportance _standardErrorImportanceToUse = MessageImportance.Normal; - - /// - /// Holds the stderr output from the tool. - /// - /// This collection is NOT thread-safe. - private Queue _standardErrorData; - - /// - /// Holds the stdout output from the tool. - /// - /// This collection is NOT thread-safe. - private Queue _standardOutputData; - - /// - /// Used for signalling when the tool writes to stderr. - /// - private ManualResetEvent _standardErrorDataAvailable; - - /// - /// Used for signalling when the tool writes to stdout. - /// - private ManualResetEvent _standardOutputDataAvailable; - - /// - /// Used for signalling when the tool exits. - /// - private ManualResetEvent _toolExited; - - /// - /// Set to true if the tool process was terminated, - /// either because the timeout was reached or it was canceled. - /// - private bool _terminatedTool; - - /// - /// Used for signalling when the tool times-out. - /// - private ManualResetEvent _toolTimeoutExpired; - - /// - /// Used for timing-out the tool. - /// - private Timer _toolTimer; - - /// - /// Used to support overriding the toolExe name. - /// - private string _toolExe; - - /// - /// Set when the events are about to be disposed, so that tardy - /// calls on the event handlers don't try to reset a disposed event - /// - private bool _eventsDisposed; - - /// - /// List of name, value pairs to be passed to the spawned tool's environment. - /// May be null. - /// - private List> _environmentVariablePairs; - - /// - /// Enumeration which indicates what kind of queue is being passed - /// - private enum StandardOutputOrErrorQueueType - { - StandardError = 0, - StandardOutput = 1 - } - - #endregion - } -} From 5f74f0e3e7685bd087d3c747193a46a3b98d8247 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 26 Mar 2026 11:18:51 +0100 Subject: [PATCH 07/19] Set directory from taskEnvironment only if GetWorkingDirectory() returns nothing --- src/Utilities.UnitTests/ToolTask_Tests.cs | 76 ++++++++++++++++++----- src/Utilities/ToolTask.cs | 23 ++++--- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 2e08e2f1c77..40439a0e2a5 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -1171,15 +1171,17 @@ public int TerminationTimeout } /// - /// A ToolTask subclass that exposes GetProcessStartInfoMultiThreaded for testing. + /// A ToolTask subclass that exposes GetProcessStartInfoMultithreadable for testing. /// private sealed class MultiThreadedToolTask : ToolTask, IDisposable { private readonly string _fullToolName; + private readonly string _workingDirectory; - public MultiThreadedToolTask(string fullToolName) + public MultiThreadedToolTask(string fullToolName, string workingDirectory) { _fullToolName = fullToolName; + _workingDirectory = workingDirectory; } public void Dispose() { } @@ -1188,10 +1190,12 @@ public void Dispose() { } protected override string GenerateFullPathToTool() => _fullToolName; + protected override string GetWorkingDirectory() => _workingDirectory; + /// /// Exposes the protected GetProcessStartInfoMultiThreaded for test verification. /// - public ProcessStartInfo CallGetProcessStartInfoMultiThreaded(TaskEnvironment taskEnvironment) + public ProcessStartInfo CallGetProcessStartInfoMultithreadable(TaskEnvironment taskEnvironment) { return GetProcessStartInfoMultithreadable( _fullToolName, @@ -1202,30 +1206,70 @@ public ProcessStartInfo CallGetProcessStartInfoMultiThreaded(TaskEnvironment tas } [Fact] - public void GetProcessStartInfoMultiThreaded_ShouldPropagateWorkingDirectory() + public void GetProcessStartInfoMultithreadable_NoOverride_UsesProjectDirectory() { - // Arrange: create a MultiThreadedTaskEnvironmentDriver with a known project directory. - string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; - using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir); + // Arrange: no GetWorkingDirectory() override — should fall back to project directory. + 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); + using var tool = new MultiThreadedToolTask(toolPath, null); tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultiThreaded(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); - // Assert: verify env vars from the driver are present + // Assert result.Environment.Count.ShouldBeGreaterThan(0, "Environment variables should be propagated from TaskEnvironment"); + result.WorkingDirectory.ShouldBe(projectDir, + "Without a GetWorkingDirectory() override, WorkingDirectory should fall back to taskEnvironment.ProjectDirectory"); + } + + [Fact] + public void GetProcessStartInfoMultithreadable_RelativeOverride_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.CallGetProcessStartInfoMultithreadable(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 GetProcessStartInfoMultithreadable_AbsoluteOverride_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.CallGetProcessStartInfoMultithreadable(taskEnv); - // Assert: verify WorkingDirectory - result.WorkingDirectory.ShouldBe(expectedWorkingDir, - "WorkingDirectory from MultiThreadedTaskEnvironmentDriver should be propagated to the child process ProcessStartInfo"); + // 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 GetProcessStartInfoMultiThreaded_EnvironmentVariablesOverride() + public void GetProcessStartInfoMultithreadable_EnvironmentVariablesOverride() { // Arrange: create a multithreaded driver with a custom env var. string expectedWorkingDir = NativeMethodsShared.IsUnixLike ? "/tmp" : @"C:\SomeProjectDir"; @@ -1238,14 +1282,14 @@ public void GetProcessStartInfoMultiThreaded_EnvironmentVariablesOverride() var taskEnv = new TaskEnvironment(driver); string toolPath = NativeMethodsShared.IsUnixLike ? "/bin/sh" : @"C:\Windows\System32\cmd.exe"; - using var tool = new MultiThreadedToolTask(toolPath); + 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.CallGetProcessStartInfoMultiThreaded(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); // Assert: task-level override should win. result.Environment["MY_VAR"].ShouldBe("from_task_override", diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index c94a4897857..faa16df4587 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -645,6 +645,7 @@ protected virtual ProcessStartInfo GetProcessStartInfo( SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); ApplyEnvironmentOverrides(startInfo); + return startInfo; } @@ -746,21 +747,25 @@ protected ProcessStartInfo GetProcessStartInfoMultithreadable( string responseFileSwitch, TaskEnvironment taskEnvironment) { - ProcessStartInfo startInfo = new ProcessStartInfo(); - - startInfo.WorkingDirectory = taskEnvironment.ProjectDirectory; - - startInfo.Environment.Clear(); - foreach (var kvp in taskEnvironment.GetEnvironmentVariables()) - { - startInfo.Environment[kvp.Key] = kvp.Value; - } + ProcessStartInfo startInfo = taskEnvironment.GetProcessStartInfo(); SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); // Apply task-level environment overrides — they should take precedence over TaskEnvironment. ApplyEnvironmentOverrides(startInfo); + // Set working directory: prefer the derived task's GetWorkingDirectory() override + // (absolutized against the project directory), otherwise fall back to the project directory. + string workingDirectory = GetWorkingDirectory(); + if (!string.IsNullOrEmpty(workingDirectory)) + { + startInfo.WorkingDirectory = taskEnvironment.GetAbsolutePath(workingDirectory); + } + else + { + startInfo.WorkingDirectory = taskEnvironment.ProjectDirectory; + } + return startInfo; } From dbaf184ac99592f2599f1c397ab60145cfb42db6 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 26 Mar 2026 16:54:23 +0100 Subject: [PATCH 08/19] Migrate ToolTask to Task Environment API --- src/Framework/TaskEnvironment.cs | 5 + src/Tasks/Al.cs | 10 +- src/Utilities.UnitTests/ToolTask_Tests.cs | 239 +++++++++++++++++++++- src/Utilities/ToolTask.cs | 42 ++-- 4 files changed, 263 insertions(+), 33 deletions(-) diff --git a/src/Framework/TaskEnvironment.cs b/src/Framework/TaskEnvironment.cs index ec18be1d817..5ac3fba11b2 100644 --- a/src/Framework/TaskEnvironment.cs +++ b/src/Framework/TaskEnvironment.cs @@ -14,6 +14,11 @@ public sealed class TaskEnvironment { private readonly ITaskEnvironmentDriver _driver; + /// + /// Gets the underlying driver for this TaskEnvironment. + /// + internal ITaskEnvironmentDriver Driver => _driver; + /// /// Initializes a new instance of the TaskEnvironment class. /// diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs index eafc0d9fbe9..706f5a8ffb3 100644 --- a/src/Tasks/Al.cs +++ b/src/Tasks/Al.cs @@ -3,7 +3,6 @@ #if NETFRAMEWORK using System; -using System.Diagnostics; using Microsoft.Build.Shared.FileSystem; using Microsoft.Build.Utilities; #endif @@ -21,11 +20,9 @@ namespace Microsoft.Build.Tasks /// modules and resource files into assemblies. /// [MSBuildMultiThreadableTask] - public class AL : ToolTaskExtension, IALTaskContract, IMultiThreadableTask + public class AL : ToolTaskExtension, IALTaskContract { #region Properties - public TaskEnvironment TaskEnvironment { get; set; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance); - /* Microsoft (R) Assembly Linker version 7.10.2175 for Microsoft (R) .NET Framework version 1.2 @@ -335,11 +332,6 @@ protected override string GenerateFullPathToTool() return string.IsNullOrEmpty(pathToTool) ? pathToTool : TaskEnvironment.GetAbsolutePath(pathToTool).Value; } - protected override ProcessStartInfo GetProcessStartInfo( - string pathToTool, - string commandLineCommands, - string responseFileSwitch) => GetProcessStartInfoMultithreadable(pathToTool, commandLineCommands, responseFileSwitch, TaskEnvironment); - /// /// Fills the provided CommandLineBuilderExtension with those switches and other information that can go into a response file. /// diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 40439a0e2a5..a4017bbb147 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -755,6 +755,7 @@ public void ToolPathIsFoundWhenDirectoryExistsWithNameOfTool() [Fact] public void FindOnPathSucceeds() { + using MyTool tool = new MyTool(); string[] expectedCmdPath; string shellName; string cmdPath; @@ -762,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); @@ -1193,16 +1194,27 @@ public void Dispose() { } protected override string GetWorkingDirectory() => _workingDirectory; /// - /// Exposes the protected GetProcessStartInfoMultiThreaded for test verification. + /// Exposes the protected GetProcessStartInfoMultithreadable for test verification. /// public ProcessStartInfo CallGetProcessStartInfoMultithreadable(TaskEnvironment taskEnvironment) { + TaskEnvironment = taskEnvironment; return GetProcessStartInfoMultithreadable( _fullToolName, commandLineCommands: "/nologo", - responseFileSwitch: null, - taskEnvironment); + responseFileSwitch: null); } + + /// + /// Exposes the protected DeleteTempFile for test verification. + /// + public void CallDeleteTempFile(string fileName) => DeleteTempFile(fileName); + + /// + /// Exposes the virtual GetProcessStartInfo for test verification of routing. + /// + public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch) + => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); } [Fact] @@ -1295,5 +1307,222 @@ public void GetProcessStartInfoMultithreadable_EnvironmentVariablesOverride() result.Environment["MY_VAR"].ShouldBe("from_task_override", "EnvironmentVariables property on the task should override TaskEnvironment values"); } + + [Fact] + public void GetProcessStartInfoMultithreadable_MultiProcessDriver_BackwardCompat() + { + // Arrange: use the default MultiProcessTaskEnvironmentDriver (non-multithreaded mode). + // This simulates a task that hasn't been fully migrated — GetProcessStartInfoMultithreadable + // with the default driver should behave like the old GetProcessStartInfo: 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.CallGetProcessStartInfoMultithreadable(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 GetProcessStartInfoMultithreadable_EmptyWorkingDirectory_KeepsProjectDirectory() + { + // Arrange: GetWorkingDirectory() returns empty string — should NOT override project dir. + // The multithreadable path 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.CallGetProcessStartInfoMultithreadable(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 DeleteTempFile_WarningMessage_UsesOriginalPath() + { + // Arrange: create a read-only file that will fail to delete, then verify the + // warning message uses the original (non-absolutized) relative path. + using var env = TestEnvironment.Create(_output); + string projectDir = env.CreateFolder().Path; + string fileName = "locked.rsp"; + string fullPath = Path.Combine(projectDir, fileName); + File.WriteAllText(fullPath, "test content"); + File.SetAttributes(fullPath, FileAttributes.ReadOnly); + + 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; + var mockEngine = new MockEngine(_output); + tool.BuildEngine = mockEngine; + + try + { + // Act: delete using relative path — should fail and log a warning with original path. + tool.CallDeleteTempFile(fileName); + + // Assert: if a warning was logged, it should reference the original relative path. + if (mockEngine.Warnings > 0) + { + mockEngine.AssertLogContains(fileName); + } + } + finally + { + // Clean up: remove read-only attribute so the test environment can clean up. + File.SetAttributes(fullPath, FileAttributes.Normal); + } + } + + [Fact] + public void GetProcessStartInfo_RoutesToMultithreadablePath_ForMultiThreadedDriver() + { + // Arrange: when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver, + // GetProcessStartInfo should route through GetProcessStartInfoMultithreadable + // which sets WorkingDirectory from the driver's ProjectDirectory. + 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: multithreaded path should set WorkingDirectory to project directory + // and propagate environment variables from the driver. + result.WorkingDirectory.ShouldBe(projectDir, + "MultiThreadedDriver should route through multithreadable path, setting WorkingDirectory to ProjectDirectory"); + result.Environment.Count.ShouldBeGreaterThan(0, + "MultiThreadedDriver path should propagate environment variables"); + } + + [Fact] + public void GetProcessStartInfo_UsesOldPath_ForMultiProcessDriver() + { + // Arrange: when TaskEnvironment uses the default MultiProcessTaskEnvironmentDriver, + // GetProcessStartInfo should take the classic code path that does NOT set + // WorkingDirectory (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: classic path leaves WorkingDirectory empty (inherits from parent process). + result.WorkingDirectory.ShouldBeNullOrEmpty( + "MultiProcessDriver should use the old code path with no explicit WorkingDirectory"); + } + + [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 faa16df4587..6e7d98f68dd 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; } = new TaskEnvironment(MultiProcessTaskEnvironmentDriver.Instance); + /// /// 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); @@ -634,6 +636,11 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string commandLineCommands, string responseFileSwitch) { + if (TaskEnvironment.Driver is MultiThreadedTaskEnvironmentDriver) + { + return GetProcessStartInfoMultithreadable(pathToTool, commandLineCommands, responseFileSwitch); + } + ProcessStartInfo startInfo = new ProcessStartInfo(); // Generally we won't set a working directory, and it will use the current directory @@ -744,10 +751,9 @@ private void ApplyEnvironmentOverrides(ProcessStartInfo startInfo) protected ProcessStartInfo GetProcessStartInfoMultithreadable( string pathToTool, string commandLineCommands, - string responseFileSwitch, - TaskEnvironment taskEnvironment) + string responseFileSwitch) { - ProcessStartInfo startInfo = taskEnvironment.GetProcessStartInfo(); + ProcessStartInfo startInfo = TaskEnvironment.GetProcessStartInfo(); SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); @@ -755,15 +761,11 @@ protected ProcessStartInfo GetProcessStartInfoMultithreadable( ApplyEnvironmentOverrides(startInfo); // Set working directory: prefer the derived task's GetWorkingDirectory() override - // (absolutized against the project directory), otherwise fall back to the project directory. + // (absolutized against the project directory), otherwise keep the project directory. string workingDirectory = GetWorkingDirectory(); if (!string.IsNullOrEmpty(workingDirectory)) { - startInfo.WorkingDirectory = taskEnvironment.GetAbsolutePath(workingDirectory); - } - else - { - startInfo.WorkingDirectory = taskEnvironment.ProjectDirectory; + startInfo.WorkingDirectory = TaskEnvironment.GetAbsolutePath(workingDirectory); } return startInfo; @@ -930,13 +932,15 @@ protected void DeleteTempFile(string fileName) return; } + string absolutePath = !string.IsNullOrEmpty(fileName) ? TaskEnvironment.GetAbsolutePath(fileName) : fileName; + try { - File.Delete(fileName); + File.Delete(absolutePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { - string lockedFileMessage = LockCheck.GetLockedFileMessage(fileName); + string lockedFileMessage = LockCheck.GetLockedFileMessage(absolutePath); // Warn only -- occasionally temp files fail to delete because of virus checkers; we // don't want the build to fail in such cases @@ -1082,7 +1086,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) @@ -1392,17 +1396,17 @@ 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 => { try { // The PATH can contain anything, including bad characters - return FileSystems.Default.DirectoryExists(path); + return FileSystems.Default.DirectoryExists(TaskEnvironment.GetAbsolutePath(path)); } catch (Exception) { @@ -1410,7 +1414,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 From 828e3023bd9387ae3a619fe2aa4a4ca289d630c3 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 27 Mar 2026 17:21:04 +0100 Subject: [PATCH 09/19] Remove DeleteTempFile_WarningMessage_UsesOriginalPath test The test relied on FileAttributes.ReadOnly to force File.Delete to throw, but this is not cross-platform - Unix read-only attributes don't prevent deletion. --- src/Utilities.UnitTests/ToolTask_Tests.cs | 39 ----------------------- 1 file changed, 39 deletions(-) diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 2ab1ab9fd44..9ae8809c5b0 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -1472,45 +1472,6 @@ public void DeleteTempFile_UsesTaskEnvironmentForAbsolutePath() "DeleteTempFile should have deleted the file using TaskEnvironment-absolutized path"); } - [Fact] - public void DeleteTempFile_WarningMessage_UsesOriginalPath() - { - // Arrange: create a read-only file that will fail to delete, then verify the - // warning message uses the original (non-absolutized) relative path. - using var env = TestEnvironment.Create(_output); - string projectDir = env.CreateFolder().Path; - string fileName = "locked.rsp"; - string fullPath = Path.Combine(projectDir, fileName); - File.WriteAllText(fullPath, "test content"); - File.SetAttributes(fullPath, FileAttributes.ReadOnly); - - 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; - var mockEngine = new MockEngine(_output); - tool.BuildEngine = mockEngine; - - try - { - // Act: delete using relative path — should fail and log a warning with original path. - tool.CallDeleteTempFile(fileName); - - // Assert: if a warning was logged, it should reference the original relative path. - if (mockEngine.Warnings > 0) - { - mockEngine.AssertLogContains(fileName); - } - } - finally - { - // Clean up: remove read-only attribute so the test environment can clean up. - File.SetAttributes(fullPath, FileAttributes.Normal); - } - } - [Fact] public void GetProcessStartInfo_RoutesToMultithreadablePath_ForMultiThreadedDriver() { From aa6ffe2752c444983947414cc4521ba436610ded Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 27 Mar 2026 17:57:08 +0100 Subject: [PATCH 10/19] Remove unnecessary comment formatting changes. --- src/Tasks/Al.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs index 706f5a8ffb3..4ff00e13399 100644 --- a/src/Tasks/Al.cs +++ b/src/Tasks/Al.cs @@ -297,7 +297,7 @@ public string SdkToolsPath protected override string ToolName => "al.exe"; /// - /// Return the path of the tool to execute. + /// Return the path of the tool to execute /// protected override string GenerateFullPathToTool() { @@ -377,7 +377,7 @@ protected internal override void AddResponseFileCommands(CommandLineBuilderExten ["LogicalName", "TargetFile", "Access"]); // It's a good idea for the response file to be the very last switch passed, just - // from a predictability perspective. This is also consistent with the compiler + // from a predictability perspective. This is also consistent with the compiler // tasks (Csc, etc.) if (ResponseFiles != null) { From 2480e3897c8779f5e0acc197ed1dcb06cb032d1c Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Fri, 27 Mar 2026 19:18:15 +0100 Subject: [PATCH 11/19] migrate SGen task to Task environment API --- src/Tasks.UnitTests/SGen_Tests.cs | 88 +++++++++++++++++++++++++++++++ src/Tasks/SGen.cs | 43 ++++++++++----- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/Tasks.UnitTests/SGen_Tests.cs b/src/Tasks.UnitTests/SGen_Tests.cs index 2755cc179aa..aad00be2cc9 100644 --- a/src/Tasks.UnitTests/SGen_Tests.cs +++ b/src/Tasks.UnitTests/SGen_Tests.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics; using System.IO; using System.Linq; +using Microsoft.Build.Framework; using Microsoft.Build.Tasks; +using Microsoft.Build.Utilities; using Shouldly; using Xunit; @@ -273,6 +276,91 @@ public void TestReferencesCommandLine() Assert.Equal(targetCommandLine, commandLine); } + + /// + /// Verifies that GenerateFullPathToTool returns an absolute path (or null) + /// when called with a multithreaded TaskEnvironment, validating the + /// TaskEnvironment.GetAbsolutePath() integration. + /// + [Fact] + public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() + { + string projectDir = Path.GetTempPath(); + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + TestableSGen t = new TestableSGen(); + t.TaskEnvironment = taskEnv; + t.BuildEngine = new MockEngine(); + + 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 routes through the multithreadable path + /// when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver, + /// and that the working directory comes from the TaskEnvironment. + /// + [Fact] + public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() + { + string expectedWorkingDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir); + var taskEnv = new TaskEnvironment(driver); + + TestableSGen t = new TestableSGen(); + t.TaskEnvironment = taskEnv; + t.BuildEngine = new MockEngine(); + + ProcessStartInfo startInfo = t.CallGetProcessStartInfo(@"C:\test\sgen.exe", "/nologo", null); + + startInfo.WorkingDirectory.ShouldBe(expectedWorkingDir); + } + + /// + /// Verifies that BuildAssemblyPath uses TaskEnvironment.GetAbsolutePath + /// instead of Path.GetFullPath, by providing a relative path that resolves + /// correctly against the TaskEnvironment's project directory. + /// + [Fact] + public void BuildAssemblyPath_UsesTaskEnvironmentGetAbsolutePath() + { + using var env = TestEnvironment.Create(); + string projectDir = env.CreateFolder().Path; + string subDir = "output"; + Directory.CreateDirectory(Path.Combine(projectDir, subDir)); + + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); + var taskEnv = new TaskEnvironment(driver); + + TestableSGen t = new TestableSGen(); + t.TaskEnvironment = taskEnv; + t.BuildEngine = new MockEngine(); + t.BuildAssemblyPath = subDir; // relative path + + string result = t.BuildAssemblyPath; + + Path.IsPathRooted(result).ShouldBeTrue( + "BuildAssemblyPath should return an absolute path resolved via TaskEnvironment"); + result.ShouldBe(Path.Combine(projectDir, subDir)); + } + + /// + /// Subclass that exposes protected methods for testing without reflection. + /// + private sealed class TestableSGen : SGen + { + public string CallGenerateFullPathToTool() => GenerateFullPathToTool(); + + public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch) + => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); + } #endif } } diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index 7be17cf5dc2..87cfebafac3 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -68,6 +68,7 @@ internal interface ISGenTaskContract } #if RUNTIME_TYPE_NETCORE + [MSBuildMultiThreadableTask] public class SGen : ToolTaskExtension, ISGenTaskContract { #pragma warning disable format // region formatting is different in net7.0 and net472, and cannot be fixed for both @@ -138,6 +139,7 @@ public override bool Execute() /// /// Genererates a serialization assembly containing XML serializers for the input assembly. /// + [MSBuildMultiThreadableTask] public class SGen : ToolTaskExtension, ISGenTaskContract { private string _buildAssemblyPath; @@ -170,7 +172,12 @@ public string BuildAssemblyPath string thisPath; try { - thisPath = Path.GetFullPath(_buildAssemblyPath); + // Validate the path first — GetFullPath throws for invalid characters. + // Then resolve via TaskEnvironment for thread-safe absolute path resolution. + Path.GetFullPath(_buildAssemblyPath); + thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) + ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath) + : _buildAssemblyPath; } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { @@ -255,16 +262,28 @@ public string SerializationAssemblyName } } - private string SerializationAssemblyPath + private AbsolutePath SerializationAssemblyPath { get { Debug.Assert(BuildAssemblyPath.Length > 0, "Build assembly path is blank"); - return Path.Combine(BuildAssemblyPath, SerializationAssemblyName); + string combined = Path.Combine(BuildAssemblyPath, SerializationAssemblyName); + return !string.IsNullOrEmpty(combined) + ? TaskEnvironment.GetAbsolutePath(combined) + : new AbsolutePath(combined, ignoreRootedCheck: true); } } - private string AssemblyFullPath => Path.Combine(BuildAssemblyPath, BuildAssemblyName); + private AbsolutePath AssemblyFullPath + { + get + { + string combined = Path.Combine(BuildAssemblyPath, BuildAssemblyName); + return !string.IsNullOrEmpty(combined) + ? TaskEnvironment.GetAbsolutePath(combined) + : new AbsolutePath(combined, ignoreRootedCheck: true); + } + } public string SdkToolsPath { @@ -307,17 +326,17 @@ 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))) { pathToTool = SdkToolsPathUtility.GeneratePathToTool(SdkToolsPathUtility.FileInfoExists, ProcessorArchitecture.CurrentProcessArchitecture, SdkToolsPath, ToolExe, Log, true); } - return pathToTool; + return string.IsNullOrEmpty(pathToTool) ? pathToTool : TaskEnvironment.GetAbsolutePath(pathToTool).Value; } /// @@ -330,7 +349,7 @@ protected override bool ValidateParameters() { foreach (string reference in References) { - if (!FileSystems.Default.FileExists(reference)) + if (string.IsNullOrEmpty(reference) || !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(reference))) { Log.LogErrorWithCodeFromResources("SGen.ResourceNotFound", reference); return false; @@ -365,11 +384,11 @@ protected override string GenerateCommandLineCommands() { Debug.Assert(ShouldGenerateSerializer, "GenerateCommandLineCommands() should not be called if ShouldGenerateSerializer is true and SerializationAssembly is null."); - SerializationAssembly = [new TaskItem(SerializationAssemblyPath)]; + SerializationAssembly = [new TaskItem(SerializationAssemblyPath.OriginalValue)]; } // Add the assembly switch - commandLineBuilder.AppendSwitchIfNotNull("/assembly:", AssemblyFullPath); + commandLineBuilder.AppendSwitchIfNotNull("/assembly:", AssemblyFullPath.Value); commandLineBuilder.AppendWhenTrue("/proxytypes", Bag, "UseProxyTypes"); @@ -441,11 +460,11 @@ protected override string GenerateCommandLineCommands() // leave the earlier produced assembly around to be propagated by later processes. catch (UnauthorizedAccessException e) { - Log.LogErrorWithCodeFromResources("SGen.CouldNotDeleteSerializer", SerializationAssemblyPath, e.Message); + Log.LogErrorWithCodeFromResources("SGen.CouldNotDeleteSerializer", SerializationAssemblyPath.OriginalValue, e.Message); } catch (IOException e) { - Log.LogErrorWithCodeFromResources("SGen.CouldNotDeleteSerializer", SerializationAssemblyPath, e.Message); + Log.LogErrorWithCodeFromResources("SGen.CouldNotDeleteSerializer", SerializationAssemblyPath.OriginalValue, e.Message); } // The DirectoryNotFoundException is safely ignorable since that means that there is no // existing serialization assembly. This would be extremely unlikely anyway because we From 8e595bba107c6c3b68c222426bb8608ef7d0e5e7 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Mon, 30 Mar 2026 14:38:02 +0200 Subject: [PATCH 12/19] Adressing CR comments: WindowsFullFrameworkOnlyFact added to Al tests, ApplyEnvironmentOverrides moved inside SetUpProcessStartInfo. Added override for DeleteTempFile using AbsolutePath. Made Driver private again, expose only enum value to decided if driver is multitheaded. Made GetProcessStartInfoMultithreadable private. add overload for DeleteTempFile with AbsolutePath argument --- src/Framework/TaskEnvironment.cs | 15 ++++++-- src/Tasks.UnitTests/Al_Tests.cs | 6 ++-- src/Utilities.UnitTests/ToolTask_Tests.cs | 18 +++++----- src/Utilities/ToolTask.cs | 44 +++++++++++------------ 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/src/Framework/TaskEnvironment.cs b/src/Framework/TaskEnvironment.cs index 5ac3fba11b2..eb2d0f465f3 100644 --- a/src/Framework/TaskEnvironment.cs +++ b/src/Framework/TaskEnvironment.cs @@ -6,6 +6,15 @@ namespace Microsoft.Build.Framework { + /// + /// Identifies the kind of driver backing a . + /// + internal enum TaskEnvironmentDriverKind + { + MultiProcess, + MultiThreaded, + } + /// /// Provides an with access to a run-time execution environment including /// environment variables, file paths, and process management capabilities. @@ -15,9 +24,11 @@ public sealed class TaskEnvironment private readonly ITaskEnvironmentDriver _driver; /// - /// Gets the underlying driver for this TaskEnvironment. + /// Gets the kind of driver backing this TaskEnvironment. /// - internal ITaskEnvironmentDriver Driver => _driver; + internal TaskEnvironmentDriverKind DriverKind => _driver is MultiThreadedTaskEnvironmentDriver + ? TaskEnvironmentDriverKind.MultiThreaded + : TaskEnvironmentDriverKind.MultiProcess; /// /// Initializes a new instance of the TaskEnvironment class. diff --git a/src/Tasks.UnitTests/Al_Tests.cs b/src/Tasks.UnitTests/Al_Tests.cs index e7681cc9226..72960a64e59 100644 --- a/src/Tasks.UnitTests/Al_Tests.cs +++ b/src/Tasks.UnitTests/Al_Tests.cs @@ -620,13 +620,12 @@ public void Win32Resource() CommandLine.ValidateHasParameter(t, @"/win32res:foo.res"); } -#if NETFRAMEWORK /// /// Verifies that GenerateFullPathToTool returns an absolute path (or null) /// when called with a multithreaded TaskEnvironment, validating the /// TaskEnvironment.GetAbsolutePath() integration. /// - [Fact] + [WindowsFullFrameworkOnlyFact] public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() { string projectDir = Path.GetTempPath(); @@ -651,7 +650,7 @@ public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() /// GetProcessStartInfoMultiThreaded when TaskEnvironment is set, /// and that the working directory comes from the TaskEnvironment. /// - [Fact] + [WindowsFullFrameworkOnlyFact] public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() { string expectedWorkingDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); @@ -677,6 +676,5 @@ private sealed class TestableAL : AL public ProcessStartInfo CallGetProcessStartInfo(string pathToTool, string commandLineCommands, string responseFileSwitch) => GetProcessStartInfo(pathToTool, commandLineCommands, responseFileSwitch); } -#endif } } diff --git a/src/Utilities.UnitTests/ToolTask_Tests.cs b/src/Utilities.UnitTests/ToolTask_Tests.cs index 9ae8809c5b0..fbc1259aa01 100644 --- a/src/Utilities.UnitTests/ToolTask_Tests.cs +++ b/src/Utilities.UnitTests/ToolTask_Tests.cs @@ -1256,12 +1256,12 @@ public void Dispose() { } protected override string GetWorkingDirectory() => _workingDirectory; /// - /// Exposes the protected GetProcessStartInfoMultithreadable for test verification. + /// Exposes the protected GetProcessStartInfo for test verification. /// - public ProcessStartInfo CallGetProcessStartInfoMultithreadable(TaskEnvironment taskEnvironment) + public ProcessStartInfo CallGetProcessStart(TaskEnvironment taskEnvironment) { TaskEnvironment = taskEnvironment; - return GetProcessStartInfoMultithreadable( + return GetProcessStartInfo( _fullToolName, commandLineCommands: "/nologo", responseFileSwitch: null); @@ -1292,7 +1292,7 @@ public void GetProcessStartInfoMultithreadable_NoOverride_UsesProjectDirectory() tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); // Assert result.Environment.Count.ShouldBeGreaterThan(0, "Environment variables should be propagated from TaskEnvironment"); @@ -1313,7 +1313,7 @@ public void GetProcessStartInfoMultithreadable_RelativeOverride_AbsolutizedAgain tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); // Assert: relative path should be combined with the project directory. string expected = Path.Combine(projectDir, "subdir"); @@ -1335,7 +1335,7 @@ public void GetProcessStartInfoMultithreadable_AbsoluteOverride_UsesOverridePath tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + 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, @@ -1363,7 +1363,7 @@ public void GetProcessStartInfoMultithreadable_EnvironmentVariablesOverride() tool.EnvironmentVariables = ["MY_VAR=from_task_override"]; // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); // Assert: task-level override should win. result.Environment["MY_VAR"].ShouldBe("from_task_override", @@ -1384,7 +1384,7 @@ public void GetProcessStartInfoMultithreadable_MultiProcessDriver_BackwardCompat tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); // Assert: with MultiProcessTaskEnvironmentDriver, WorkingDirectory should be empty // (process inherits parent CWD) — matching pre-migration behavior. @@ -1409,7 +1409,7 @@ public void GetProcessStartInfoMultithreadable_EmptyWorkingDirectory_KeepsProjec tool.BuildEngine = new MockEngine(_output); // Act - ProcessStartInfo result = tool.CallGetProcessStartInfoMultithreadable(taskEnv); + ProcessStartInfo result = tool.CallGetProcessStart(taskEnv); // Assert: empty-string GetWorkingDirectory() must not overwrite the project directory. result.WorkingDirectory.ShouldBe(projectDir, diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index cfcf936f39a..819a0596283 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -636,7 +636,7 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string commandLineCommands, string responseFileSwitch) { - if (TaskEnvironment.Driver is MultiThreadedTaskEnvironmentDriver) + if (TaskEnvironment.DriverKind == TaskEnvironmentDriverKind.MultiThreaded) { return GetProcessStartInfoMultithreadable(pathToTool, commandLineCommands, responseFileSwitch); } @@ -651,7 +651,6 @@ protected virtual ProcessStartInfo GetProcessStartInfo( } SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); - ApplyEnvironmentOverrides(startInfo); return startInfo; } @@ -702,17 +701,11 @@ private void SetUpProcessStartInfo( // the program terminates very fast. startInfo.RedirectStandardInput = true; } - } - /// - /// Applies task-level environment variable (both the obsolete - /// and the current ) to the given . - /// Prefers the pre-parsed populated by , - /// falling back to parsing directly for callers outside - /// the normal Execute() path. - /// - private void ApplyEnvironmentOverrides(ProcessStartInfo startInfo) - { + // 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; @@ -748,7 +741,7 @@ private void ApplyEnvironmentOverrides(ProcessStartInfo startInfo) } } - protected ProcessStartInfo GetProcessStartInfoMultithreadable( + private ProcessStartInfo GetProcessStartInfoMultithreadable( string pathToTool, string commandLineCommands, string responseFileSwitch) @@ -757,9 +750,6 @@ protected ProcessStartInfo GetProcessStartInfoMultithreadable( SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); - // Apply task-level environment overrides — they should take precedence over TaskEnvironment. - ApplyEnvironmentOverrides(startInfo); - // Set working directory: prefer the derived task's GetWorkingDirectory() override // (absolutized against the project directory), otherwise keep the project directory. string workingDirectory = GetWorkingDirectory(); @@ -934,26 +924,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; } - string absolutePath = !string.IsNullOrEmpty(fileName) ? TaskEnvironment.GetAbsolutePath(fileName) : fileName; - try { - File.Delete(absolutePath); + File.Delete(filePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { - string lockedFileMessage = LockCheck.GetLockedFileMessage(absolutePath); + 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); } } From 4d25a426acce95e49ca652e67ecf6e62edaf4150 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Mon, 30 Mar 2026 15:09:04 +0200 Subject: [PATCH 13/19] Path to tool absolutization fix --- src/Tasks/SGen.cs | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index 87cfebafac3..a07c3c5349f 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -176,8 +176,8 @@ public string BuildAssemblyPath // Then resolve via TaskEnvironment for thread-safe absolute path resolution. Path.GetFullPath(_buildAssemblyPath); thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) - ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath) - : _buildAssemblyPath; + ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm() + : _buildAssemblyPath; } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { @@ -262,28 +262,9 @@ public string SerializationAssemblyName } } - private AbsolutePath SerializationAssemblyPath - { - get - { - Debug.Assert(BuildAssemblyPath.Length > 0, "Build assembly path is blank"); - string combined = Path.Combine(BuildAssemblyPath, SerializationAssemblyName); - return !string.IsNullOrEmpty(combined) - ? TaskEnvironment.GetAbsolutePath(combined) - : new AbsolutePath(combined, ignoreRootedCheck: true); - } - } + private AbsolutePath SerializationAssemblyPath => new AbsolutePath(Path.Combine(BuildAssemblyPath, SerializationAssemblyName)); - private AbsolutePath AssemblyFullPath - { - get - { - string combined = Path.Combine(BuildAssemblyPath, BuildAssemblyName); - return !string.IsNullOrEmpty(combined) - ? TaskEnvironment.GetAbsolutePath(combined) - : new AbsolutePath(combined, ignoreRootedCheck: true); - } - } + private AbsolutePath AssemblyFullPathv => new AbsolutePath(Path.Combine(BuildAssemblyPath, BuildAssemblyName)); public string SdkToolsPath { @@ -333,7 +314,11 @@ protected override string GenerateFullPathToTool() if (String.IsNullOrEmpty(pathToTool) || !FileSystems.Default.FileExists(TaskEnvironment.GetAbsolutePath(pathToTool))) { - pathToTool = SdkToolsPathUtility.GeneratePathToTool(SdkToolsPathUtility.FileInfoExists, ProcessorArchitecture.CurrentProcessArchitecture, SdkToolsPath, ToolExe, Log, true); + pathToTool = SdkToolsPathUtility.GeneratePathToTool( + f => !string.IsNullOrEmpty(f) + ? SdkToolsPathUtility.FileInfoExists(TaskEnvironment.GetAbsolutePath(f)) + : SdkToolsPathUtility.FileInfoExists(f), + ProcessorArchitecture.CurrentProcessArchitecture, SdkToolsPath, ToolExe, Log, true); } return string.IsNullOrEmpty(pathToTool) ? pathToTool : TaskEnvironment.GetAbsolutePath(pathToTool).Value; From 1a588f37fd113b553fddf552cd5905bc6e4e18f6 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 8 Apr 2026 08:52:31 +0200 Subject: [PATCH 14/19] Cleanup after merge --- src/Framework/TaskEnvironment.cs | 16 ---------------- src/Utilities/ToolTask.cs | 25 ------------------------- 2 files changed, 41 deletions(-) diff --git a/src/Framework/TaskEnvironment.cs b/src/Framework/TaskEnvironment.cs index ddbc3647a37..43f3c92315e 100644 --- a/src/Framework/TaskEnvironment.cs +++ b/src/Framework/TaskEnvironment.cs @@ -7,15 +7,6 @@ namespace Microsoft.Build.Framework { - /// - /// Identifies the kind of driver backing a . - /// - internal enum TaskEnvironmentDriverKind - { - MultiProcess, - MultiThreaded, - } - /// /// Provides an with access to a run-time execution environment including /// environment variables, file paths, and process management capabilities. @@ -24,13 +15,6 @@ public sealed class TaskEnvironment { private readonly ITaskEnvironmentDriver _driver; - /// - /// Gets the kind of driver backing this TaskEnvironment. - /// - internal TaskEnvironmentDriverKind DriverKind => _driver is MultiThreadedTaskEnvironmentDriver - ? TaskEnvironmentDriverKind.MultiThreaded - : TaskEnvironmentDriverKind.MultiProcess; - /// /// Initializes a new instance of the TaskEnvironment class. /// diff --git a/src/Utilities/ToolTask.cs b/src/Utilities/ToolTask.cs index f89caf9aba2..05ed6aca646 100644 --- a/src/Utilities/ToolTask.cs +++ b/src/Utilities/ToolTask.cs @@ -635,31 +635,6 @@ protected virtual ProcessStartInfo GetProcessStartInfo( string pathToTool, string commandLineCommands, string responseFileSwitch) - { - if (TaskEnvironment.DriverKind == TaskEnvironmentDriverKind.MultiThreaded) - { - return GetProcessStartInfoMultithreadable(pathToTool, commandLineCommands, responseFileSwitch); - } - - ProcessStartInfo startInfo = new ProcessStartInfo(); - - // Generally we won't set a working directory, and it will use the current directory - string workingDirectory = GetWorkingDirectory(); - if (workingDirectory != null) - { - startInfo.WorkingDirectory = workingDirectory; - } - - SetUpProcessStartInfo(startInfo, pathToTool, commandLineCommands, responseFileSwitch); - - return startInfo; - } - - private void SetUpProcessStartInfo( - ProcessStartInfo startInfo, - string pathToTool, - string commandLineCommands, - string responseFileSwitch) { // Build up the command line that will be spawned. string commandLine = commandLineCommands; From aaf974e679a9715c545fabfbad5884896aa529aa Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 8 Apr 2026 08:54:08 +0200 Subject: [PATCH 15/19] Cleanu[ --- src/Tasks/SGen.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index a07c3c5349f..d2211cf8352 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -172,8 +172,6 @@ public string BuildAssemblyPath string thisPath; try { - // Validate the path first — GetFullPath throws for invalid characters. - // Then resolve via TaskEnvironment for thread-safe absolute path resolution. Path.GetFullPath(_buildAssemblyPath); thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm() From 4210def85d9c84237828912c9fce5fb1af06d6fd Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 8 Apr 2026 09:51:10 +0200 Subject: [PATCH 16/19] test fixes --- src/Tasks.UnitTests/SGen_Tests.cs | 22 ++++++++++++++++------ src/Tasks/SGen.cs | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Tasks.UnitTests/SGen_Tests.cs b/src/Tasks.UnitTests/SGen_Tests.cs index aad00be2cc9..4f1fd4582c2 100644 --- a/src/Tasks.UnitTests/SGen_Tests.cs +++ b/src/Tasks.UnitTests/SGen_Tests.cs @@ -10,6 +10,7 @@ using Microsoft.Build.Utilities; using Shouldly; using Xunit; +using Xunit.Abstractions; #nullable disable @@ -17,6 +18,13 @@ namespace Microsoft.Build.UnitTests { public class SGen_Tests { + private readonly ITestOutputHelper _output; + + public SGen_Tests(ITestOutputHelper output) + { + _output = output; + } + #if RUNTIME_TYPE_NETCORE [Fact] public void TaskFailsOnCore() @@ -285,13 +293,14 @@ public void TestReferencesCommandLine() [Fact] public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() { - string projectDir = Path.GetTempPath(); + using TestEnvironment env = TestEnvironment.Create(_output); + string projectDir = env.DefaultTestDirectory.Path; using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); var taskEnv = new TaskEnvironment(driver); TestableSGen t = new TestableSGen(); t.TaskEnvironment = taskEnv; - t.BuildEngine = new MockEngine(); + t.BuildEngine = new MockEngine(_output); string result = t.CallGenerateFullPathToTool(); @@ -310,13 +319,14 @@ public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() [Fact] public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() { - string expectedWorkingDir = Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar); + using TestEnvironment env = TestEnvironment.Create(_output); + string expectedWorkingDir = env.DefaultTestDirectory.Path; using var driver = new MultiThreadedTaskEnvironmentDriver(expectedWorkingDir); var taskEnv = new TaskEnvironment(driver); TestableSGen t = new TestableSGen(); t.TaskEnvironment = taskEnv; - t.BuildEngine = new MockEngine(); + t.BuildEngine = new MockEngine(_output); ProcessStartInfo startInfo = t.CallGetProcessStartInfo(@"C:\test\sgen.exe", "/nologo", null); @@ -331,7 +341,7 @@ public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() [Fact] public void BuildAssemblyPath_UsesTaskEnvironmentGetAbsolutePath() { - using var env = TestEnvironment.Create(); + using TestEnvironment env = TestEnvironment.Create(_output); string projectDir = env.CreateFolder().Path; string subDir = "output"; Directory.CreateDirectory(Path.Combine(projectDir, subDir)); @@ -341,7 +351,7 @@ public void BuildAssemblyPath_UsesTaskEnvironmentGetAbsolutePath() TestableSGen t = new TestableSGen(); t.TaskEnvironment = taskEnv; - t.BuildEngine = new MockEngine(); + t.BuildEngine = new MockEngine(_output); t.BuildAssemblyPath = subDir; // relative path string result = t.BuildAssemblyPath; diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index d2211cf8352..362d81c749c 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -262,7 +262,7 @@ public string SerializationAssemblyName private AbsolutePath SerializationAssemblyPath => new AbsolutePath(Path.Combine(BuildAssemblyPath, SerializationAssemblyName)); - private AbsolutePath AssemblyFullPathv => new AbsolutePath(Path.Combine(BuildAssemblyPath, BuildAssemblyName)); + private AbsolutePath AssemblyFullPath => new AbsolutePath(Path.Combine(BuildAssemblyPath, BuildAssemblyName)); public string SdkToolsPath { From 248a7d1b2e4131bf71e5fed4ddf19ae447256205 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 8 Apr 2026 13:23:48 +0200 Subject: [PATCH 17/19] Impove tests and BuildAssemblyPath getter --- src/Tasks.UnitTests/SGen_Tests.cs | 33 ++++++++++++++++--------------- src/Tasks/SGen.cs | 7 +++---- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Tasks.UnitTests/SGen_Tests.cs b/src/Tasks.UnitTests/SGen_Tests.cs index 4f1fd4582c2..5338ca17f2d 100644 --- a/src/Tasks.UnitTests/SGen_Tests.cs +++ b/src/Tasks.UnitTests/SGen_Tests.cs @@ -286,35 +286,38 @@ public void TestReferencesCommandLine() } /// - /// Verifies that GenerateFullPathToTool returns an absolute path (or null) - /// when called with a multithreaded TaskEnvironment, validating the - /// TaskEnvironment.GetAbsolutePath() integration. + /// Verifies that GenerateFullPathToTool resolves SdkToolsPath relative to the + /// TaskEnvironment's project directory and returns an absolute path, validating + /// the TaskEnvironment.GetAbsolutePath() integration. /// [Fact] - public void GenerateFullPathToTool_ReturnsAbsolutePathOrNull() + public void GenerateFullPathToTool_ResolvesRelativeSdkToolsPathViaTaskEnvironment() { using TestEnvironment env = TestEnvironment.Create(_output); - string projectDir = env.DefaultTestDirectory.Path; + string projectDir = env.CreateFolder().Path; + string sdkSubDir = "sdk"; + string sdkFullDir = Path.Combine(projectDir, sdkSubDir); + Directory.CreateDirectory(sdkFullDir); + string fakeTool = Path.Combine(sdkFullDir, "sgen.exe"); + File.WriteAllBytes(fakeTool, []); + using var driver = new MultiThreadedTaskEnvironmentDriver(projectDir); var taskEnv = new TaskEnvironment(driver); TestableSGen t = new TestableSGen(); t.TaskEnvironment = taskEnv; t.BuildEngine = new MockEngine(_output); + t.SdkToolsPath = sdkSubDir; // relative path — must resolve via TaskEnvironment string result = t.CallGenerateFullPathToTool(); - if (result is not null) - { - Path.IsPathRooted(result).ShouldBeTrue( - $"GenerateFullPathToTool should return an absolute path, got: {result}"); - } + result.ShouldNotBeNull("GenerateFullPathToTool should find the tool via SdkToolsPath"); + result.ShouldBe(fakeTool); } /// - /// Verifies that the GetProcessStartInfo routes through the multithreadable path - /// when TaskEnvironment uses MultiThreadedTaskEnvironmentDriver, - /// and that the working directory comes from the TaskEnvironment. + /// Verifies that in GetProcessStartInfo + /// working directory comes from the TaskEnvironment. /// [Fact] public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() @@ -328,7 +331,7 @@ public void GetProcessStartInfo_UsesTaskEnvironmentWorkingDirectory() t.TaskEnvironment = taskEnv; t.BuildEngine = new MockEngine(_output); - ProcessStartInfo startInfo = t.CallGetProcessStartInfo(@"C:\test\sgen.exe", "/nologo", null); + ProcessStartInfo startInfo = t.CallGetProcessStartInfo(Path.Combine(expectedWorkingDir, "sgen.exe"), "/nologo", null); startInfo.WorkingDirectory.ShouldBe(expectedWorkingDir); } @@ -356,8 +359,6 @@ public void BuildAssemblyPath_UsesTaskEnvironmentGetAbsolutePath() string result = t.BuildAssemblyPath; - Path.IsPathRooted(result).ShouldBeTrue( - "BuildAssemblyPath should return an absolute path resolved via TaskEnvironment"); result.ShouldBe(Path.Combine(projectDir, subDir)); } diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index 362d81c749c..647dfe50991 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -172,10 +172,9 @@ public string BuildAssemblyPath string thisPath; try { - Path.GetFullPath(_buildAssemblyPath); - thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) - ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm() - : _buildAssemblyPath; + string absolutePath = !string.IsNullOrEmpty(_buildAssemblyPath) + ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).Value : _buildAssemblyPath; + thisPath = Path.GetFullPath(absolutePath); } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From 28a1145468b320b6627b3717d6d10f2bbec60af7 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Wed, 8 Apr 2026 13:49:54 +0200 Subject: [PATCH 18/19] Adress comments, remove Path.getFullPath, fix test --- src/Tasks.UnitTests/SGen_Tests.cs | 10 ++++------ src/Tasks/SGen.cs | 5 ++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Tasks.UnitTests/SGen_Tests.cs b/src/Tasks.UnitTests/SGen_Tests.cs index 5338ca17f2d..6c0cf499b1d 100644 --- a/src/Tasks.UnitTests/SGen_Tests.cs +++ b/src/Tasks.UnitTests/SGen_Tests.cs @@ -29,9 +29,8 @@ public SGen_Tests(ITestOutputHelper output) [Fact] public void TaskFailsOnCore() { - using (TestEnvironment testenv = TestEnvironment.Create()) - { - MockLogger logger = ObjectModelHelpers.BuildProjectExpectFailure(@$" + using TestEnvironment testenv = TestEnvironment.Create(_output); + MockLogger logger = ObjectModelHelpers.BuildProjectExpectFailure(@$" "); - logger.ErrorCount.ShouldBe(1); - logger.Errors.First().Code.ShouldBe("MSB3474"); - } + logger.ErrorCount.ShouldBe(1); + logger.Errors.First().Code.ShouldBe("MSB3474"); } #else internal sealed class SGenExtension : SGen diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index 647dfe50991..6d22deade7a 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -172,9 +172,8 @@ public string BuildAssemblyPath string thisPath; try { - string absolutePath = !string.IsNullOrEmpty(_buildAssemblyPath) - ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).Value : _buildAssemblyPath; - thisPath = Path.GetFullPath(absolutePath); + thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) + ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm().Value : _buildAssemblyPath; } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) { From d36d4382773654310fcd5558503443e683ca48a2 Mon Sep 17 00:00:00 2001 From: Veronika Ovsyannikova Date: Thu, 9 Apr 2026 13:37:08 +0200 Subject: [PATCH 19/19] Pass null or empty value to GetAbsolutePath for BuildAssemblyPath --- src/Tasks/SGen.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs index 6d22deade7a..427d034d1ab 100644 --- a/src/Tasks/SGen.cs +++ b/src/Tasks/SGen.cs @@ -172,8 +172,7 @@ public string BuildAssemblyPath string thisPath; try { - thisPath = !string.IsNullOrEmpty(_buildAssemblyPath) - ? TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm().Value : _buildAssemblyPath; + thisPath = TaskEnvironment.GetAbsolutePath(_buildAssemblyPath).GetCanonicalForm().Value; } catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) {