From b337b70a8e7176846b07eb0043b2bd784803584f Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 12:21:32 +0100
Subject: [PATCH 01/12] v1
---
.../MultiThreadedTaskEnvironmentDriver.cs | 44 +++++++++++++++
.../EnvironmentVariableClassifier.cs | 54 +++++++++++++++++++
src/Framework/FileUtilities.cs | 14 +++++
3 files changed, 112 insertions(+)
create mode 100644 src/Framework/EnvironmentVariableClassifier.cs
diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
index db7bc2413e5..011066e6672 100644
--- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
+++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
@@ -37,6 +37,22 @@ public MultiThreadedTaskEnvironmentDriver(
ProjectDirectory = new AbsolutePath(currentDirectoryFullPath, ignoreRootedCheck: true);
}
+ ///
+ /// Validates that the specified environment variable can be modified.
+ /// Throws if the variable is one that MSBuild assumes should remain constant.
+ ///
+ /// The name of the environment variable to check.
+ /// Thrown when attempting to modify an immutable environment variable.
+ private void EnsureVariableCanBeModified(string name)
+ {
+ if (EnvironmentVariableClassifier.IsImmutable(name))
+ {
+ throw new ArgumentException(
+ $"Task cannot modify environment variable '{name}' because MSBuild assumes it should remain constant.",
+ nameof(name));
+ }
+ }
+
///
/// Initializes a new instance of the class
/// with the specified working directory and environment variables from the current process.
@@ -88,6 +104,13 @@ public IReadOnlyDictionary GetEnvironmentVariables()
///
public void SetEnvironmentVariable(string name, string? value)
{
+ // Only validate if we're actually changing the value
+ _environmentVariables.TryGetValue(name, out string? currentValue);
+ if (!CommunicationsUtilities.EnvironmentVariableComparer.Equals(currentValue, value))
+ {
+ EnsureVariableCanBeModified(name);
+ }
+
if (value == null)
{
_environmentVariables.Remove(name);
@@ -101,6 +124,27 @@ public void SetEnvironmentVariable(string name, string? value)
///
public void SetEnvironment(IDictionary newEnvironment)
{
+ // Check for variables being removed (exist in current but not in new environment)
+ foreach (string currentVar in _environmentVariables.Keys)
+ {
+ if (!newEnvironment.ContainsKey(currentVar))
+ {
+ EnsureVariableCanBeModified(currentVar);
+ }
+ }
+
+ // Check for variables being added or modified
+ foreach (KeyValuePair entry in newEnvironment)
+ {
+ _environmentVariables.TryGetValue(entry.Key, out string? currentValue);
+
+ // Only validate if we're actually changing the value
+ if (!CommunicationsUtilities.EnvironmentVariableComparer.Equals(currentValue, entry.Value))
+ {
+ EnsureVariableCanBeModified(entry.Key);
+ }
+ }
+
// Simply replace the entire environment dictionary
_environmentVariables.Clear();
foreach (KeyValuePair entry in newEnvironment)
diff --git a/src/Framework/EnvironmentVariableClassifier.cs b/src/Framework/EnvironmentVariableClassifier.cs
new file mode 100644
index 00000000000..5404f4599de
--- /dev/null
+++ b/src/Framework/EnvironmentVariableClassifier.cs
@@ -0,0 +1,54 @@
+// 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.Frozen;
+
+namespace Microsoft.Build.Framework
+{
+ ///
+ /// Classifies environment variables to prevent modification of those that MSBuild assumes remain constant.
+ /// These variables should not be modified during the build process,
+ /// particularly in multi-threaded build scenarios.
+ ///
+ internal static class EnvironmentVariableClassifier
+ {
+
+ ///
+ /// Set of specific environment variable names that MSBuild assumes should not be modified.
+ ///
+ private static readonly Lazy> s_immutableVariables = new Lazy>(() =>
+ FrozenSet.ToFrozenSet([
+ // .NET Framework path resolution - used by FrameworkLocationHelper
+ EnvironmentVariablesNames.ComplusInstallRoot,
+ EnvironmentVariablesNames.ComplusVersion,
+
+ // Reference assembly root path - used by FrameworkLocationHelper
+ EnvironmentVariablesNames.ReferenceAssemblyRoot,
+
+ // Program Files directories - used by ToolLocationHelper for SDK/tool discovery
+ EnvironmentVariablesNames.ProgramW6432,
+ EnvironmentVariablesNames.ProgramFiles,
+
+ // .NET host path - used by ToolLocationHelper for .NET Core/.NET 5+ discovery
+ EnvironmentVariablesNames.DotnetHostPath
+ ], FrameworkFileUtilities.EnvironmentVariableComparer));
+
+ ///
+ /// Gets whether the specified environment variable is one that MSBuild assumes should not be modified.
+ ///
+ /// The environment variable name to check.
+ /// True if the variable is immutable, false otherwise.
+ internal static bool IsImmutable(string name)
+ {
+ // Check specific variables that MSBuild assumes are constant
+ if (s_immutableVariables.Value.Contains(name))
+ {
+ return true;
+ }
+
+ // All variables that start with "MSBUILD" are assumed to be immutable
+ return name.StartsWith("MSBUILD", FrameworkFileUtilities.EnvironmentVariableComparison);
+ }
+ }
+}
diff --git a/src/Framework/FileUtilities.cs b/src/Framework/FileUtilities.cs
index d861b6821ee..7114502cae0 100644
--- a/src/Framework/FileUtilities.cs
+++ b/src/Framework/FileUtilities.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System;
+
#if !TASKHOST
using System.Threading;
#endif
@@ -27,6 +29,18 @@ internal static class FrameworkFileUtilities
internal static readonly char[] Slashes = [UnixDirectorySeparator, WindowsDirectorySeparator];
+ ///
+ /// The string comparison to use for environment variable name comparisons, based on OS environment variable handling.
+ /// Windows: case-insensitive, Unix/Linux: case-sensitive.
+ ///
+ internal static readonly StringComparison EnvironmentVariableComparison = NativeMethods.IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ ///
+ /// The string comparer to use for environment variable name comparisons, based on OS environment variable handling.
+ /// Windows: case-insensitive, Unix/Linux: case-sensitive.
+ ///
+ internal static readonly StringComparer EnvironmentVariableComparer = NativeMethods.IsWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
#if !TASKHOST
///
/// AsyncLocal working directory for use during property/item expansion in multithreaded mode.
From e399fdb2eb9a751509d362f4904e8f8b3eb09c78 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 13:41:59 +0100
Subject: [PATCH 02/12] deduplicate env var names
---
.../BackEnd/BuildManager_Tests.cs | 6 +--
.../Evaluation/Evaluator_Tests.cs | 2 +-
.../EnvironmentVariableValidator.cs | 3 +-
src/Build/Evaluation/Evaluator.cs | 2 +-
.../TaskFactories/AssemblyTaskFactory.cs | 2 +-
src/Framework/Constants.cs | 52 +++++++++++++++++--
src/Shared/FrameworkLocationHelper.cs | 12 ++---
.../RoslynCodeTaskFactory_Tests.cs | 2 +-
src/Tasks/Al.cs | 2 +-
.../RoslynCodeTaskFactoryCompilers.cs | 3 +-
src/Tasks/SGen.cs | 2 +-
src/UnitTests.Shared/RunnerUtilities.cs | 2 +-
12 files changed, 65 insertions(+), 25 deletions(-)
diff --git a/src/Build.UnitTests/BackEnd/BuildManager_Tests.cs b/src/Build.UnitTests/BackEnd/BuildManager_Tests.cs
index 4cbc6f23c14..fdaf18eca91 100644
--- a/src/Build.UnitTests/BackEnd/BuildManager_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/BuildManager_Tests.cs
@@ -3491,7 +3491,7 @@ public void WarningsAreTreatedAsErrorsButTargetsStillSucceed()
[Fact]
public void DotnetHostPathDirectoryWarning()
{
- _env.SetEnvironmentVariable(Constants.DotnetHostPathEnvVarName, Path.GetTempPath());
+ _env.SetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath, Path.GetTempPath());
BuildRequestData data = GetBuildRequestData(CleanupFileContents(@""));
_buildManager.Build(_parameters, data);
@@ -3506,7 +3506,7 @@ public void DotnetHostPathDirectoryWarning()
public void DotnetHostPathFileNoWarning()
{
TransientTestFile tempFile = _env.CreateFile("dotnet.exe", "");
- _env.SetEnvironmentVariable(Constants.DotnetHostPathEnvVarName, tempFile.Path);
+ _env.SetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath, tempFile.Path);
BuildRequestData data = GetBuildRequestData(CleanupFileContents(@""));
_buildManager.Build(_parameters, data);
@@ -3520,7 +3520,7 @@ public void DotnetHostPathFileNoWarning()
[Fact]
public void DotnetHostPathNotSetNoWarning()
{
- _env.SetEnvironmentVariable(Constants.DotnetHostPathEnvVarName, null);
+ _env.SetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath, null);
BuildRequestData data = GetBuildRequestData(CleanupFileContents(@""));
_buildManager.Build(_parameters, data);
diff --git a/src/Build.UnitTests/Evaluation/Evaluator_Tests.cs b/src/Build.UnitTests/Evaluation/Evaluator_Tests.cs
index 7c289d90ef0..d9c2dc527c9 100644
--- a/src/Build.UnitTests/Evaluation/Evaluator_Tests.cs
+++ b/src/Build.UnitTests/Evaluation/Evaluator_Tests.cs
@@ -2626,7 +2626,7 @@ public void MSBuildExtensionsPath64Default()
if (!String.IsNullOrEmpty(programFiles32))
{
// only set in 32-bit windows on 64-bit machines
- expected = Environment.GetEnvironmentVariable("ProgramW6432");
+ expected = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ProgramW6432);
if (string.IsNullOrEmpty(expected))
{
diff --git a/src/Build/BackEnd/BuildManager/EnvironmentVariableValidator.cs b/src/Build/BackEnd/BuildManager/EnvironmentVariableValidator.cs
index dbadbd40d93..39b726f81d4 100644
--- a/src/Build/BackEnd/BuildManager/EnvironmentVariableValidator.cs
+++ b/src/Build/BackEnd/BuildManager/EnvironmentVariableValidator.cs
@@ -6,7 +6,6 @@
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Shared.FileSystem;
-using Constants = Microsoft.Build.Framework.Constants;
namespace Microsoft.Build.Execution
{
@@ -32,7 +31,7 @@ internal static void ValidateEnvironmentVariables(ILoggingService loggingService
///
private static void ValidateDotnetHostPath(ILoggingService loggingService)
{
- string? dotnetHostPath = Environment.GetEnvironmentVariable(Constants.DotnetHostPathEnvVarName);
+ string? dotnetHostPath = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath);
if (string.IsNullOrEmpty(dotnetHostPath))
{
return;
diff --git a/src/Build/Evaluation/Evaluator.cs b/src/Build/Evaluation/Evaluator.cs
index 371c8b56ff1..8ec66d5f625 100644
--- a/src/Build/Evaluation/Evaluator.cs
+++ b/src/Build/Evaluation/Evaluator.cs
@@ -1904,7 +1904,7 @@ static string EvaluateProperty(string value, IElementLocation location,
string dotnetExe = Path.Combine(FileUtilities.GetFolderAbove(sdkResult.Path, 5), Constants.DotnetProcessName);
if (FileSystems.Default.FileExists(dotnetExe))
{
- _data.AddSdkResolvedEnvironmentVariable(Constants.DotnetHostPathEnvVarName, dotnetExe);
+ _data.AddSdkResolvedEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath, dotnetExe);
}
}
diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
index 7b080d2b3b5..d3930b7b26e 100644
--- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
+++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
@@ -654,7 +654,7 @@ private static TaskHostParameters AddNetHostParamsIfNeeded(
return currentParams;
}
- string dotnetHostPath = getProperty(Constants.DotnetHostPathEnvVarName)?.EvaluatedValue;
+ string dotnetHostPath = getProperty(EnvironmentVariablesNames.DotnetHostPath)?.EvaluatedValue;
string netCoreSdkRoot = getProperty(Constants.NetCoreSdkRoot)?.EvaluatedValue?.TrimEnd('/', '\\');
// The NetCoreSdkRoot property got added with .NET 11, so for earlier SDKs we fall back to the RID graph path
diff --git a/src/Framework/Constants.cs b/src/Framework/Constants.cs
index 36dbb4aab9f..ade3ad3af56 100644
--- a/src/Framework/Constants.cs
+++ b/src/Framework/Constants.cs
@@ -10,11 +10,6 @@ namespace Microsoft.Build.Framework
///
internal static class Constants
{
- ///
- /// Defines the name of dotnet host path environment variable (e.g DOTNET_HOST_PATH = C:\msbuild\.dotnet\dotnet.exe).
- ///
- internal const string DotnetHostPathEnvVarName = "DOTNET_HOST_PATH";
-
///
/// The project property name used to get the path to the MSBuild assembly.
///
@@ -94,4 +89,51 @@ internal static class Constants
internal const string TaskHostExplicitlyRequested = "TaskHostExplicitlyRequested";
}
+
+ ///
+ /// Environment variable names used by MSBuild for immutable variable classification.
+ /// These constants are excluded from TASKHOST context to avoid validation overhead.
+ ///
+ internal static class EnvironmentVariablesNames
+ {
+ ///
+ /// Name of the environment variable that points to 64-bit program files directory.
+ ///
+ internal const string ProgramW6432 = "ProgramW6432";
+
+ ///
+ /// Name of the environment variable that points to program files directory.
+ ///
+ internal const string ProgramFiles = "ProgramFiles";
+
+ ///
+ /// Name of the environment variable for .NET Framework installation root.
+ ///
+ internal const string ComplusInstallRoot = "COMPLUS_INSTALLROOT";
+
+ ///
+ /// Name of the environment variable for .NET Framework version override.
+ ///
+ internal const string ComplusVersion = "COMPLUS_VERSION";
+
+ ///
+ /// Name of the environment variable for reference assembly root path.
+ ///
+ internal const string ReferenceAssemblyRoot = "ReferenceAssemblyRoot";
+
+ ///
+ /// Name of the environment variable for .NET Framework installation root (alternate casing).
+ ///
+ internal const string ComplusInstallRootAlt = "COMPLUS_InstallRoot";
+
+ ///
+ /// Name of the environment variable for .NET Framework version (alternate casing).
+ ///
+ internal const string ComplusVersionAlt = "COMPLUS_Version";
+
+ ///
+ /// Defines the name of dotnet host path environment variable.
+ ///
+ internal const string DotnetHostPath = "DOTNET_HOST_PATH";
+ }
}
diff --git a/src/Shared/FrameworkLocationHelper.cs b/src/Shared/FrameworkLocationHelper.cs
index a7e5d74b727..d90bc73e5c5 100644
--- a/src/Shared/FrameworkLocationHelper.cs
+++ b/src/Shared/FrameworkLocationHelper.cs
@@ -766,8 +766,8 @@ internal static string GetPathToDotNetFramework(Version version, DotNetFramework
private static bool CheckForFrameworkInstallation(string registryEntryToCheckInstall, string registryValueToCheckInstall)
{
// Get the complus install root and version
- string complusInstallRoot = Environment.GetEnvironmentVariable("COMPLUS_INSTALLROOT");
- string complusVersion = Environment.GetEnvironmentVariable("COMPLUS_VERSION");
+ string complusInstallRoot = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusInstallRoot);
+ string complusVersion = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusVersion);
// Complus is not set we need to make sure the framework we are targeting is installed. Check the registry key before trying to find the directory.
// If complus is set then we will return that directory as the framework directory, there is no need to check the registry value for the framework and it may not even be installed.
@@ -818,8 +818,8 @@ internal static string FindDotNetFrameworkPath(
}
// If the COMPLUS variables are set, they override everything -- that's the directory we want.
- string complusInstallRoot = Environment.GetEnvironmentVariable("COMPLUS_INSTALLROOT");
- string complusVersion = Environment.GetEnvironmentVariable("COMPLUS_VERSION");
+ string complusInstallRoot = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusInstallRoot);
+ string complusVersion = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusVersion);
if (!String.IsNullOrEmpty(complusInstallRoot) && !String.IsNullOrEmpty(complusVersion))
{
@@ -935,7 +935,7 @@ internal static string GenerateProgramFiles64()
// either we're in a 32-bit window, or we're on a 32-bit machine.
// if we're on a 32-bit machine, ProgramW6432 won't exist
// if we're on a 64-bit machine, ProgramW6432 will point to the correct Program Files.
- programFilesX64 = Environment.GetEnvironmentVariable("ProgramW6432");
+ programFilesX64 = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ProgramW6432);
}
else
{
@@ -953,7 +953,7 @@ internal static string GenerateProgramFiles64()
///
internal static string GenerateProgramFilesReferenceAssemblyRoot()
{
- string combinedPath = Environment.GetEnvironmentVariable("ReferenceAssemblyRoot");
+ string combinedPath = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ReferenceAssemblyRoot);
if (!String.IsNullOrEmpty(combinedPath))
{
combinedPath = Path.GetFullPath(combinedPath);
diff --git a/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs b/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
index 0253b720504..887834995c5 100644
--- a/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
+++ b/src/Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs
@@ -899,7 +899,7 @@ public void RoslynCodeTaskFactory_UsingAPI(bool forceOutOfProc)
}
RunnerUtilities.ApplyDotnetHostPathEnvironmentVariable(env);
- var dotnetPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH");
+ var dotnetPath = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath);
var project = env.CreateTestProjectWithFiles("p1.proj", text);
var logger = project.BuildProjectExpectSuccess();
diff --git a/src/Tasks/Al.cs b/src/Tasks/Al.cs
index d1872e4fb39..6dff2b04225 100644
--- a/src/Tasks/Al.cs
+++ b/src/Tasks/Al.cs
@@ -305,7 +305,7 @@ 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(Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusInstallRootAlt)) || !String.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusVersionAlt)))
{
pathToTool = ToolLocationHelper.GetPathToDotNetFrameworkFile(ToolExe, TargetDotNetFrameworkVersion.Latest);
}
diff --git a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactoryCompilers.cs b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactoryCompilers.cs
index 64f18fab383..750afbb5902 100644
--- a/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactoryCompilers.cs
+++ b/src/Tasks/RoslynCodeTaskFactory/RoslynCodeTaskFactoryCompilers.cs
@@ -10,7 +10,6 @@
#if RUNTIME_TYPE_NETCORE
using System.Runtime.InteropServices;
using Microsoft.Build.Shared;
-using Constants = Microsoft.Build.Framework.Constants;
#endif
#nullable disable
@@ -53,7 +52,7 @@ protected RoslynCodeTaskFactoryCompilerBase()
#if RUNTIME_TYPE_NETCORE
// Tools and MSBuild Tasks within the SDK that invoke binaries via the dotnet host are expected
// to honor the environment variable DOTNET_HOST_PATH to ensure a consistent experience.
- _dotnetCliPath = Environment.GetEnvironmentVariable(Constants.DotnetHostPathEnvVarName);
+ _dotnetCliPath = Environment.GetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath);
if (string.IsNullOrEmpty(_dotnetCliPath))
{
// Fallback to get dotnet path from current process which might be dotnet executable.
diff --git a/src/Tasks/SGen.cs b/src/Tasks/SGen.cs
index 7be17cf5dc2..3a36ae20f9a 100644
--- a/src/Tasks/SGen.cs
+++ b/src/Tasks/SGen.cs
@@ -307,7 +307,7 @@ 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(Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusInstallRootAlt)) || !String.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvironmentVariablesNames.ComplusVersionAlt)))
{
pathToTool = ToolLocationHelper.GetPathToDotNetFrameworkFile(ToolExe, TargetDotNetFrameworkVersion.Latest);
}
diff --git a/src/UnitTests.Shared/RunnerUtilities.cs b/src/UnitTests.Shared/RunnerUtilities.cs
index e6ac57de4c5..ca1e596475e 100644
--- a/src/UnitTests.Shared/RunnerUtilities.cs
+++ b/src/UnitTests.Shared/RunnerUtilities.cs
@@ -39,7 +39,7 @@ public static class RunnerUtilities
public static void ApplyDotnetHostPathEnvironmentVariable(TestEnvironment testEnvironment)
{
// Built msbuild.dll executed by dotnet.exe needs this environment variable for msbuild tasks such as RoslynCodeTaskFactory.
- testEnvironment.SetEnvironmentVariable(Constants.DotnetHostPathEnvVarName, s_dotnetExePath);
+ testEnvironment.SetEnvironmentVariable(EnvironmentVariablesNames.DotnetHostPath, s_dotnetExePath);
}
#endif
From 3fff389ccd3d01ee12986ac878247ebbb71f9ddd Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:45:12 +0100
Subject: [PATCH 03/12] Improve the classifier
---
.../MultiThreadedTaskEnvironmentDriver.cs | 2 +-
.../EnvironmentVariableClassifier.cs | 88 ++++++++++++++-----
2 files changed, 68 insertions(+), 22 deletions(-)
diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
index 011066e6672..5c1a2e22862 100644
--- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
+++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
@@ -45,7 +45,7 @@ public MultiThreadedTaskEnvironmentDriver(
/// Thrown when attempting to modify an immutable environment variable.
private void EnsureVariableCanBeModified(string name)
{
- if (EnvironmentVariableClassifier.IsImmutable(name))
+ if (EnvironmentVariableClassifier.Instance.IsImmutable(name))
{
throw new ArgumentException(
$"Task cannot modify environment variable '{name}' because MSBuild assumes it should remain constant.",
diff --git a/src/Framework/EnvironmentVariableClassifier.cs b/src/Framework/EnvironmentVariableClassifier.cs
index 5404f4599de..28fef09ab82 100644
--- a/src/Framework/EnvironmentVariableClassifier.cs
+++ b/src/Framework/EnvironmentVariableClassifier.cs
@@ -3,52 +3,98 @@
using System;
using System.Collections.Frozen;
+using System.Collections.Generic;
namespace Microsoft.Build.Framework
{
///
/// Classifies environment variables to prevent modification of those that MSBuild assumes remain constant.
- /// These variables should not be modified during the build process,
- /// particularly in multi-threaded build scenarios.
+ /// These variables should not be modified during the build process.
///
- internal static class EnvironmentVariableClassifier
+ ///
+ /// Used in multithreaded build scenarios to prevent tasks from modifying environment variables that could affect other concurrently building projects.
+ ///
+ internal sealed class EnvironmentVariableClassifier
{
+ ///
+ /// Shared instance used by MSBuild for environment variable classification.
+ ///
+ ///
+ /// Deferred creation avoids overhead in multiprocess builds where this is not to be used.
+ ///
+ private static readonly Lazy s_instance = new(() => new EnvironmentVariableClassifier());
+
+ ///
+ /// Shared instance used by MSBuild for production environment variable classification.
+ ///
+ internal static EnvironmentVariableClassifier Instance => s_instance.Value;
///
/// Set of specific environment variable names that MSBuild assumes should not be modified.
///
- private static readonly Lazy> s_immutableVariables = new Lazy>(() =>
- FrozenSet.ToFrozenSet([
- // .NET Framework path resolution - used by FrameworkLocationHelper
+ private readonly FrozenSet _immutableVariables;
+
+ ///
+ /// Array of prefixes that identify immutable environment variables.
+ ///
+ private readonly string[] _immutablePrefixes;
+
+ ///
+ /// Initializse a new instance with the default set of immutable environment variables and prefixes.
+ ///
+ private EnvironmentVariableClassifier()
+ {
+ _immutableVariables = FrozenSet.ToFrozenSet([
+ // Environment variables used by FrameworkLocationHelper and ToolLocationHelper for framework/SDK discovery.
EnvironmentVariablesNames.ComplusInstallRoot,
EnvironmentVariablesNames.ComplusVersion,
-
- // Reference assembly root path - used by FrameworkLocationHelper
EnvironmentVariablesNames.ReferenceAssemblyRoot,
-
- // Program Files directories - used by ToolLocationHelper for SDK/tool discovery
- EnvironmentVariablesNames.ProgramW6432,
- EnvironmentVariablesNames.ProgramFiles,
-
- // .NET host path - used by ToolLocationHelper for .NET Core/.NET 5+ discovery
- EnvironmentVariablesNames.DotnetHostPath
- ], FrameworkFileUtilities.EnvironmentVariableComparer));
+ EnvironmentVariablesNames.ProgramW6432
+ ], FrameworkFileUtilities.EnvironmentVariableComparer);
+
+ _immutablePrefixes = ["MSBUILD"];
+ }
+
+ ///
+ /// Initializes a new instance with a custom set of immutable environment variables and prefixes.
+ /// Used primarily for testing scenarios.
+ ///
+ /// Custom set of environment variable names to treat as immutable.
+ /// Array of prefixes that identify immutable environment variables. If null or empty, no prefix matching is performed.
+ internal EnvironmentVariableClassifier(IEnumerable immutableVariables, string[] immutablePrefixes)
+ {
+ _immutableVariables = FrozenSet.ToFrozenSet(immutableVariables, FrameworkFileUtilities.EnvironmentVariableComparer);
+ _immutablePrefixes = immutablePrefixes ?? [];
+ }
///
/// Gets whether the specified environment variable is one that MSBuild assumes should not be modified.
///
/// The environment variable name to check.
/// True if the variable is immutable, false otherwise.
- internal static bool IsImmutable(string name)
+ internal bool IsImmutable(string name)
{
- // Check specific variables that MSBuild assumes are constant
- if (s_immutableVariables.Value.Contains(name))
+ if (string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ // Check specific variables that are configured as constant
+ if (_immutableVariables.Contains(name))
{
return true;
}
- // All variables that start with "MSBUILD" are assumed to be immutable
- return name.StartsWith("MSBUILD", FrameworkFileUtilities.EnvironmentVariableComparison);
+ // Check if variable starts with any of the configured immutable prefixes
+ foreach (string prefix in _immutablePrefixes)
+ {
+ if (name.StartsWith(prefix, FrameworkFileUtilities.EnvironmentVariableComparison))
+ {
+ return true;
+ }
+ }
+
+ return false;
}
}
}
From 7b95417c11b4621826a80f675641ef8b65ccab13 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:45:18 +0100
Subject: [PATCH 04/12] add tests
---
.../EnvironmentVariableClassifier_Tests.cs | 95 +++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 src/Framework.UnitTests/EnvironmentVariableClassifier_Tests.cs
diff --git a/src/Framework.UnitTests/EnvironmentVariableClassifier_Tests.cs b/src/Framework.UnitTests/EnvironmentVariableClassifier_Tests.cs
new file mode 100644
index 00000000000..94de68e8e24
--- /dev/null
+++ b/src/Framework.UnitTests/EnvironmentVariableClassifier_Tests.cs
@@ -0,0 +1,95 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Build.Shared;
+using Shouldly;
+using Xunit;
+
+#nullable disable
+
+namespace Microsoft.Build.Framework.UnitTests
+{
+ public class EnvironmentVariableClassifier_Tests
+ {
+ [Fact]
+ public void IsImmutable_CustomImmutableVariables_ExactMatchWorks()
+ {
+ var classifier = new EnvironmentVariableClassifier([
+ "test_env_var_1",
+ "test_env_var_2"
+ ], null);
+
+ classifier.IsImmutable("test_env_var_1").ShouldBeTrue();
+ classifier.IsImmutable("test_env_var_2").ShouldBeTrue();
+ classifier.IsImmutable("test_env_var_3").ShouldBeFalse();
+ classifier.IsImmutable("non_immutable_var_1").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsImmutable_CustomPrefixes_WorksCorrectly()
+ {
+ var classifier = new EnvironmentVariableClassifier([], ["prefix_1", "prefix_2"]);
+
+ // Custom prefixes should work
+ classifier.IsImmutable("prefix_1").ShouldBeTrue();
+ classifier.IsImmutable("prefix_1_var").ShouldBeTrue();
+ classifier.IsImmutable("prefix_2_var").ShouldBeTrue();
+
+ // Non-matching prefixes should not work
+ classifier.IsImmutable("test_prefix_1_var").ShouldBeFalse();
+ classifier.IsImmutable("non_immutable_var_1").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void IsImmutable_EmptyOrNullNames_ReturnsFalse()
+ {
+ var classifier = new EnvironmentVariableClassifier([
+ "test_env_var_1"
+ ], null);
+
+ classifier.IsImmutable(null).ShouldBeFalse();
+ classifier.IsImmutable("").ShouldBeFalse();
+ classifier.IsImmutable(string.Empty).ShouldBeFalse();
+ }
+
+ [WindowsOnlyFact]
+ public void IsImmutable_CaseSensitivityBehavior_Windows()
+ {
+ var classifier = new EnvironmentVariableClassifier([
+ "test_env_var_1",
+ ], ["prefix_1"]);
+
+ // On Windows, environment variables should be case-insensitive
+ classifier.IsImmutable("test_env_var_1").ShouldBeTrue();
+ classifier.IsImmutable("TEST_ENV_VAR_1").ShouldBeTrue();
+ classifier.IsImmutable("Test_Env_Var_1").ShouldBeTrue();
+
+ // Custom prefixes should also be case-insensitive
+ classifier.IsImmutable("prefix_1").ShouldBeTrue();
+ classifier.IsImmutable("PREFIX_1").ShouldBeTrue();
+ classifier.IsImmutable("prefix_1_var").ShouldBeTrue();
+ classifier.IsImmutable("PREFIX_1_VAR").ShouldBeTrue();
+ classifier.IsImmutable("Prefix_1_Var").ShouldBeTrue();
+ }
+
+ [UnixOnlyFact]
+ public void IsImmutable_CaseSensitivityBehavior_Unix()
+ {
+ var classifier = new EnvironmentVariableClassifier([
+ "test_env_var_1",
+ ], ["prefix_1"]);
+
+ // On Unix, environment variables should be case-sensitive
+ classifier.IsImmutable("test_env_var_1").ShouldBeTrue();
+ classifier.IsImmutable("TEST_ENV_VAR_1").ShouldBeFalse();
+ classifier.IsImmutable("Test_Env_Var_1").ShouldBeFalse();
+
+ // Custom prefixes should also be case-sensitive
+ classifier.IsImmutable("prefix_1").ShouldBeTrue();
+ classifier.IsImmutable("prefix_1_var").ShouldBeTrue();
+ classifier.IsImmutable("PREFIX_1").ShouldBeFalse();
+ classifier.IsImmutable("PREFIX_1_VAR").ShouldBeFalse();
+ classifier.IsImmutable("Prefix_1_Var").ShouldBeFalse();
+ }
+ }
+}
From f55ebda7ca499e526abeae906d952aa3ee0edb22 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 14:55:21 +0100
Subject: [PATCH 05/12] Use localizable strings for the user-facing text
---
.../MultiThreadedTaskEnvironmentDriver.cs | 8 ++++----
src/Build/Resources/Strings.resx | 3 +++
src/Build/Resources/xlf/Strings.cs.xlf | 5 +++++
src/Build/Resources/xlf/Strings.de.xlf | 5 +++++
src/Build/Resources/xlf/Strings.es.xlf | 5 +++++
src/Build/Resources/xlf/Strings.fr.xlf | 5 +++++
src/Build/Resources/xlf/Strings.it.xlf | 5 +++++
src/Build/Resources/xlf/Strings.ja.xlf | 5 +++++
src/Build/Resources/xlf/Strings.ko.xlf | 5 +++++
src/Build/Resources/xlf/Strings.pl.xlf | 5 +++++
src/Build/Resources/xlf/Strings.pt-BR.xlf | 5 +++++
src/Build/Resources/xlf/Strings.ru.xlf | 5 +++++
src/Build/Resources/xlf/Strings.tr.xlf | 5 +++++
src/Build/Resources/xlf/Strings.zh-Hans.xlf | 5 +++++
src/Build/Resources/xlf/Strings.zh-Hant.xlf | 5 +++++
15 files changed, 72 insertions(+), 4 deletions(-)
diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
index 5c1a2e22862..2930b1def77 100644
--- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
+++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
@@ -7,6 +7,7 @@
using System.Diagnostics;
using Microsoft.Build.Framework;
using Microsoft.Build.Internal;
+using Microsoft.Build.Shared;
namespace Microsoft.Build.BackEnd
{
@@ -42,14 +43,13 @@ public MultiThreadedTaskEnvironmentDriver(
/// Throws if the variable is one that MSBuild assumes should remain constant.
///
/// The name of the environment variable to check.
- /// Thrown when attempting to modify an immutable environment variable.
+ /// Thrown when attempting to modify an immutable environment variable.
private void EnsureVariableCanBeModified(string name)
{
if (EnvironmentVariableClassifier.Instance.IsImmutable(name))
{
- throw new ArgumentException(
- $"Task cannot modify environment variable '{name}' because MSBuild assumes it should remain constant.",
- nameof(name));
+ throw new InvalidOperationException(
+ ResourceUtilities.FormatResourceStringStripCodeAndKeyword("TaskCannotModifyImmutableEnvironmentVariable", name));
}
}
diff --git a/src/Build/Resources/Strings.resx b/src/Build/Resources/Strings.resx
index c573bbdd54d..06a6cb0d1d1 100644
--- a/src/Build/Resources/Strings.resx
+++ b/src/Build/Resources/Strings.resx
@@ -145,6 +145,9 @@
The operation cannot be completed because EndBuild has already been called but existing submissions have not yet completed.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
Property '{0}' with value '{1}' expanded from the environment.
diff --git a/src/Build/Resources/xlf/Strings.cs.xlf b/src/Build/Resources/xlf/Strings.cs.xlf
index 043072c5530..c5ef95706b1 100644
--- a/src/Build/Resources/xlf/Strings.cs.xlf
+++ b/src/Build/Resources/xlf/Strings.cs.xlf
@@ -1002,6 +1002,11 @@ Chyby: {3}
Sestavení úlohy bylo načteno z{0}, ale požadované umístění bylo{1}.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.Úloha {0} uvolnila tento počet jader: {1}. Teď používá celkem tento počet jader: {2}
diff --git a/src/Build/Resources/xlf/Strings.de.xlf b/src/Build/Resources/xlf/Strings.de.xlf
index 13e8c4a1028..bc2ec0b9fbe 100644
--- a/src/Build/Resources/xlf/Strings.de.xlf
+++ b/src/Build/Resources/xlf/Strings.de.xlf
@@ -1002,6 +1002,11 @@ Fehler: {3}
Die Aufgabenassembly wurde aus „{0}“ geladen, während der gewünschte Speicherort „{1}“ war.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.Die Aufgabe "{0}" hat {1} Kerne freigegeben und belegt jetzt insgesamt {2} Kerne.
diff --git a/src/Build/Resources/xlf/Strings.es.xlf b/src/Build/Resources/xlf/Strings.es.xlf
index 03acc62388f..91856ff82fa 100644
--- a/src/Build/Resources/xlf/Strings.es.xlf
+++ b/src/Build/Resources/xlf/Strings.es.xlf
@@ -1002,6 +1002,11 @@ Errores: {3}
El ensamblado de tarea se cargó desde "{0}" mientras que la ubicación deseada era "{1}".
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.La tarea "{0}" liberó {1} núcleos y ahora retiene un total de {2} núcleos.
diff --git a/src/Build/Resources/xlf/Strings.fr.xlf b/src/Build/Resources/xlf/Strings.fr.xlf
index 4887128eaca..f6373a41a3b 100644
--- a/src/Build/Resources/xlf/Strings.fr.xlf
+++ b/src/Build/Resources/xlf/Strings.fr.xlf
@@ -1002,6 +1002,11 @@ Erreurs : {3}
L’assembly de tâche a été chargé à partir de « {0} » alors que l’emplacement souhaité était « {1} ».
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.La tâche "{0}" a libéré {1} cœur. Elle détient désormais {2} cœurs au total.
diff --git a/src/Build/Resources/xlf/Strings.it.xlf b/src/Build/Resources/xlf/Strings.it.xlf
index 6f3a39ae585..ae428e45eb1 100644
--- a/src/Build/Resources/xlf/Strings.it.xlf
+++ b/src/Build/Resources/xlf/Strings.it.xlf
@@ -1002,6 +1002,11 @@ Errori: {3}
L'assembly attività è stato caricato da "{0}" mentre era "{1}" il percorso desiderato.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.L'attività "{0}" ha rilasciato {1} core e ora contiene {2} core in totale.
diff --git a/src/Build/Resources/xlf/Strings.ja.xlf b/src/Build/Resources/xlf/Strings.ja.xlf
index d8ce6bf4357..a2d04c0b79e 100644
--- a/src/Build/Resources/xlf/Strings.ja.xlf
+++ b/src/Build/Resources/xlf/Strings.ja.xlf
@@ -1002,6 +1002,11 @@ Errors: {3}
タスク アセンブリは '{0}' から読み込まれましたが、必要な場所は '{1}' でした。
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.タスク "{0}" では、{1} 個のコアを解放したため、現在合計 {2} 個のコアを保持しています。
diff --git a/src/Build/Resources/xlf/Strings.ko.xlf b/src/Build/Resources/xlf/Strings.ko.xlf
index 7c369855cd3..6a704fc0cab 100644
--- a/src/Build/Resources/xlf/Strings.ko.xlf
+++ b/src/Build/Resources/xlf/Strings.ko.xlf
@@ -1002,6 +1002,11 @@ Errors: {3}
원하는 위치가 '{1}'인 동안 '{0}'에서 작업 어셈블리를 로드했습니다.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total."{0}" 작업에서 코어 {1}개를 해제했고 지금 총 {2}개의 코어를 보유하고 있습니다.
diff --git a/src/Build/Resources/xlf/Strings.pl.xlf b/src/Build/Resources/xlf/Strings.pl.xlf
index bd540f5ff51..beffb2aa003 100644
--- a/src/Build/Resources/xlf/Strings.pl.xlf
+++ b/src/Build/Resources/xlf/Strings.pl.xlf
@@ -1002,6 +1002,11 @@ Błędy: {3}
Zestaw zadania został załadowany z lokalizacji „{0}”, gdy żądana lokalizacja to „{1}”.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.Zadanie „{0}” zwolniło rdzenie ({1}) i teraz jego łączna liczba rdzeni to {2}.
diff --git a/src/Build/Resources/xlf/Strings.pt-BR.xlf b/src/Build/Resources/xlf/Strings.pt-BR.xlf
index dba114aa2f6..e999ddb22b0 100644
--- a/src/Build/Resources/xlf/Strings.pt-BR.xlf
+++ b/src/Build/Resources/xlf/Strings.pt-BR.xlf
@@ -1002,6 +1002,11 @@ Erros: {3}
O assembly da tarefa foi carregado de "{0}" enquanto o local desejado era "{1}".
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.A tarefa "{0}" liberou {1} núcleos e agora contém {2} núcleos no total.
diff --git a/src/Build/Resources/xlf/Strings.ru.xlf b/src/Build/Resources/xlf/Strings.ru.xlf
index 6b069c84db6..bce9431e594 100644
--- a/src/Build/Resources/xlf/Strings.ru.xlf
+++ b/src/Build/Resources/xlf/Strings.ru.xlf
@@ -1002,6 +1002,11 @@ Errors: {3}
Сборка задачи была загружена из "{0}", а нужное расположение — "{1}".
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.Задача "{0}" освободила указанное число ядер ({1}). Теперь общее число ядер, которыми располагает задача, равно {2}.
diff --git a/src/Build/Resources/xlf/Strings.tr.xlf b/src/Build/Resources/xlf/Strings.tr.xlf
index 5f47925406e..1855df13cc3 100644
--- a/src/Build/Resources/xlf/Strings.tr.xlf
+++ b/src/Build/Resources/xlf/Strings.tr.xlf
@@ -1002,6 +1002,11 @@ Hatalar: {3}
İstenilen konum '{1}' iken görev derlemesi '{0}'dan yüklendi.
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total."{0}" görevi {1} çekirdeği serbest bıraktı. Şu anda toplam {2} çekirdek tutuyor.
diff --git a/src/Build/Resources/xlf/Strings.zh-Hans.xlf b/src/Build/Resources/xlf/Strings.zh-Hans.xlf
index 1e431883513..eba97842f0d 100644
--- a/src/Build/Resources/xlf/Strings.zh-Hans.xlf
+++ b/src/Build/Resources/xlf/Strings.zh-Hans.xlf
@@ -1002,6 +1002,11 @@ Errors: {3}
已从“{0}”加载任务程序集,但所需位置为“{1}”。
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.任务“{0}”发布了 {1} 个核心,现总共包含 {2} 个核心。
diff --git a/src/Build/Resources/xlf/Strings.zh-Hant.xlf b/src/Build/Resources/xlf/Strings.zh-Hant.xlf
index c056dd7c8a1..54feac70a03 100644
--- a/src/Build/Resources/xlf/Strings.zh-Hant.xlf
+++ b/src/Build/Resources/xlf/Strings.zh-Hant.xlf
@@ -1002,6 +1002,11 @@ Errors: {3}
工作組件已從 '{0}' 載入,但 '{1}' 才是所需的位置。
+
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+ Cannot modify environment variable '{0}': variable is immutable and must remain constant during the build.
+
+ Task "{0}" released {1} cores and now holds {2} cores total.工作 "{0}" 已發行 {1} 個核心,現在共保留 {2} 個核心。
From 3ac15d3e2520eab8d30c2ab236e16158d55b1bec Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 16:12:35 +0100
Subject: [PATCH 06/12] fix failing tests
---
src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs | 12 ++++++------
src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs | 2 +-
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
index 4a9d6126ced..5c5a601955a 100644
--- a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
@@ -86,7 +86,7 @@ private static string GetResolvedTempPath()
public void TaskEnvironment_SetAndGetEnvironmentVariable_ShouldWork(string environmentType)
{
var taskEnvironment = CreateTaskEnvironment(environmentType);
- string testVarName = $"MSBUILD_TEST_VAR_{environmentType}_{Guid.NewGuid():N}";
+ string testVarName = $"TEST_ENV_VAR_{environmentType}_{Guid.NewGuid():N}";
string testVarValue = $"test_value_{environmentType}";
try
@@ -112,7 +112,7 @@ public void TaskEnvironment_SetAndGetEnvironmentVariable_ShouldWork(string envir
public void TaskEnvironment_SetEnvironmentVariableToNull_ShouldRemoveVariable(string environmentType)
{
var taskEnvironment = CreateTaskEnvironment(environmentType);
- string testVarName = $"MSBUILD_REMOVE_TEST_{environmentType}_{Guid.NewGuid():N}";
+ string testVarName = $"TEST_ENV_VAR_REMOVE_{environmentType}_{Guid.NewGuid():N}";
string testVarValue = "value_to_remove";
try
@@ -138,7 +138,7 @@ public void TaskEnvironment_SetEnvironmentVariableToNull_ShouldRemoveVariable(st
public void TaskEnvironment_SetEnvironment_ShouldReplaceAllVariables(string environmentType)
{
var taskEnvironment = CreateTaskEnvironment(environmentType);
- string prefix = $"MSBUILD_SET_ENV_TEST_{environmentType}_{Guid.NewGuid():N}";
+ string prefix = $"TEST_ENV_VAR_SET_{environmentType}_{Guid.NewGuid():N}";
string var1Name = $"{prefix}_VAR1";
string var2Name = $"{prefix}_VAR2";
string var3Name = $"{prefix}_VAR3";
@@ -265,7 +265,7 @@ public void TaskEnvironment_GetProcessStartInfo_ShouldConfigureCorrectly(string
{
var taskEnvironment = CreateTaskEnvironment(environmentType);
string testDirectory = GetResolvedTempPath();
- string testVarName = $"MSBUILD_PROCESS_TEST_{environmentType}_{Guid.NewGuid():N}";
+ string testVarName = $"TEST_ENV_VAR_PROCESS_{environmentType}_{Guid.NewGuid():N}";
string testVarValue = "process_test_value";
string originalDirectory = Directory.GetCurrentDirectory();
@@ -303,7 +303,7 @@ public void TaskEnvironment_GetProcessStartInfo_ShouldConfigureCorrectly(string
[Fact]
public void TaskEnvironment_StubEnvironment_ShouldAffectSystemEnvironment()
{
- string testVarName = $"MSBUILD_STUB_ISOLATION_TEST_{Guid.NewGuid():N}";
+ string testVarName = $"TEST_ENV_VAR_STUB_ISOLATION_{Guid.NewGuid():N}";
string testVarValue = "stub_test_value";
var stubEnvironment = TaskEnvironmentHelper.CreateForTest();
@@ -333,7 +333,7 @@ public void TaskEnvironment_StubEnvironment_ShouldAffectSystemEnvironment()
[Fact]
public void TaskEnvironment_MultithreadedEnvironment_ShouldBeIsolatedFromSystem()
{
- string testVarName = $"MSBUILD_MULTITHREADED_ISOLATION_TEST_{Guid.NewGuid():N}";
+ string testVarName = $"TEST_ENV_VAR_MULTITHREADED_ISOLATION_{Guid.NewGuid():N}";
string testVarValue = "multithreaded_test_value";
using var driver = new MultiThreadedTaskEnvironmentDriver(
diff --git a/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs b/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs
index 3b932d38924..d3785ad03c3 100644
--- a/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs
+++ b/src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs
@@ -62,7 +62,7 @@ private bool VerifyTaskEnvironment()
private bool TestEnvironmentIsolation()
{
string mode = IsMultithreadedMode ? "MultiThreaded" : "MultiProcess";
- string envVarName = $"MSBUILD_MULTITHREADED_TEST_VAR_{Guid.NewGuid():N}";
+ string envVarName = $"TEST_ENV_VAR_MULTITHREADED_{Guid.NewGuid():N}";
string envVarValue = "TestValue";
// Set environment variable using TaskEnvironment
From 7c2493b9d6ff481080877328405cb9cb09ebf222 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:15:43 +0100
Subject: [PATCH 07/12] fix Task Environment tests
---
src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
index 5c5a601955a..bfdbf6ff81e 100644
--- a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
@@ -32,7 +32,13 @@ private static TaskEnvironment CreateTaskEnvironment(string environmentType)
return environmentType switch
{
StubEnvironmentName => TaskEnvironmentHelper.CreateForTest(),
- MultithreadedEnvironmentName => new TaskEnvironment(new MultiThreadedTaskEnvironmentDriver(GetResolvedTempPath())),
+ MultithreadedEnvironmentName => new TaskEnvironment(new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary
+ {
+ ["env_var_1"] = "env_var_1_value",
+ ["env_var_2"] = "env_var_2_value"
+ })),
_ => throw new ArgumentException($"Unknown environment type: {environmentType}")
};
}
From 05aca89f65383adb7d6a7ee2d997cd6f091ae495 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Fri, 20 Feb 2026 18:42:57 +0100
Subject: [PATCH 08/12] Account for various casing of msbuild prefix.
---
src/Framework/EnvironmentVariableClassifier.cs | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/Framework/EnvironmentVariableClassifier.cs b/src/Framework/EnvironmentVariableClassifier.cs
index 28fef09ab82..5487202a380 100644
--- a/src/Framework/EnvironmentVariableClassifier.cs
+++ b/src/Framework/EnvironmentVariableClassifier.cs
@@ -35,9 +35,9 @@ internal sealed class EnvironmentVariableClassifier
private readonly FrozenSet _immutableVariables;
///
- /// Array of prefixes that identify immutable environment variables.
+ /// List of prefixes that identify immutable environment variables.
///
- private readonly string[] _immutablePrefixes;
+ private readonly IReadOnlyList _immutablePrefixes;
///
/// Initializse a new instance with the default set of immutable environment variables and prefixes.
@@ -52,7 +52,9 @@ private EnvironmentVariableClassifier()
EnvironmentVariablesNames.ProgramW6432
], FrameworkFileUtilities.EnvironmentVariableComparer);
- _immutablePrefixes = ["MSBUILD"];
+ // On case-sensitive systems, both "MSBUILD" and "MSBuild" prefixes are used
+ var prefixSet = new HashSet(FrameworkFileUtilities.EnvironmentVariableComparer) { "MSBUILD", "MSBuild" };
+ _immutablePrefixes = new List(prefixSet);
}
///
From 0023da9b0ed35cc667193e6f5f733f318d5800b7 Mon Sep 17 00:00:00 2001
From: AR-May <67507805+AR-May@users.noreply.github.com>
Date: Mon, 23 Feb 2026 11:30:37 +0100
Subject: [PATCH 09/12] fix bug and add tests
---
.../BackEnd/TaskEnvironment_Tests.cs | 85 +++++++++++++++++++
.../MultiThreadedTaskEnvironmentDriver.cs | 4 +-
2 files changed, 87 insertions(+), 2 deletions(-)
diff --git a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
index bfdbf6ff81e..1955ac1c287 100644
--- a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
@@ -17,6 +17,8 @@ public class TaskEnvironment_Tests
{
private const string StubEnvironmentName = "Stub";
private const string MultithreadedEnvironmentName = "Multithreaded";
+ private const string ImmutableTestVar = "MSBUILDTESTVAR";
+ private const string TestValue = "value";
public static TheoryData EnvironmentTypes =>
new TheoryData
@@ -390,5 +392,88 @@ public void TaskEnvironment_GetAbsolutePath_WithInvalidPathChars_ShouldNotThrow(
DisposeTaskEnvironment(taskEnvironment);
}
}
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_SetEnvironmentVariable_ThrowsOnCaseChangeOfImmutableValue()
+ {
+ // Changing just the case of an immutable variable's value should throw because
+ // value comparison must be case-sensitive (StringComparison.Ordinal).
+ // If we incorrectly used case-insensitive comparison, this would not throw.
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [ImmutableTestVar] = TestValue
+ });
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ // Changing "value" to "VALUE" is a modification (case-sensitive) and should throw
+ Should.Throw(() =>
+ taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, TestValue.ToUpperInvariant()));
+ }
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_SetEnvironmentVariable_ThrowsOnImmutableVariable()
+ {
+ // MSBuild-prefixed variables are immutable and should throw when adding
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase));
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ Should.Throw(() =>
+ taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, TestValue));
+ }
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_SetEnvironmentVariable_AllowsSettingSameValue()
+ {
+ // Setting an immutable variable to its current value should not throw
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [ImmutableTestVar] = TestValue
+ });
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ Should.NotThrow(() =>
+ taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, TestValue));
+ }
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_SetEnvironment_ThrowsOnImmutableVariableRemoval()
+ {
+ // Removing an immutable variable via SetEnvironment should throw
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [ImmutableTestVar] = TestValue
+ });
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ // New environment without the immutable variable - this is a removal
+ var newEnvironment = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ Should.Throw(() =>
+ taskEnvironment.SetEnvironment(newEnvironment));
+ }
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_SetEnvironmentVariable_ThrowsOnImmutableVariableRemoval()
+ {
+ // Removing an immutable variable by setting to null should throw
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [ImmutableTestVar] = TestValue
+ });
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ Should.Throw(() =>
+ taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, null));
+ }
}
}
diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
index 2930b1def77..cc2cd0d088f 100644
--- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
+++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
@@ -106,7 +106,7 @@ public void SetEnvironmentVariable(string name, string? value)
{
// Only validate if we're actually changing the value
_environmentVariables.TryGetValue(name, out string? currentValue);
- if (!CommunicationsUtilities.EnvironmentVariableComparer.Equals(currentValue, value))
+ if (!string.Equals(currentValue, value, StringComparison.Ordinal))
{
EnsureVariableCanBeModified(name);
}
@@ -139,7 +139,7 @@ public void SetEnvironment(IDictionary newEnvironment)
_environmentVariables.TryGetValue(entry.Key, out string? currentValue);
// Only validate if we're actually changing the value
- if (!CommunicationsUtilities.EnvironmentVariableComparer.Equals(currentValue, entry.Value))
+ if (!string.Equals(currentValue, entry.Value, StringComparison.Ordinal))
{
EnsureVariableCanBeModified(entry.Key);
}
From d1c6828c7f3bb08d0c7b1240494615849a887925 Mon Sep 17 00:00:00 2001
From: AR-May <67507805+AR-May@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:00:19 +0100
Subject: [PATCH 10/12] Update comments
---
.../EnvironmentVariableClassifier.cs | 24 ++++++++++---------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/src/Framework/EnvironmentVariableClassifier.cs b/src/Framework/EnvironmentVariableClassifier.cs
index 5487202a380..79467452dd7 100644
--- a/src/Framework/EnvironmentVariableClassifier.cs
+++ b/src/Framework/EnvironmentVariableClassifier.cs
@@ -8,8 +8,7 @@
namespace Microsoft.Build.Framework
{
///
- /// Classifies environment variables to prevent modification of those that MSBuild assumes remain constant.
- /// These variables should not be modified during the build process.
+ /// Classifies environment variables as immutable or mutable.
///
///
/// Used in multithreaded build scenarios to prevent tasks from modifying environment variables that could affect other concurrently building projects.
@@ -20,27 +19,28 @@ internal sealed class EnvironmentVariableClassifier
/// Shared instance used by MSBuild for environment variable classification.
///
///
- /// Deferred creation avoids overhead in multiprocess builds where this is not to be used.
+ /// Deferred creation avoids overhead in multi-process builds where environment virtualization is not used.
///
private static readonly Lazy s_instance = new(() => new EnvironmentVariableClassifier());
///
- /// Shared instance used by MSBuild for production environment variable classification.
+ /// Gets the shared singleton instance used for classifying environment variables during builds.
///
internal static EnvironmentVariableClassifier Instance => s_instance.Value;
///
- /// Set of specific environment variable names that MSBuild assumes should not be modified.
+ /// Set of specific environment variable names that are classified as immutable.
///
private readonly FrozenSet _immutableVariables;
///
- /// List of prefixes that identify immutable environment variables.
+ /// Prefixes that identify immutable environment variables.
+ /// Any variable starting with one of these prefixes is considered immutable.
///
private readonly IReadOnlyList _immutablePrefixes;
///
- /// Initializse a new instance with the default set of immutable environment variables and prefixes.
+ /// Initializes a new instance with the default set of immutable environment variables and prefixes.
///
private EnvironmentVariableClassifier()
{
@@ -59,10 +59,12 @@ private EnvironmentVariableClassifier()
///
/// Initializes a new instance with a custom set of immutable environment variables and prefixes.
- /// Used primarily for testing scenarios.
///
- /// Custom set of environment variable names to treat as immutable.
- /// Array of prefixes that identify immutable environment variables. If null or empty, no prefix matching is performed.
+ /// Set of environment variable names to treat as immutable.
+ /// Prefixes that identify immutable environment variables. If null or empty, no prefix matching is performed.
+ ///
+ /// This constructor is primarily intended for testing scenarios where custom immutability rules are needed.
+ ///
internal EnvironmentVariableClassifier(IEnumerable immutableVariables, string[] immutablePrefixes)
{
_immutableVariables = FrozenSet.ToFrozenSet(immutableVariables, FrameworkFileUtilities.EnvironmentVariableComparer);
@@ -70,7 +72,7 @@ internal EnvironmentVariableClassifier(IEnumerable immutableVariables, s
}
///
- /// Gets whether the specified environment variable is one that MSBuild assumes should not be modified.
+ /// Determines whether the specified environment variable is classified as immutable.
///
/// The environment variable name to check.
/// True if the variable is immutable, false otherwise.
From e231f0f895a224b05adf9e70c5a28e0d7aa80e60 Mon Sep 17 00:00:00 2001
From: AR-May <67507805+AR-May@users.noreply.github.com>
Date: Mon, 23 Feb 2026 12:21:31 +0100
Subject: [PATCH 11/12] Add escape hatch.
---
.../BackEnd/TaskEnvironment_Tests.cs | 19 ++++++++
.../MultiThreadedTaskEnvironmentDriver.cs | 44 +++++++++++--------
src/Framework/Traits.cs | 7 +++
3 files changed, 51 insertions(+), 19 deletions(-)
diff --git a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
index 1955ac1c287..3c21f7ef7f2 100644
--- a/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
+++ b/src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs
@@ -475,5 +475,24 @@ public void MultiThreadedTaskEnvironmentDriver_SetEnvironmentVariable_ThrowsOnIm
Should.Throw(() =>
taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, null));
}
+
+ [Fact]
+ public void MultiThreadedTaskEnvironmentDriver_EscapeHatch_DisablesImmutableVariableCheck()
+ {
+ // When the escape hatch is set, modifying immutable variables should not throw
+ using TestEnvironment env = TestEnvironment.Create();
+ env.SetEnvironmentVariable("MSBUILDDISABLEIMMUTABLEENVIRONMENTVARIABLECHECK", "1");
+
+ using var driver = new MultiThreadedTaskEnvironmentDriver(
+ GetResolvedTempPath(),
+ new Dictionary(StringComparer.OrdinalIgnoreCase));
+ var taskEnvironment = new TaskEnvironment(driver);
+
+ // With escape hatch enabled, setting an MSBUILD-prefixed variable should not throw
+ Should.NotThrow(() =>
+ taskEnvironment.SetEnvironmentVariable(ImmutableTestVar, TestValue));
+
+ taskEnvironment.GetEnvironmentVariable(ImmutableTestVar).ShouldBe(TestValue);
+ }
}
}
diff --git a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
index cc2cd0d088f..ca8608f5ca9 100644
--- a/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
+++ b/src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs
@@ -44,7 +44,7 @@ public MultiThreadedTaskEnvironmentDriver(
///
/// The name of the environment variable to check.
/// Thrown when attempting to modify an immutable environment variable.
- private void EnsureVariableCanBeModified(string name)
+ private static void EnsureVariableCanBeModified(string name)
{
if (EnvironmentVariableClassifier.Instance.IsImmutable(name))
{
@@ -104,11 +104,14 @@ public IReadOnlyDictionary GetEnvironmentVariables()
///
public void SetEnvironmentVariable(string name, string? value)
{
- // Only validate if we're actually changing the value
- _environmentVariables.TryGetValue(name, out string? currentValue);
- if (!string.Equals(currentValue, value, StringComparison.Ordinal))
+ if (!Traits.Instance.EscapeHatches.DisableImmutableEnvironmentVariableCheck)
{
- EnsureVariableCanBeModified(name);
+ // Only validate if we're actually changing the value
+ _environmentVariables.TryGetValue(name, out string? currentValue);
+ if (!string.Equals(currentValue, value, StringComparison.Ordinal))
+ {
+ EnsureVariableCanBeModified(name);
+ }
}
if (value == null)
@@ -124,24 +127,27 @@ public void SetEnvironmentVariable(string name, string? value)
///
public void SetEnvironment(IDictionary newEnvironment)
{
- // Check for variables being removed (exist in current but not in new environment)
- foreach (string currentVar in _environmentVariables.Keys)
+ if (!Traits.Instance.EscapeHatches.DisableImmutableEnvironmentVariableCheck)
{
- if (!newEnvironment.ContainsKey(currentVar))
+ // Check for variables being removed (exist in current but not in new environment)
+ foreach (string currentVar in _environmentVariables.Keys)
{
- EnsureVariableCanBeModified(currentVar);
+ if (!newEnvironment.ContainsKey(currentVar))
+ {
+ EnsureVariableCanBeModified(currentVar);
+ }
}
- }
-
- // Check for variables being added or modified
- foreach (KeyValuePair entry in newEnvironment)
- {
- _environmentVariables.TryGetValue(entry.Key, out string? currentValue);
-
- // Only validate if we're actually changing the value
- if (!string.Equals(currentValue, entry.Value, StringComparison.Ordinal))
+
+ // Check for variables being added or modified
+ foreach (KeyValuePair entry in newEnvironment)
{
- EnsureVariableCanBeModified(entry.Key);
+ _environmentVariables.TryGetValue(entry.Key, out string? currentValue);
+
+ // Only validate if we're actually changing the value
+ if (!string.Equals(currentValue, entry.Value, StringComparison.Ordinal))
+ {
+ EnsureVariableCanBeModified(entry.Key);
+ }
}
}
diff --git a/src/Framework/Traits.cs b/src/Framework/Traits.cs
index f14208e145a..999fd5a833b 100644
--- a/src/Framework/Traits.cs
+++ b/src/Framework/Traits.cs
@@ -258,6 +258,13 @@ internal class EscapeHatches
///
public readonly bool AlwaysDoImmutableFilesUpToDateCheck = Environment.GetEnvironmentVariable("MSBUILDDONOTCACHEMODIFICATIONTIME") == "1";
+ ///
+ /// Disables the check that prevents tasks from modifying immutable environment variables (e.g., MSBUILD* variables)
+ /// in multithreaded build mode. This escape hatch should only be used for compatibility with tasks that need to
+ /// modify these variables and understand the risks of doing so in a concurrent build environment.
+ ///
+ public readonly bool DisableImmutableEnvironmentVariableCheck = Environment.GetEnvironmentVariable("MSBUILDDISABLEIMMUTABLEENVIRONMENTVARIABLECHECK") == "1";
+
///
/// When copying over an existing file, copy directly into the existing file rather than deleting and recreating.
///
From 0d4674618c642f6d2d6b0dbc404fb3ca65475114 Mon Sep 17 00:00:00 2001
From: Alina Mayorova <67507805+AR-May@users.noreply.github.com>
Date: Tue, 24 Feb 2026 12:27:48 +0100
Subject: [PATCH 12/12] Add docs
---
.../msbuild-task-enlightenment.md | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 documentation/specs/multithreading/msbuild-task-enlightenment.md
diff --git a/documentation/specs/multithreading/msbuild-task-enlightenment.md b/documentation/specs/multithreading/msbuild-task-enlightenment.md
new file mode 100644
index 00000000000..fa00392e1e5
--- /dev/null
+++ b/documentation/specs/multithreading/msbuild-task-enlightenment.md
@@ -0,0 +1,26 @@
+# MSBuild Repository Task Enlightenment Guidelines
+
+This document provides specific guidance for enlightening tasks within the MSBuild repository.
+
+## Acceptable Changes in Task Behavior During Enlightenment
+
+**The fundamental principle is to preserve task behavior and outputs for all input options.**
+
+### Permissible Changes
+Modifications to error messaging or failure location are acceptable when a task fails with a different but reasonable error at an alternative execution point, provided that **no significant side effects** (such as disk modifications or output changes) occur between the original and new failure points.
+
+### Changes Requiring Change Waves
+Modifications that alter the success or failure outcome of a task for given inputs require careful evaluation. Such changes are permissible only when implemented behind a **change wave** and when the affected scenarios represent **obscure or edge use cases**.
+
+## Immutable Environment Variables in MSBuild
+
+Certain MSBuild tasks (such as `GetFrameworkPath`) use internal infrastructure that depends on environment variables for resolution. These results are cached in-process for reuse by both tasks and internal MSBuild components. MSBuild assumes that these variables remain constant throughout the build process.
+
+While multiprocess mode cannot prevent tasks from modifying these variables, multithreaded mode enables MSBuild to enforce protection of environment variables that must remain immutable. Attempts to modify these protected variables will result in an `InvalidOperationException`.
+
+With this protection in place, we will allow MSBuild tasks to continue using internal infrastructure directly without requiring the TaskEnvironment API.
+
+MSBuild protects environment variables in these categories:
+
+1. **Variables with MSBuild-specific prefixes** (e.g. ones used in Traits)
+2. **Framework and SDK location variables** (e.g., `COMPLUS_INSTALL_ROOT`, `COMPLUS_VERSION`, `ReferenceAssemblyRoot`, `ProgramW6432`, etc)