Skip to content
Draft
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
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" — -mt switch was on the command line 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>
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
31 changes: 31 additions & 0 deletions src/MSBuild.UnitTests/MSBuildServer_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,37 @@ public void ServerShouldStartWhenBuildIsInteractive()
serverIsDown.ShouldBeTrue();
}

[Fact]
public void ServerStartsWhenMtPresentEvenWithoutEnvVar()
{
// Regression test for the "-mt implies MSBuild Server" routing decision
// (investigation #9379, ShouldUseMSBuildServer / CommandLineContainsMultiThreadedSwitch).
// 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.
_env.SetEnvironmentVariable("MSBUILDUSESERVER", null);

// 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 ");

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

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

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

[Fact]
public void PropertyMSBuildStartupDirectoryOnServer()
{
Expand Down
109 changes: 108 additions & 1 deletion src/MSBuild/XMake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,16 @@ public static int Main(string[] args)

int exitCode;
if (
Environment.GetEnvironmentVariable(Traits.UseMSBuildServerEnvVarName) == "1" &&
ShouldUseMSBuildServer(args, out string serverEnableReason) &&
!Traits.Instance.EscapeHatches.EnsureStdOutForChildNodesIsPrimaryStdout &&
CanRunServerBasedOnCommandLineSwitches(args))
{
Console.CancelKeyPress += Console_CancelKeyPress;

if (KnownTelemetry.PartialBuildTelemetry != null)
{
KnownTelemetry.PartialBuildTelemetry.ServerEnableReason = serverEnableReason;
}

// Use the client app to execute build in msbuild server. Opt-in feature.
exitCode = ((s_initialized && MSBuildClientApp.Execute(args, s_buildCancellationSource.Token) == ExitType.Success) ? 0 : 1);
Expand All @@ -337,6 +341,109 @@ public static int Main(string[] args)
return exitCode;
}

/// <summary>
/// Returns true if MSBuild Server should be used for this invocation.
/// </summary>
/// <remarks>
/// Decision tree:
/// <list type="bullet">
/// <item><c>MSBUILDUSESERVER=1</c> → use server (existing explicit opt-in).</item>
/// <item><c>MSBUILDUSESERVER=0</c> → do NOT use server (explicit opt-out, takes precedence over -mt).</item>
/// <item><c>MSBUILDUSESERVER</c> unset AND command line contains <c>-mt</c>/<c>-multithreaded</c> → use server.
/// Rationale: <c>-mt</c> users already accept process-shared state (in-proc thread workers
/// instead of multi-process worker nodes), so server reuse barely adds risk and recovers the
/// per-invocation JIT/SDK-resolution warm-up cost that <c>-mt</c> would otherwise pay every
/// build (because <c>-mt</c> shares the entry process with the build instead of the workers).
/// See <see href="https://github.com/dotnet/msbuild/issues/9379">#9379</see>.</item>
/// <item>Otherwise → no server (existing default).</item>
/// </list>
/// </remarks>
/// <param name="args">Raw command-line arguments.</param>
/// <param name="serverEnableReason">Telemetry-friendly reason: "EnvVar", "ImpliedByMt", or empty when not enabled.</param>
/// <returns>True if server should be used.</returns>
private static bool ShouldUseMSBuildServer(string[] args, out string serverEnableReason)
{
serverEnableReason = string.Empty;
string envVar = Environment.GetEnvironmentVariable(Traits.UseMSBuildServerEnvVarName);

if (envVar == "1")
{
serverEnableReason = "EnvVar";
return true;
}

// Explicit opt-out: MSBUILDUSESERVER=0 always wins, even if -mt is on the command line.
if (envVar == "0")
{
return false;
}

// Implicit opt-in via -mt. Best-effort detection that does not throw — if argument
// parsing fails for any reason we conservatively decline the implicit opt-in (the
// explicit MSBUILDUSESERVER=1 path is unaffected).
if (CommandLineContainsMultiThreadedSwitch(args))
{
serverEnableReason = "ImpliedByMt";
return true;
}

return false;
}

/// <summary>
/// Lightweight check for the presence of <c>-mt</c>/<c>-multithreaded</c> on the command
/// line, suitable for the early routing decision in <see cref="ShouldUseMSBuildServer"/>.
/// </summary>
/// <remarks>
/// Does NOT do full command-line parsing (which is expensive and runs again later).
/// Intentionally conservative: returns false on any parse problem.
/// </remarks>
private static bool CommandLineContainsMultiThreadedSwitch(string[] args)
{
if (args is null || args.Length == 0)
{
return false;
}

foreach (string arg in args)
{
if (string.IsNullOrEmpty(arg) || arg.Length < 2)
{
continue;
}

// Switches start with - or / (and on Unix, - is the only convention).
char prefix = arg[0];
if (prefix != '-' && prefix != '/')
{
continue;
}

// Strip leading - or /, then split on : to get the switch name (ignore any value/parameters).
string body = arg.Substring(1);
int colonIndex = body.IndexOf(':');
string switchName = colonIndex >= 0 ? body.Substring(0, colonIndex) : body;

if (switchName.Equals("mt", StringComparison.OrdinalIgnoreCase) ||
switchName.Equals("multithreaded", StringComparison.OrdinalIgnoreCase))
{
// Honor an explicit ":false" value if present (matches ProcessBooleanSwitch semantics).
if (colonIndex >= 0)
{
string value = body.Substring(colonIndex + 1);
if (value.Equals("false", StringComparison.OrdinalIgnoreCase) ||
value.Equals("0", StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
}

return false;
}

/// <summary>
/// Returns true if arguments allows or make sense to leverage msbuild server.
/// </summary>
Expand Down
Loading