Skip to content

Prevent tasks from modifying immutable environment variables in MT mode#13273

Draft
AR-May wants to merge 12 commits intodotnet:mainfrom
AR-May:protect-env-vars
Draft

Prevent tasks from modifying immutable environment variables in MT mode#13273
AR-May wants to merge 12 commits intodotnet:mainfrom
AR-May:protect-env-vars

Conversation

@AR-May
Copy link
Copy Markdown
Member

@AR-May AR-May commented Feb 20, 2026

Context

Some MSBuild tasks implicitly rely on environment variables remaining immutable across multiple projects builds and cache this state in-process. We would like to disallow to modify certain set of environment variables in the tasks. We can easily check that in the multithreaded mode through task environment. This will help use easier enlighten msbuild tasks that depend on immutable state and cache this state in process. These checks would work only in multithreaded mode.

Changes Made

  • In multithreaded mode, validate environment variable assignments and disallow modifications to variables marked as immutable
  • Centralize and refactor environment variable constants to avoid code duplication
  • added escape hatch to opt-out from the behavior.

Testing

unit tests + manual tests (in process)

/// The string comparison to use for environment variable name comparisons, based on OS environment variable handling.
/// Windows: case-insensitive, Unix/Linux: case-sensitive.
/// </summary>
internal static readonly StringComparison EnvironmentVariableComparison = NativeMethods.IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that environment variable comparisons can be case-sensitive or insensitive independently from the file system’s case sensitivity.

@AR-May AR-May marked this pull request as ready for review February 23, 2026 11:04
Copilot AI review requested due to automatic review settings February 23, 2026 11:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces environment variable immutability validation for multithreaded builds to prevent tasks from modifying variables that other concurrent project builds depend on. The implementation centralizes environment variable constants and adds runtime validation that throws when tasks attempt to modify MSBUILD-prefixed or framework-discovery-related variables.

Changes:

  • Added EnvironmentVariableClassifier to determine which environment variables are immutable (MSBUILD-prefixed variables and specific framework discovery variables)
  • Refactored environment variable name constants into centralized EnvironmentVariablesNames class in Framework\Constants.cs
  • Added validation in MultiThreadedTaskEnvironmentDriver to enforce immutability constraints when tasks modify environment variables

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Framework/EnvironmentVariableClassifier.cs New classifier to determine if environment variables are immutable based on prefixes and specific names
src/Framework/Constants.cs Added EnvironmentVariablesNames class with centralized constants, moved DotnetHostPath from Constants class
src/Framework/FileUtilities.cs Added platform-aware EnvironmentVariableComparer/Comparison utilities for consistent env var name handling
src/Build/BackEnd/TaskExecutionHost/MultiThreadedTaskEnvironmentDriver.cs Added validation logic to prevent modification of immutable environment variables
src/Tasks/Al.cs, SGen.cs, RoslynCodeTaskFactory/RoslynCodeTaskFactoryCompilers.cs Refactored to use EnvironmentVariablesNames constants instead of hardcoded strings
src/Shared/FrameworkLocationHelper.cs Refactored to use EnvironmentVariablesNames constants
src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs, Evaluation/Evaluator.cs, BuildManager/EnvironmentVariableValidator.cs Updated to use EnvironmentVariablesNames.DotnetHostPath
src/Build/Resources/Strings.resx + xlf files Added error message for immutable variable modification attempts
src/Framework.UnitTests/EnvironmentVariableClassifier_Tests.cs Unit tests for classifier with case-sensitivity behavior verification
src/Build.UnitTests/BackEnd/TaskEnvironment_Tests.cs Tests for immutable variable validation in multithreaded mode
src/MSBuild.UnitTests/MSBuildMultithreaded_Tests.cs, Build.UnitTests/BackEnd/BuildManager_Tests.cs, Evaluation/Evaluator_Tests.cs, Tasks.UnitTests/RoslynCodeTaskFactory_Tests.cs, UnitTests.Shared/RunnerUtilities.cs Test updates to use new constants and avoid MSBUILD-prefixed test variables

Comment on lines +127 to +134
// 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);
}
}
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SetEnvironment method has a critical bug in handling case-sensitivity when checking for removed variables. The code calls newEnvironment.ContainsKey(currentVar), but this check uses the comparer of the newEnvironment dictionary, which may differ from the _environmentVariables dictionary's comparer.

For example:

  • _environmentVariables uses CommunicationsUtilities.EnvironmentVariableComparer (OrdinalIgnoreCase)
  • If newEnvironment is passed with a case-sensitive comparer and contains "msbuildtest"
  • But _environmentVariables contains "MSBUILDTEST"
  • Then newEnvironment.ContainsKey("MSBUILDTEST") returns false (case mismatch)
  • This incorrectly triggers the "variable removed" validation even though the variable is present

The fix is to use _environmentVariables.Comparer.Equals(currentVar, key) when checking if a key exists in newEnvironment, or to ensure both dictionaries use compatible comparers.

Copilot uses AI. Check for mistakes.
EnvironmentVariablesNames.ProgramW6432
], FrameworkFileUtilities.EnvironmentVariableComparer);

// On case-sensitive systems, both "MSBUILD" and "MSBuild" prefixes are used
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "On case-sensitive systems, both 'MSBUILD' and 'MSBuild' prefixes are used" is misleading. The HashSet deduplication happens on all systems, but has different results:

  • On Windows (case-insensitive comparer): HashSet keeps only one entry since "MSBUILD" and "MSBuild" are considered equal
  • On Unix/Linux (case-sensitive comparer): HashSet keeps both entries since they are distinct

The comment implies this behavior is specific to case-sensitive systems, when in fact the deduplication logic works on all systems. Consider revising to: "The HashSet deduplication ensures both 'MSBUILD' and 'MSBuild' prefixes are available on case-sensitive systems, while on case-insensitive systems they are treated as equivalent."

Suggested change
// On case-sensitive systems, both "MSBUILD" and "MSBuild" prefixes are used
// The HashSet deduplication ensures both "MSBUILD" and "MSBuild" prefixes are available on case-sensitive systems,
// while on case-insensitive systems they are treated as equivalent.

Copilot uses AI. Check for mistakes.
@AR-May AR-May self-assigned this Feb 23, 2026
/// </summary>
private EnvironmentVariableClassifier()
{
_immutableVariables = FrozenSet.ToFrozenSet([
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why just some of the ones that are in the class?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it was answered in #13273 (comment) comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the place that solely define the set of immutable variables.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how to decide which ones are immutable and which ones mutable?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. are and should the DOTNET_* be mutable?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I for time being added ones for which i am sure that they should be immutable. This set might and should extend. For adding DOTNET we would need to make sure there is no task that legitimately sets it in SDK repo or any other dotnet related repos.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you write that up as an issue?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a case where we should consider being as restrictive as we can be in v1, since we can't change it later.

/// The string comparison to use for environment variable name comparisons, based on OS environment variable handling.
/// Windows: case-insensitive, Unix/Linux: case-sensitive.
/// </summary>
internal static readonly StringComparison EnvironmentVariableComparison = NativeMethods.IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just moving from shared fileutilities to framework?

/// 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.
/// </summary>
public readonly bool DisableImmutableEnvironmentVariableCheck = Environment.GetEnvironmentVariable("MSBUILDDISABLEIMMUTABLEENVIRONMENTVARIABLECHECK") == "1";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea

@AR-May
Copy link
Copy Markdown
Member Author

AR-May commented Mar 3, 2026

@rainersigwald and I synced, the question was raised whether we actually need these checks.

In multi‑process MSBuild, if a task mutates environment variables that the build engine or static cache relies on, the change can have different effects:

  • It may have no immediate effect if the value was already captured (e.g., in Traits) or if static caches were initialized earlier using the original value.
  • It may change build behavior if the engine consults the variable after the mutation or some static caches were initialized later. Which is undesirable, as it can lead to build inconsistencies.
    Any subsequently created process would inherit the modified environment, so the change could affect build behavior there regardless.

In multi-threaded MSBuild, if a task changes environment variables via the TaskEnvironment API, it will not affect the build engine’s behavior - the engine doesn’t use TaskEnvironment nor the static caches, as the process environment variables always would be used to populate those.
If the task causes a new process to be spawned, that child process would use the modified environment. So, engine protection from the env variable that.

So, there is a behavioral difference between the two modes, and it is inevitable. We cannot, and should not, make the build engine rely on the environment variables set by tasks, since different threads could set different values. The old behavior is inherently timing‑dependent and potentially inconsistent, depending on when projects or tasks run.

The only real benefit I see from these checks is debuggability. If a solution relies on this inconsistent behavior (e.g., setting an environment variable and expecting the engine or a static cache to observe it), then with /mt the variable would be silently ignored rather than triggering an error, making such issues harder to detect.

@AR-May
Copy link
Copy Markdown
Member Author

AR-May commented Mar 4, 2026

Ok, discussed on the sync, we would like to warn the customers that by modifying the environment variable they are relying on the inconsistent behavior (that their env variable is set before the static caches are populated). Main points:

  • It should be a warning in the task, not error/exception. Only in mt mode.
  • In the warning include a link to msbuild markdown document that describes differences in behavior and why multiprocess mode was unreliable if some task sets those variables.
  • Document that in case of changes on the machine (like installing a new framework) the processes need to restart to recognize the new locations.
  • Also, there should be examples how to properly set a prohibited environment variable for using in a child process (using the ProcessStartInfo and TaskEnvironment API)
  • DOTNET_HOST_PATH should be prohibited variable.
  • We should be able to populate the set of prohibited names later, as we are unlikely to find all of them at once right away.

AR-May added a commit that referenced this pull request Mar 5, 2026
### Context
Enlighten` GetFrameworkPath` and `GetFrameworkSdkPath` tasks.

### Changes Made
- `GetFrameworkPath` and `GetFrameworkSdkPath` depend on the
`ToolLocationHelper` and `FrameworkLocationHelper` that uses environment
variables to compute the locations of tools and frameworks. They then
store it in process-wide cache. GetFrameworkPath should be safe as used
environment variables are not supposed to be modified during the build
by engine or tasks. To be on the safe side, merge after #13273 that
ensures that those variables does not change.
- added locks to `GetFrameworkSdkPath` to make it thread-safe.

### Testing
Unit tests

### Notes
merge after #13273

We also assume that environment variables that supposed to contain path
to objects, like "COMPLUS_INSTALLROOT" is absolute.
This was an implicit assumption prior this change as well.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@JanProvaznik
Copy link
Copy Markdown
Member

@AR-May do I get the discussion correctly that this should be reworked? Please draft or close the pr in that case

@AR-May AR-May marked this pull request as draft March 19, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants