Repository navigation
ChangeWaves state is not reset between builds in reused nodes (worker nodes and MSBuild Server) #14547
Description
Issue Description
ChangeWaves caches the parsed value of MSBUILDDISABLEFEATURESFROMVERSION on first use and never re-reads it. In any process that serves more than one build — a reused out-of-proc worker node (node reuse is on by default) or an MSBuild Server node — later builds silently inherit the change-wave setting of the first build that process served.
The worker-node variant is the more serious one: because the entry-point process is fresh each invocation but worker nodes are reused, projects within a single build can disagree about which change waves are enabled, depending on which node happened to evaluate them.
Traits.UpdateFromEnvironment() is already called when a new build is queued on both the server node and the out-of-proc node. ChangeWaves needs the same treatment.
Steps to Reproduce
Four trivial child projects and a traversal project that builds them in parallel:
Child1.proj … Child4.proj (identical apart from the number):
<Project>
<Target Name="Probe">
<Message Importance="High" Text="CHILD1 WAVE=[$(MSBuildDisableFeaturesFromVersion)]" />
</Target>
</Project>All.proj:
<Project>
<ItemGroup><C Include="$(MSBuildThisFileDirectory)Child*.proj" /></ItemGroup>
<Target Name="Probe">
<MSBuild Projects="@(C)" Targets="Probe" BuildInParallel="true" />
</Target>
</Project>Run twice in the same session with different in-rotation wave values (server explicitly off, so this is purely the worker-node path):
set MSBUILDUSESERVER=0
set MSBUILDDISABLEFEATURESFROMVERSION=17.12
dotnet msbuild All.proj -t:Probe -m:4 -v:m
set MSBUILDDISABLEFEATURESFROMVERSION=18.5
dotnet msbuild All.proj -t:Probe -m:4 -v:m
Expected Behavior
The second invocation reports 18.5 for every child, because that is what the environment says.
Actual Behavior
The second invocation reports a mix: the project evaluated in the fresh entry-point process sees the new value, and the projects evaluated on reused worker nodes see the previous build's value.
set=17.12 -> 1:17.12 2:17.12 3:17.12 4:17.12
set=18.5 -> 1:18.5 2:17.12 3:17.12 4:17.12
^^^^^^^^^^^^^^^^^^^^^^^^^ stale, from the previous build
Reproduced identically on 3 consecutive trials.
The same staleness occurs with MSBuild Server (MSBUILDUSESERVER=1), where it is consistent rather than mixed because a single server process serves the whole build:
MSBUILDDISABLEFEATURESFROMVERSION |
Without server | With server |
|---|---|---|
17.0 |
17.10 |
17.10 |
99.0 |
18.10 |
17.10 (stuck on the first build's value) |
17.0 |
17.10 |
17.10 |
A fresh server session reads the value correctly, so the leak is scoped to process lifetime.
This matters more now that recent .NET SDKs enable MSBuild Server by default (verified on 11.0.100-preview.7.26377.110), but note the worker-node variant needs no server at all and affects the default multi-proc configuration.
Analysis
ChangeWaves.ShouldApplyChangeWave only permits re-evaluation while the wave has never been successfully applied:
private static bool ShouldApplyChangeWave
{
get
{
return ConversionState == ChangeWaveConversionState.NotConvertedYet || _cachedWave == null;
}
}Once a build applies a wave, _cachedWave is non-null and ConversionState is no longer NotConvertedYet, so ApplyChangeWave never re-reads the environment variable for the remaining lifetime of the process. The only reset path, ChangeWaves.ResetStateForTests, is test-only (and sets _runningTests under #if DEBUG), and nothing in the node or server request paths calls it.
There is also an ordering hazard worth fixing at the same time: the general refresh mechanism depends on the state that is going stale.
public static void UpdateFromEnvironment()
{
// Re-create Traits instance to update values in Traits according to current environment.
if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave17_10))
{
_instance = new Traits();
}
}If a build ever disables Wave17_10, that gate reads stale ChangeWaves state and Traits stops refreshing for every subsequent build in the process too.
Suggested Fix
Reset ChangeWaves wherever Traits.UpdateFromEnvironment() is already called when a new build is queued:
src/Build/BackEnd/Node/OutOfProcNode.cs:510src/Build/BackEnd/Node/OutOfProcNode.cs:749src/Build/BackEnd/Node/OutOfProcServerNode.cs:398
This needs a non-test reset entry point on ChangeWaves (ResetStateForTests sets _runningTests in DEBUG, so it is not appropriate to call from product code), and ChangeWaves must be reset before Traits.UpdateFromEnvironment() so the Wave17_10 gate is evaluated against fresh state.
Suggested regression tests:
- Two builds in one server session with different
MSBUILDDISABLEFEATURESFROMVERSIONvalues; assert the second observes its own value. - A multi-proc build with node reuse, run twice with different values; assert all projects in the second build agree and observe the new value.
More broadly, it is worth auditing the remaining environment-derived static state reachable from a build for the same cached-on-first-use-with-no-reset pattern, and giving reused nodes a single explicit "reset per request" path rather than the current scattered resets.
Versions & Configurations
- Reproduced on
dotnet/msbuildmainat867c13677e(local build) and on .NET SDK11.0.100-preview.7.26377.110. - Windows 11 (10.0.26200).
- Affects both the reused out-of-proc worker node path (no server required, default multi-proc configuration) and MSBuild Server.
Activity
JanProvaznik commentedon Jul 28, 2026
step 1: document MicrosoftDocs/visualstudio-docs#11409
step 2: changewave should be part of handshake