Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions documentation/specs/multithreading/multithreaded-msbuild.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,18 @@ deactivate Thread1_Project1
## MSBuild Server integration

To avoid regressing CLI incremental build performance, it is essential to fully support the MSBuild server feature in multithreaded mode. Out-of-process nodes offer significant benefits by preserving caches across build command executions when node reuse is enabled. However, caches in the entry process are lost at the end of each build unless the MSBuild server feature is used. In multiprocess execution, that is a fraction of the caches and processes, but with multithreading, it is the entire process. As a result, opting into multithreading on the command line should automatically enable MSBuild server.

### `-mt` implies MSBuild Server

When this is a `-mt` (`-multithreaded`) build and `MSBUILDUSESERVER` is unset, MSBuild Server is engaged automatically. Setting `MSBUILDUSESERVER` to any explicit value opts out of the implicit path: `1` keeps the server on, and any other value (e.g. `0`, `false`) keeps it off, matching the pre-existing behavior. `-mt` is itself experimental and opt-in, so the implicit server engagement is not separately gated.

| `MSBUILDUSESERVER` | `-mt` build | Server engaged? | Telemetry `ServerEnableReason` |
|---|---|---|---|
| `1` | (any) | Yes | `EnvVar` |
| any other non-empty value (`0`, `false`, …) | (any) | No (explicit opt-out) | β€” |
| unset | yes | **Yes** | `ImpliedByMt` |
| unset | no | No | β€” |

`-mt` is detected from the full, response-file-aware command-line parse, so it counts wherever it is supplied β€” command line, auto-response file, a project `Directory.Build.rsp`, an `@response` file, or `MSBUILDFORCEMULTITHREADED`. The same value decides whether an engaged server is launched with [Server GC](../../MSBuild-Server.md#garbage-collection).


3 changes: 2 additions & 1 deletion src/Build/BackEnd/Client/MSBuildClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,8 @@ private ServerNodeBuildCommand GetServerNodeBuildCommand()
: new PartialBuildTelemetry(
startedAt: KnownTelemetry.PartialBuildTelemetry.StartAt.GetValueOrDefault(),
initialServerState: KnownTelemetry.PartialBuildTelemetry.InitialMSBuildServerState,
serverFallbackReason: KnownTelemetry.PartialBuildTelemetry.ServerFallbackReason);
serverFallbackReason: KnownTelemetry.PartialBuildTelemetry.ServerFallbackReason,
serverEnableReason: KnownTelemetry.PartialBuildTelemetry.ServerEnableReason);

return new ServerNodeBuildCommand(
_commandLine,
Expand Down
1 change: 1 addition & 0 deletions src/Build/BackEnd/Node/OutOfProcServerNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ private void HandleServerNodeBuildCommand(ServerNodeBuildCommand command)
buildTelemetry.StartAt = command.PartialBuildTelemetry.StartedAt;
buildTelemetry.InitialMSBuildServerState = command.PartialBuildTelemetry.InitialServerState;
buildTelemetry.ServerFallbackReason = command.PartialBuildTelemetry.ServerFallbackReason;
buildTelemetry.ServerEnableReason = command.PartialBuildTelemetry.ServerEnableReason;
}

// Also try our best to increase chance custom Loggers which use Console static members will work as expected.
Expand Down
7 changes: 6 additions & 1 deletion src/Build/BackEnd/Node/PartialBuildTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ internal sealed class PartialBuildTelemetry : ITranslatable
private DateTime _startedAt = default;
private string? _initialServerState = default;
private string? _serverFallbackReason = default;
private string? _serverEnableReason = default;

public PartialBuildTelemetry(DateTime startedAt, string? initialServerState, string? serverFallbackReason)
public PartialBuildTelemetry(DateTime startedAt, string? initialServerState, string? serverFallbackReason, string? serverEnableReason)
{
_startedAt = startedAt;
_initialServerState = initialServerState;
_serverFallbackReason = serverFallbackReason;
_serverEnableReason = serverEnableReason;
}

/// <summary>
Expand All @@ -35,11 +37,14 @@ private PartialBuildTelemetry()

public string? ServerFallbackReason => _serverFallbackReason;

public string? ServerEnableReason => _serverEnableReason;

public void Translate(ITranslator translator)
{
translator.Translate(ref _startedAt);
translator.Translate(ref _initialServerState);
translator.Translate(ref _serverFallbackReason);
translator.Translate(ref _serverEnableReason);
}

internal static PartialBuildTelemetry FactoryForDeserialization(ITranslator translator)
Expand Down
11 changes: 11 additions & 0 deletions src/Framework/Telemetry/BuildTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ internal class BuildTelemetry : TelemetryBase, IActivityTelemetryDataHolder
/// </summary>
public string? ServerFallbackReason { get; set; }

/// <summary>
/// Why MSBuild server was engaged for this invocation. One of:
/// "EnvVar" β€” MSBUILDUSESERVER=1 was set (explicit opt-in)
/// "ImpliedByMt" β€” this is a multithreaded (-mt) build and MSBUILDUSESERVER was unset
/// null β€” server was not engaged
/// Lets dashboards measure adoption of the implicit -mt-implies-server path separately
/// from the explicit env-var path.
/// </summary>
Comment thread
JanProvaznik marked this conversation as resolved.
public string? ServerEnableReason { get; set; }

/// <summary>
/// Version of MSBuild.
/// </summary>
Expand Down Expand Up @@ -200,6 +210,7 @@ public override IDictionary<string, string> GetProperties()
AddIfNotNull(InitialMSBuildServerState);
AddIfNotNull(ProjectPath != null ? Path.GetFileName(ProjectPath) : null, nameof(ProjectPath));
AddIfNotNull(ServerFallbackReason);
AddIfNotNull(ServerEnableReason);
AddIfNotNull(SanitizeBuildTarget(BuildTarget), nameof(BuildTarget));
AddIfNotNull(BuildEngineVersion?.ToString(), nameof(BuildEngineVersion));
AddIfNotNull(BuildSuccess?.ToString(), nameof(BuildSuccess));
Expand Down
71 changes: 71 additions & 0 deletions src/MSBuild.UnitTests/MSBuildServer_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,77 @@ public void ServerShouldStartWhenBuildIsInteractive()
serverIsDown.ShouldBeTrue();
}
Comment thread
JanProvaznik marked this conversation as resolved.

[Fact]
public void ServerStartsWhenMtPresentEvenWithoutEnvVar()
{
// Regression test for the "-mt implies MSBuild Server" routing decision
// (investigation #9379, ShouldUseMSBuildServer / IsMultiThreadedRequested).
// When MSBUILDUSESERVER is unset and the user passes -mt, the client should engage
// the server automatically. Verified by running two builds back-to-back and asserting
// the server process PID is the SAME for both β€” server reuse is the unique signature
// of MSBuild server engagement (a non-server build would always get a fresh worker PID).
TransientTestFile project = _env.CreateFile("testProject.proj", printPidContents);
// Explicitly clear MSBUILDUSESERVER so we test the -mt-implies-server path, and isolate this
// test's server with a unique handshake salt so it can't reuse/shut down an unrelated server.
_env.SetEnvironmentVariable("MSBUILDUSESERVER", null);
_env.SetEnvironmentVariable("MSBUILDNODEHANDSHAKESALT", Guid.NewGuid().ToString("N"));

// Make sure we start with no server running.
MSBuildClient.ShutdownServer(CancellationToken.None);

string output1 = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path + " -mt", out bool success1, false, _output);
success1.ShouldBeTrue();
int serverPid1 = ParseNumber(output1, "Server ID is ");
// Register cleanup before any assertion so the server does not leak if an assertion throws.
_env.WithTransientProcess(serverPid1);

string output2 = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path + " -mt", out bool success2, false, _output);
success2.ShouldBeTrue();
int serverPid2 = ParseNumber(output2, "Server ID is ");

Comment thread
JanProvaznik marked this conversation as resolved.
serverPid1.ShouldBe(serverPid2, "When -mt implies server, two consecutive builds should reuse the same server process. PIDs were " + serverPid1 + " and " + serverPid2 + ".");

// Clean up the server we spun up.
MSBuildClient.ShutdownServer(CancellationToken.None);
}

[Fact]
public void ServerStartsWhenMtInResponseFileEvenWithoutEnvVar()
{
// Regression test for rainersigwald's review concern (#13758): -mt enabled via a response file
// (here a project Directory.Build.rsp) - not on the command line - must still implicitly engage the
// server. This is the expected dogfooding mechanism, so the authoritative, response-file-aware parse
// drives the decision. Verified the same way as ServerStartsWhenMtPresentEvenWithoutEnvVar: two builds
// back-to-back reuse the SAME server PID, the unique signature of server engagement.
TransientTestFolder folder = _env.CreateFolder();
TransientTestFile project = _env.CreateFile(folder, "testProject.proj", printPidContents);
// -mt comes ONLY from the response file; it is NOT passed on the command line below.
_env.CreateFile(folder, "Directory.Build.rsp", "-mt");

// Explicitly clear MSBUILDUSESERVER so we test the implicit path, and isolate this test's server with
// a unique handshake salt so it can't reuse/shut down an unrelated server.
_env.SetEnvironmentVariable("MSBUILDUSESERVER", null);
_env.SetEnvironmentVariable("MSBUILDNODEHANDSHAKESALT", Guid.NewGuid().ToString("N"));

// Make sure we start with no server running.
MSBuildClient.ShutdownServer(CancellationToken.None);

string output1 = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out bool success1, false, _output);
success1.ShouldBeTrue();
int serverPid1 = ParseNumber(output1, "Server ID is ");
// Register cleanup before any assertion so the server does not leak if an assertion throws.
_env.WithTransientProcess(serverPid1);

string output2 = RunnerUtilities.ExecMSBuild(BuildEnvironmentHelper.Instance.CurrentMSBuildExePath, project.Path, out bool success2, false, _output);
success2.ShouldBeTrue();
int serverPid2 = ParseNumber(output2, "Server ID is ");

serverPid1.ShouldBe(serverPid2, "When -mt from a response file implies server, two consecutive builds should reuse the same server process. PIDs were " + serverPid1 + " and " + serverPid2 + ".");

// Clean up the server we spun up.
MSBuildClient.ShutdownServer(CancellationToken.None);
}

[Fact]
public void PropertyMSBuildStartupDirectoryOnServer()
{
Expand Down
22 changes: 22 additions & 0 deletions src/MSBuild.UnitTests/XMake_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3217,6 +3217,28 @@ public void MSBuildForceMultiThreadedEnvironmentVariableNonOneValueDoesNotEnable
MSBuildApp.IsMultiThreadedEnabled(switches).ShouldBeFalse();
}

[Theory]
// MSBUILDUSESERVER value, -mt build, expected server use, expected reason.
[InlineData("1", false, true, "EnvVar")]
[InlineData("1", true, true, "EnvVar")]
[InlineData("0", true, false, "")]
[InlineData("0", false, false, "")]
[InlineData("false", true, false, "")] // any explicit non-"1" value opts out
[InlineData("true", true, false, "")]
[InlineData(null, true, true, "ImpliedByMt")]
[InlineData(null, false, false, "")]
[InlineData("", true, true, "ImpliedByMt")] // empty is treated as unset
public void ShouldUseMSBuildServerDecisionTree(string useServerValue, bool isMultiThreaded, bool expectedUseServer, string expectedReason)
{
using TestEnvironment testEnvironment = TestEnvironment.Create();
testEnvironment.SetEnvironmentVariable("MSBUILDUSESERVER", useServerValue);

bool useServer = MSBuildApp.ShouldUseMSBuildServer(isMultiThreaded, out string reason);

useServer.ShouldBe(expectedUseServer);
reason.ShouldBe(expectedReason);
}

private string CopyMSBuild()
{
string dest = null;
Expand Down
Loading
Loading