Skip to content

Fix MSBuild Server client dropping build result under WaitAny race (#14172) - #14251

Merged
JanProvaznik merged 2 commits into
dotnet:mainfrom
JanProvaznik:janprovaznik/fix-server-client-result-race-14172
Jul 9, 2026
Merged

JanProvaznik merged 2 commits into
dotnet:mainfrom
JanProvaznik:janprovaznik/fix-server-client-result-race-14172

Conversation

@JanProvaznik

Copy link
Copy Markdown
Member

Summary

Addresses #14172: on Linux with MSBuild Server (/mt), a build that succeeds intermittently makes the entry process exit 1 with the final Build succeeded. summary dropped (no error printed).

Root cause

The MSBuild Server client's packet loop waits on WaitAny([cancellation, PacketPumpCompleted, PacketReceivedEvent]):

  • PacketPumpCompleted is a sticky ManualResetEvent at a lower WaitAny index than PacketReceivedEvent.
  • The packet pump enqueues the final ServerNodeBuildResult (setting PacketReceivedEvent) and then, a few instructions later, sets PacketPumpCompleted.
  • If the main loop reaches WaitAny when both are already signaled, it returns the lower index and runs HandlePacketPumpCompleted, which set _buildFinished = true without draining the queue — dropping the queued ServerNodeBuildResult and any trailing console writes (e.g. the Build succeeded. summary).
  • With no result processed, MSBuildAppExitTypeString stays null, so MSBuildClientApp.Execute returns MSBuildApp.ExitType.MSBuildClientFailure → the process exits 1 on a successful build.

This regressed in #7852, which changed the index‑1 wait handle from an error‑only event to the always‑set PacketPumpCompleted without making the handler drain the received-packets queue. Before that change, the low‑priority handle only fired on genuine errors, so a successful result was always drained via the PacketReceivedEvent branch.

Fix

Drain the received-packets queue before honoring PacketPumpCompleted:

  • Extracted the loop into ProcessPacketsUntilBuildFinished and added a shared DrainPacketQueue helper used by both the PacketReceivedEvent and PacketPumpCompleted branches.
  • A delivered result is honored even if the pump also recorded an exception (DrainPacketQueue, then if (!_buildFinished) HandlePacketPumpCompleted(...)), and queued console output is flushed on all completion paths.
  • Exception propagation for a genuine mid-build disconnect (no result delivered) is preserved.

Testing

  • Deterministic reproduction (both Windows and Linux): with an env‑gated stall that arranges the main loop to reach WaitAny after both events are set (the descheduling that CPU saturation causes naturally), the pre‑fix binary reproduces the exact symptom — exit 1, Build succeeded. dropped, no error, output truncated, with the trace confirming the ServerNodeBuildResult was still queued. With the fix, the same scenario exits 0 with the summary shown, and normal builds are unaffected. (Scaffolding not included in this PR.)
  • Regression unit test (MSBuildClient_Tests.ProcessPackets_WhenPumpCompletedRacesQueuedResult_DoesNotDropBuildResult): drives the real loop with the result + summary queued and both events pre‑signaled; fails on the pre‑fix loop, passes on the fixed one.

Notes on reproduction confidence

The field symptom only reproduces naturally under heavy CPU load on ~32‑core Linux (~2.3%); it did not reproduce end‑to‑end on a 16‑vCPU box. The mechanism is confirmed by the deterministic forced‑race and by ruling out the two competing hypotheses in code (the server writes the result before closing the pipe — Join() before Dispose() with drain‑on‑terminate — so it is always delivered; and the build's exit type is frozen before the result is sent, so late node/TaskHost teardown cannot corrupt it). A failing‑build client MSBUILDDEBUGCOMM trace would make the attribution airtight; this change removes a real latent defect matching the symptom with no behavioral regression.

JanProvaznik and others added 2 commits July 2, 2026 17:19
…otnet#14172)

The MSBuild Server client's packet loop waits on WaitAny([cancel, PacketPumpCompleted, PacketReceivedEvent]). PacketPumpCompleted is a sticky ManualResetEvent at a lower index than PacketReceivedEvent, and the pump sets it immediately after enqueuing the final ServerNodeBuildResult. If the main loop reaches WaitAny after both are signaled, it took the completed branch, which set _buildFinished without draining the queue - dropping the build result (and trailing console writes such as the 'Build succeeded.' summary). The result's exit type was then null, so MSBuildClientApp returned MSBuildClientFailure and the process exited 1 on a successful build.

This regressed in dotnet#7852, which changed the index-1 handle from an error-only event to the always-set PacketPumpCompleted without making the handler drain the queue.

Fix: drain the received-packets queue before honoring PacketPumpCompleted (extracted into ProcessPacketsUntilBuildFinished + a shared DrainPacketQueue helper). A delivered result is honored even if the pump also recorded an exception, and queued console output is flushed on all paths. Adds a deterministic regression test that fails on the pre-fix loop and passes on the fixed one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… is forwarded

The test seeded a 'Build succeeded.' console write before the build result but
only asserted the exit result, not the actual user-visible symptom dotnet#14172 caused
(the dropped summary). Capture Console.Out and assert the trailing console packet
is flushed after the result is drained; this also keeps the seeded write out of
the real test-run output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@JanProvaznik

Copy link
Copy Markdown
Member Author

Expert code review — 24-dimension analysis

Reviewed with the repo's expert-reviewer methodology (.github/agents/expert-reviewer.agent.md). Verdict: approve. The production change is a correct, minimal, root-cause fix with no regression risk and no convention violations — no Critical/High/Medium findings.

Why it's correct

Draining the received-packets queue in both WaitAny branches makes the outcome independent of WaitAny index ordering. Because PacketPumpCompleted is a sticky ManualResetEvent that the pump sets strictly after every PacketReceived enqueue (MSBuildClientPacketPump.cs — enqueue+Set() then, after loop exit, PacketPumpCompleted.Set()), at least one full DrainPacketQueue is guaranteed to happen-after the last enqueue. So the final ServerNodeBuildResult can never be stranded, whichever handle WaitAny returns. This is a structural fix, not a retry/paper-over.

Threading is single-producer (pump thread enqueues + sets events) / single-consumer (ProcessPacketsUntilBuildFinished dequeues); _buildFinished is written and read only on the consumer thread, so there is no cross-thread visibility question. Every interleaving was walked — the both-signaled race, cancel + complete + result, and a genuine mid-build disconnect (exception, no result) — none drops, double-processes, hangs, or spins. The mid-build disconnect path still surfaces the exception as MSBuildClientExitType.Unexpected (behavior preserved), and trailing console writes now flush before that throw (a strict improvement).

Empirical verification

Checked out the branch, ran a full build.cmd, and ran the regression test from the built net10.0 exe → passes. Confirmed via code reading that pre-fix the PacketPumpCompleted branch set _buildFinished without draining, leaving MSBuildAppExitTypeString null (→ non-zero process exit), which the test's MSBuildAppExitTypeString.ShouldBe("Success") assertion catches.

Follow-up commit

Pushed b8dce54 strengthening the regression test: it now redirects Console.Out and asserts the trailing Build succeeded. summary is actually forwarded — the real user-visible symptom of #14172 — and this also keeps the seeded console write out of the test-run stdout.

Per-dimension results

# Dimension Verdict Notes
1 Backwards Compatibility Restores intended behavior (success → exit 0). No new warnings/errors; WarnAsError-safe. Only intentional delta is the #14172 fix.
2 ChangeWave Discipline ✅ N/A Correctly omitted — MSBuild Server is opt-in (/mt); gating a wrong-exit-code fix behind an opt-out would perpetuate the bug.
3 Performance & Allocation Client packet loop runs once per build (not a hot path). Helper extraction is allocation-neutral.
4 Test Coverage Deterministic regression test drives the exact both-signaled race; fails pre-fix, passes post-fix. Strengthened in b8dce54 to assert the dropped summary.
5 Error Message Quality ✅ N/A No user-facing diagnostics changed.
6 Logging & Diagnostics Trace calls preserved; restores the dropped summary; trailing console writes now flush on all completion paths.
7 String Comparison ✅ N/A No string comparisons introduced.
8 API Surface Discipline New members private except ProcessSeededPacketsForTests (internal, via InternalsVisibleTo). No public API / PublicAPI.txt change.
9 Target Authoring ✅ N/A No targets/props.
10 Design Before Implementation Index-order-independent drain — structural root-cause fix matching the documented mechanism.
11 Cross-Platform Correctness Pure managed WaitHandle/ConcurrentQueue logic; platform-agnostic though the symptom is Linux-observed.
12 Code Simplification Extraction + shared DrainPacketQueue removes duplication; clearer control flow.
13 Concurrency & Thread Safety Single-producer/single-consumer; sticky-event guarantees a drain happens-after the last enqueue; all interleavings safe.
14 Naming Precision ProcessPacketsUntilBuildFinished/DrainPacketQueue are clear; test follows MethodUnderTest_Scenario_ExpectedResult.
15 SDK Integration ✅ N/A No SDK boundary.
16 Idiomatic C# Collection expressions, consistent nullable annotations, using declarations.
17 File I/O & Path Handling ✅ N/A No path/file I/O (test uses in-memory MemoryStream/StringWriter).
18 Documentation Accuracy XML docs on all new members; comments cite #14172 and explain the why.
19 Build Infrastructure ✅ N/A No dependency/version/eng changes.
20 Scope & PR Discipline Two files, tightly scoped to the fix + its regression test; no drive-by changes.
21 Evaluation Model Integrity ✅ N/A Not evaluation code.
22 Correctness & Edge Cases Race, cancel+complete+result, and disconnect paths all correct; result honored over a concurrent PacketPumpException (mutually exclusive in practice; result is authoritative).
23 Dependency Management ✅ N/A No dependency changes.
24 Security Awareness ✅ N/A Local named-pipe IPC unchanged; no untrusted-input handling changes.

Optional nits (non-blocking)

  • result.MSBuildClientExitType.ShouldBe(Success) is not load-bearing — Success is the enum's default (0), so it passes even on the buggy code. The MSBuildAppExitTypeString and (new) console-content assertions are the real guards. Fine to keep as intent documentation.
  • TestEnvironment env is unused in the new test (the seeded-packet path touches no env/mutex state), but it's harmless and consistent with the sibling test.

Generated with the GitHub Copilot CLI expert-reviewer methodology.

@JanProvaznik
JanProvaznik marked this pull request as ready for review July 2, 2026 15:47
Copilot AI review requested due to automatic review settings July 2, 2026 15:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes an MSBuild Server (/mt) client-side race where WaitHandle.WaitAny can observe PacketPumpCompleted before draining a queued ServerNodeBuildResult, causing a successful build to exit 1 with the final success summary dropped.

Changes:

  • Refactors the packet-processing loop into ProcessPacketsUntilBuildFinished and adds a shared DrainPacketQueue helper so queued packets are drained before honoring PacketPumpCompleted.
  • Ensures a delivered ServerNodeBuildResult is processed even if the pump also completed (and would otherwise end the loop early).
  • Adds a regression unit test that deterministically seeds the pump/events to reproduce the race and validates the result + console write are not dropped.

Reviewed changes

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

File Description
src/Build/BackEnd/Client/MSBuildClient.cs Drains the received-packets queue before treating the packet pump as completed; adds helper + test hook to prevent dropping the final build result under the WaitAny ordering race.
src/Build.UnitTests/BackEnd/MSBuildClient_Tests.cs Adds deterministic regression coverage to ensure the queued build result and success summary are not lost when PacketPumpCompleted and PacketReceivedEvent are both signaled.

Comment thread src/Build/BackEnd/Client/MSBuildClient.cs
Comment thread src/Build/BackEnd/Client/MSBuildClient.cs
@JanProvaznik
JanProvaznik requested a review from ViktorHofer July 3, 2026 15:36
@JanProvaznik
JanProvaznik merged commit 3d4b391 into dotnet:main Jul 9, 2026
14 checks passed
JanProvaznik added a commit to JanProvaznik/dotnet that referenced this pull request Jul 10, 2026
Make the MSBuild Server the default instead of a special opt-in, per feedback.

SDK (src/sdk): MSBuildForwardingAppWithoutLogging now defaults the server ON - it passes
MSBUILDUSESERVER=1 when the user hasn't set it explicitly (an explicit value, e.g. "0", is
respected; DOTNET_CLI_USE_MSBUILD_SERVER=false opts out of the default-on behavior). This
replaces the VMR-injected MSBUILDUSESERVER=1.

VMR wiring (repo-projects):
- Directory.Build.props: remove the DotNetBuildMSBuildServer opt-in flag, the UseMSBuildServer
  computation and the MSBuildServerExcludedRepos/override exclusion machinery, and the explicit
  MSBUILDUSESERVER=1 injection. Node reuse is now enabled by default for every repo (only a repo
  setting <UseNodeReuse>false</UseNodeReuse> disables it - which is exactly what turns the server
  off for that repo, since the server requires node reuse). The per-repo handshake salt is kept
  (MSBUILDNODEHANDSHAKESALT=dotnet-vmr-<repo>) so parallel repo servers don't collide.
- Directory.Build.targets: keep the per-repo salt-scoped `build-server shutdown --msbuild`
  cleanup, now gated on UseNodeReuse != false instead of the removed flag.
- wpf.proj: remove the UseMSBuildServer=false exclusion. wpf failed only due to the MSBuild
  Server exit-code bug fixed by dotnet/msbuild#14251, which is now merged and present after
  merging upstream main.

Root Directory.Build.props: remove the validation-only DotNetBuildMSBuildServer=true default
(the server is now default-on via the SDK).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
JanProvaznik added a commit to JanProvaznik/sdk that referenced this pull request Jul 13, 2026
…fixes

Bumps the SDK-redistributed MSBuild from 18.10.0-preview-26359-117 (on main) to 18.10.0-1.26360.102, which includes dotnet/msbuild#14251 (fixes dotnet#14172): the MSBuild Server client dropping the build result under a WaitAny race, causing a successful build to exit 1 on Linux under load. Also includes the earlier TerminalLogger-under-server fix (dotnet#14077).

This MSBuild adds [RequiresUnreferencedCode] to ProjectInstance.Build/BuildManager APIs, so add IL2026 suppressions at the CLI's non-AOT MSBuild call sites (matching the existing IL3050 suppression pattern).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 499249b7-514f-4ecf-88bc-2466455069c5
JanProvaznik added a commit to JanProvaznik/sdk that referenced this pull request Jul 13, 2026
…fixes

Bumps the SDK-redistributed MSBuild from 18.10.0-preview-26359-117 (on main) to 18.10.0-1.26360.102, which includes dotnet/msbuild#14251 (fixes dotnet#14172): the MSBuild Server client dropping the build result under a WaitAny race, causing a successful build to exit 1 on Linux under load. Also includes the earlier TerminalLogger-under-server fix (dotnet#14077).

This MSBuild adds [RequiresUnreferencedCode] to ProjectInstance.Build/BuildManager APIs, so add IL2026 suppressions at the CLI's non-AOT MSBuild call sites (matching the existing IL3050 suppression pattern).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 499249b7-514f-4ecf-88bc-2466455069c5
WarperSan pushed a commit to WarperSan/ThunderPipe that referenced this pull request Sep 17, 2026
Updated
[Microsoft.Build.Utilities.Core](https://github.com/dotnet/msbuild) from
18.9.6 to 18.10.1.

<details>
<summary>Release notes</summary>

_Sourced from [Microsoft.Build.Utilities.Core's
releases](https://github.com/dotnet/msbuild/releases)._

## 18.10.1

## What's Changed
* [vs16.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13103
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13796
* [vs17.8] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13902
* [vs17.11] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13903
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13909
* [vs17.12] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13986
* Add vs18.9 to merge-flow config; retire vs18.3 by @​JanProvaznik in
dotnet/msbuild#14214
* Bump labeler-cache-retention to use issue-labeler v2.1.0 by
@​jeffhandley in dotnet/msbuild#14171
* Bump main to 18.10.0 after vs18.9 snap by @​JanProvaznik in
dotnet/msbuild#14216
* Improve release skill: Phase 2 DARC rules, VMR backflow, deterministic
baseline by @​JanProvaznik in
dotnet/msbuild#14220
* Determinize release: hardcode OptProf baseline + Phase 3.2 baseline
resolver by @​JanProvaznik in
dotnet/msbuild#14222
* Serialize BuildRequestConfiguration.RequestedTargets to fix solution
metaproject MSB4057 in parallel builds by @​ViktorHofer in
dotnet/msbuild#14223
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14203
* Core support for AbsolutePath/FileInfo/DirectoryInfo and ITaskItem<T>
as task parameters by @​baronfel in
dotnet/msbuild#13971
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14206
* Fix existence cache kind poisoning by @​AlesProkop in
dotnet/msbuild#14249
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14226
* Don't disable the MSBuild server for /mt builds when node reuse is off
by @​AR-May in dotnet/msbuild#14248
* Enhance expert reviewer guidelines with additional checks. by @​AR-May
in dotnet/msbuild#14255
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14253
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14268
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14267
* Bump github/gh-aw-actions/setup from 0.81.6 to 0.82.2 by
@​dependabot[bot] in dotnet/msbuild#14266
* Avoid boxing the struct enumerator in
PropertyDictionary<T>.GetEnumerator() by @​nareshjo in
dotnet/msbuild#14272
* Refresh copy marker when implementation output changes by @​AlesProkop
in dotnet/msbuild#14231
* Send task-host build process environment as delta by @​OvesN in
dotnet/msbuild#14126
* Add regression coverage for metadata newline preservation by
@​VolPlita in dotnet/msbuild#14261
* Fix EmbedInBinlog items with relative paths from child projects by
@​huulinhnguyen-dev in dotnet/msbuild#13990
* Stop requiring VersionPrefix updates in servicing - insert prerelease
versions to VS by @​ViktorHofer in
dotnet/msbuild#14277
* Fix WriteLinesToFile rewriting unchanged file when custom encoding is
used by @​huulinhnguyen-dev in
dotnet/msbuild#14146
* Enable trim/AOT analyzers for Microsoft.Build and clean up annotations
by @​JeremyKuhne in dotnet/msbuild#14064
* [automated] Merge branch 'vs18.9' => 'main' by @​github-actions[bot]
in dotnet/msbuild#14291
* Fix MicroBuild plugin feed URL to use allowed pkgs.dev.azure.com
format by @​AlesProkop in dotnet/msbuild#14295
* Pass ExcludeRestorePackageImports during restore to avoid redundant
evaluations by @​ViktorHofer with @​Copilot in
dotnet/msbuild#14274
* [vs18.7] Update dependencies from dotnet/arcade by
@​dotnet-maestro[bot] in dotnet/msbuild#13988
* Adopt Clever Test Selection (CTS) as parallel, non-blocking PR
pipeline by @​jankratochvilcz in
dotnet/msbuild#14212
* Harden exceptions when connecting to server by @​JanProvaznik in
dotnet/msbuild#14292
* Update MicrosoftBuildVersion in analyzer template by
@​github-actions[bot] in dotnet/msbuild#13886
* Fix MSBuild Server client dropping build result under WaitAny race
(#​14172) by @​JanProvaznik in
dotnet/msbuild#14251
* Partially revert #​13660: remove NuGet RestoreTask transient TaskHost
workaround by @​JanProvaznik in
dotnet/msbuild#14297
* Disable daily AI credits guardrail for Expert Code Review workflow by
@​JanProvaznik with @​Copilot in
dotnet/msbuild#14314
* Localized file check-in by OneLocBuild Task: Build definition ID 9434:
Build ID 14614733 by @​dotnet-bot in
dotnet/msbuild#14246
* Add opt-in partial (stop-after-pass) project evaluation by
@​ViktorHofer in dotnet/msbuild#14290
* Use partial evaluation for -getProperty/-getItem without a target by
@​ViktorHofer in dotnet/msbuild#14296
* [main] Source code updates from dotnet/dotnet by @​dotnet-maestro[bot]
in dotnet/msbuild#14324
* [main] Update dependencies from dotnet/roslyn by @​dotnet-maestro[bot]
in dotnet/msbuild#14333
* [main] Update dependencies from nuget/nuget.client by
@​dotnet-maestro[bot] in dotnet/msbuild#14330
* Bump github/gh-aw-actions/setup from 0.82.2 to 0.82.8 by
@​dependabot[bot] in dotnet/msbuild#14328
* Restrict partial evaluation to ProjectInstance by @​ViktorHofer in
dotnet/msbuild#14340
 ... (truncated)

Commits viewable in [compare
view](dotnet/msbuild@v18.9.6...v18.10.1).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Microsoft.Build.Utilities.Core&package-manager=nuget&previous-version=18.9.6&new-version=18.10.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Sep 17, 2026
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.

3 participants