Repository navigation
Try a transient server before falling back for busy MT builds - #15000
JanProvaznik wants to merge 3 commits into
Conversation
Preserve the existing in-process fallback when the private server cannot be acquired. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 236afeff-bd8f-4e31-ae91-e829fb1727c4
Receiving the warmup result does not mean the server has released its busy mutex. Synchronize before sending the debugger request and preserve diagnostics for unexpected early completion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 236afeff-bd8f-4e31-ae91-e829fb1727c4
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate findings remain in telemetry, framework coverage, and test synchronization.
Pull request overview
This PR retries busy reusable MT builds through a private transient server while preserving fallback behavior.
Changes:
- Adds transient-server retry routing and lifecycle handling.
- Expands retry, fallback, GC, failure, cancellation, and debugger tests.
- Updates server documentation.
File summaries
| File | Summary | Final finding |
|---|---|---|
src/MSBuild/MSBuildClientApp.cs |
Implements transient retry routing. | Moderate (1 vote): Preserve the initial telemetry admission state instead of overwriting hot with cold. |
src/MSBuild.UnitTests/MSBuildServer_Tests.cs |
Synchronizes warm-server debugger coverage. | Moderate (1 vote): Use OutOfProcServerNode.GetBusyServerMutexName(handshake) rather than duplicating the mutex-name formula. |
src/MSBuild.UnitTests/MSBuildClientApp_Tests.cs |
Adds retry and fallback coverage. | Moderate (1 vote): Provide equivalent net472 coverage without relying on Environment.ProcessId. |
documentation/MSBuild-Server.md |
Documents server lifecycle behavior. | Nit (1 vote): Document the additional ShortLived=true contention case. |
Review details
Suppressed comments (4)
documentation/MSBuild-Server.md:27
- This introduces another producer of
ShortLived=true: a reusable/mtrequest that falls back to a private server. The publicMSBuildServerLifecycleEventArgs.ShortLivedcontract still documents the flag as only applying to/mtwith node reuse off, so update that API documentation (and the corresponding server-node summary) with the contention case.
The client determines the requested mode and node-reuse setting from the response-file-aware command-line parse. It sets the `ShutdownAfterBuild` flag on the `ServerNodeBuildCommand` packet for a short-lived server, whether requested by `/mt -nodeReuse:false` or used after resident-server contention. The in-process fallback does not acquire Server GC merely because `/mt` was requested.
src/MSBuild.UnitTests/MSBuildClientApp_Tests.cs:59
Environment.ProcessIdis not available on thenet472target, so the#if !NETFRAMEWORKguard excludes this entire new suite from full-framework testing even though the production retry path is built there (src/Directory.Build.props:37-39). That leaves the busy/launch-contended behavior untested on the framework-specific server/client implementation; use a framework-compatible process-id API and run equivalent coverage fornet472instead of excluding the file.
serverPid.ShouldNotBe(Environment.ProcessId, "The busy resident must not force the MT build into its client.");
src/MSBuild.UnitTests/MSBuildServer_Tests.cs:148
- Use
OutOfProcServerNode.GetBusyServerMutexName(handshake)here instead of duplicating the production mutex-name formula. If the production name changes later, this wait will observe the wrong object and the test can pass while submitting the debugger request during the resident's busy interval.
string busyMutexName = $@"Global\msbuild-server-busy-{handshake.ComputeHash()}";
src/MSBuild/MSBuildClientApp.cs:82
MSBuildClient.ExecuteupdatesKnownTelemetry.PartialBuildTelemetry.InitialMSBuildServerStateon every attempt. The first resident admission recordshot, but this retry uses a private hash and overwrites it withcold, so successful busy-resident builds are reported as cold and the admission condition is lost from telemetry. Preserve the first admission state (or record the transient state separately) when retrying.
exitResult = transientClient.Execute(cancellationToken);
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Expert code review —
|
| Step | Cost |
|---|---|
Attempt 1: ServerIsRunning() + ServerWasBusy() → ServerBusy |
~µs (mutex open) |
Attempt 2: TryLaunchServer() → Process.Start |
tens of ms + host startup |
Attempt 2: TryConnectToServer(NewServerConnectionTimeoutMilliseconds) |
up to 5,000 ms (MSBuildClient.cs:37, used at :259) |
Fallback: full in-process MSBuildApp.Execute |
unchanged |
A user whose transient server fails to come up waits ~5 s of pure dead time before their build even starts, on a build that previously started immediately. Plus a stray server lingering up to 10 s before self-terminating (MSBuildClient.cs:612-614). The transient attempt connects to a process it just launched with a private identity, so a shorter budget than 5 s seems safe.
M2 — The whole new test file is excluded from .NET Framework, where the production code ships
MSBuildClientApp_Tests.cs:4 (#if !NETFRAMEWORK) / :196. The csproj targets $(RuntimeOutputTargetFrameworks) = net472;net10.0 on Windows, and the retry block at MSBuildClientApp.cs:74-83 is not #if-guarded — nor is anything on the path to it (IsMultiThreadedEnabled, ShouldUseMSBuildServer, the MSBuildClientApp.Execute call at XMake.cs:345). So a new process-spawning path ships in net472 MSBuild.exe with zero test coverage there.
Also inconsistent with both siblings: MSBuildServer_Tests.cs has no file-level guard (only individual tests use #if NET), and MSBuildMultithreaded_Tests.cs has none at all.
Either drop the file-level guard and guard only the specific tests that genuinely can't run on Framework, or #if-guard the production block too so code and tests agree.
M3 — Mutex name formats duplicated into tests; production lacks a helper for the launch mutex
MSBuildClientApp_Tests.cs:44, 88, 150-151; MSBuildServer_Tests.cs:148; production MSBuildClient.cs:526
| Name | Production source | Helper? |
|---|---|---|
Global\msbuild-server-running-{hash} |
OutOfProcServerNode.cs:207 |
✅ GetRunningServerMutexName |
Global\msbuild-server-busy-{hash} |
OutOfProcServerNode.cs:210 |
✅ GetBusyServerMutexName |
Global\msbuild-server-launch-{hash} |
MSBuildClient.cs:526 |
❌ inline literal in production |
This is a silent-false-pass hazard rather than a cosmetic one: renaming any of these compiles cleanly and turns the tests into no-ops — they'd create a mutex nobody looks at, the resident would never appear busy, the retry would never trigger, and every assertion would still pass because the build succeeds either way.
Suggest adding GetServerLaunchMutexName(ServerNodeHandshake) next to the other two, calling it from TryLaunchServer, and having the tests use the production helpers (ServerNodeHandshake_Tests.cs:95-96 already does this).
M4 — ChangeWave not needed, but an escape hatch is
A ChangeWave is not required: /mt is fully opt-in today (Traits.cs:46,52, or an explicit -mt), and changing behavior inside an opt-in preview feature is legitimately exempt.
An escape hatch still is, for two reasons: the new stderr output and changed reason codes in H2 are observable by /mt users today; and the stated trajectory is to default /mt on, at which point H1 becomes default behavior on every CI agent with no way to disable it and no ChangeWave to opt out of. Cheaper to add now.
Low / nits
- L1 —
MSBuildClientissealedbut notIDisposable, yet its ctor allocates aNamedPipeClientStream+ packet pump (:198-216). On theServerBusypathReadPacketsLoop— the only place that disposes the pump — never runs. Pre-existing, but this change now abandons two clients instead of one before running a full build in the same process. - L2 —
!cancellationToken.IsCancellationRequestedis checked once, thenTryConnectToServertakes no token, so Ctrl-C isn't observed for up to 5 s. Orphaning itself is bounded (transient servers self-exit within 10 s via the forcedMSBUILDNODECONNECTIONTIMEOUTatMSBuildClient.cs:610-614), so this is responsiveness only. Re-checking the token right afterTryLaunchServerwould shrink the window. - L3 —
int.Parse/bool.Parseon file content with noCultureInfo.InvariantCultureand noprobe.Lengthguard (:59-60, 64, 107). If the probe project fails beforeWriteLinesToFile, this surfaces as a bareFormatExceptionwith no indication of what the build did. - L4 — Fixed 10 s timeouts (
:71, 190;MSBuildServer_Tests.cs:153) bracket a real server launch, a full build, and teardown, in tests that deliberately create contention. Thin on a loaded CI agent; notexunit.runner.jsonsetslongRunningTestSeconds: 60. - L5 —
if (Environment.ProcessorCount > 1)(:62-65) silently skips the Server GC assertion — the entire motivation for the change — and still reports green. Worth anAssert.Skipor at least an_output.WriteLine. - N1 — Terminology drift: the same concept is transient (code), private/short-lived (doc), and overflow (test message at
:72). "Transient" matches the existing constant. - N2 — The
!shutdownServerAfterBuildguard is unreachable today: such a client already has a private identity, so it can never observeServerBusy. Correct defensive coding, but worth one comment line so the next reader doesn't try to cover it with a test. - N3 —
env.WithTransientProcess(serverPid)is registered after assertions that could throw (:59-61). Low real risk since the transient self-terminates, and if line 60 fires thenserverPid == Environment.ProcessIdanyway — but registering first is free. - N4 —
MSBuild-Server.mdhas a duplicate list number adjacent to the edit (2. Initiate handshake/2. Issue build command). Pre-existing, but you're editing that list. - N5 — If H1 ships as-is, the doc should state that the private server is not throttled and that N contended
/mtbuilds produce N server processes.
Things I checked that turned out fine
- Test parallelism / global mutex collisions — not an issue.
src/Shared/UnitTests/xunit.runner.jsonsetsmaxParallelThreads: 1andparallelizeTestCollections: falseand is linked into this csproj. Cross-process collision is prevented by the freshMSBUILDNODEHANDSHAKESALTGUID, whichHandshake's ctor reads live from the environment (Handshake.cs:93) rather than the cachedTraitsstatic — so the mutex names really are per-test-unique. Nice. ResetInstance_ForUnitTestsOnlyleaking static state — correctly reverted;TestEnvironment.Cleanup()calls it unconditionally (TestEnvironment.cs:123) and the test holds the env in ausing.- Orphaned transient servers — bounded to 10 s, as above.
- Warm-server PID leak from the new
if (!warmServer)— no leak; the PID is registered earlier in the new warm-server block (MSBuildServer_Tests.cs:140) from the samepidFieldvalue. The guard just avoids a duplicate registration. - Doc claim about node-reuse semantics — accurate.
_shutdownServerAfterBuildfeeds only theServerNodeBuildCommandflag (MSBuildClient.cs:668);commandLineArgspass through unchanged, so the build keeps its-nodeReuse. Server side,OutOfProcServerNode.cs:510setsBuildCompleteand:487publishesCurrentBuildShutsDownServerNode, whichXMake.cs:2151-2158turns intoShortLived = true. - The
MSBuildServer_Testswarm-server change is a correct fix for a genuine race, and testing real contention against real processes rather than mocking it away is the right call.
Summary
Before merge: H2 (preserve the resident's outcome; stop reporting server-crashed; restore InitialMSBuildServerState; fix the test assertion), H1 (at minimum an env-var kill switch + document the unthrottled behavior), M2 (align the test guard with the production guard).
Strongly recommended: the pre-spawn resident poll via the existing ServerIsBusyWithWaitAndRetry (H1.2 / M1) — it's the cheapest credible mitigation and it's already sitting in the file — plus M3.
Follow-ups, non-blocking: L1–L5, N1–N5, and a tracked issue for a real throttle regardless of which mitigation lands now.
🤖 Generated with GitHub Copilot CLI
|
with the expert code review I am starting to doubt if this is actually desirable
|
|
so M1 sounds worst, |
|
I don't think I'm too worried about the 5s worst-case wait where the transient server crashes, but I'm not going to push too hard for this either. Agreed that H1 is bogus and H2 is valid. |
Related issue(s): Follow-up to #14647.
Context
Two overlapping plain
-mtbuilds still contend for the resident server. The second currently falls back into its client, losing the Server GC selected when launching an MT server. #14647 isolated explicitly transient builds but did not change this resident-busy path.Changes Made
When resident admission returns
ServerBusyfor a reusable MT request, make one attempt to launch and connect to a private transient server using the existing per-invocation identity and shutdown machinery.Keep the original command line and node-reuse setting. Skip the extra attempt when cancellation is already requested. If transient acquisition fails, retain the existing in-process fallback and report the transient attempt's failure reason. A build already executed by a server is never replayed.
Add deterministic coverage for busy and launch-contended residents, Server GC, transient exit and binlog lifecycle events, failed-build result propagation, failed-launch fallback, non-MT behavior, and pre-cancelled requests. Update the server documentation.
Fix the warm-server debugger test's admission race exposed in Linux CI: receiving the warmup result does not mean the resident has released its busy mutex. Wait for that mutex to disappear before submitting the debugger request, register the resident for cleanup before waiting, and report the client exit type if the request unexpectedly finishes. The original debugger assertions remain intact.
Compatibility
Scoped to experimental, opt-in MT behavior; no additional ChangeWave. Non-MT routing, explicitly disabled server use, and
-mt -nr:falseremain unchanged. No new user-facing diagnostics, public APIs, or wire-format changes. The final in-process fallback remains available and does not guarantee Server GC. The Linux follow-up changes test synchronization only, not production admission behavior.Testing
net11.0andnet472.11.0.100-rc.1.26420.103: 20 single-CPU, coverage-enabled runs passed all 280 selected cases, including 20 warm and 20 cold debugger cases, 40 in-process debugger opt-out cases, and 200 retry/fallback cases. No skipped cases.ServerBusy; with the same delay, the synchronized test passed. Removed the diagnostic delay, rebuilt, and warm/cold cases passed again. No diagnostic delay is part of this PR. The unmodified baseline did not fail naturally in 20 runs; the controlled experiment demonstrates the race mechanism rather than claiming a natural reproduction of the CI run.-mtexperiment with node reuse left at its default.GCSettings.IsServerGCwas read inside the build process, not a TaskHost, with no GC environment overrides.11.0.100-rc.2.26459.117TrueFalseTrueTrueThe second changed build's diagnostic log records a server started for this build only; its process has a unique
/serverinstanceidand exits afterward.CI build 1591186's Linux Core failure was
ServerOnlyDebuggingWaitsBeforeEvaluation(warmServer: True); the cold case and all ten new retry/fallback cases passed. The follow-up above addresses its result-before-idle race. Fresh CI is pending after the fix.Earlier local caveat:
MultiThreadedServerProcessUsesServerGC, which launches from the unit-test output layout, failed in isolation with an uncontendedUnableToConnect. It does not enter the new busy-retry branch. The bootstrap-based coverage and live probe pass; that separate layout failure is not changed here. No full local suite was run.Dependencies and Follow-up
Uses the already-merged #14647 transient-server infrastructure. Full TaskHost ownership and handling a resident originally started with Workstation GC remain outside this change.