Fail fast when the MSBuildCache graph build hits real build errors - #10287
Conversation
The MSBuildCache graph build wrapped its result in `exit 0` plus `continueOnError: true`, so a failed cache build was recorded as `succeeded` (plain green check, warning annotation only). Runs therefore showed a green "Build solution graph with MSBuildCache" step immediately followed by the full Arcade "Build" step, with nothing in the UI explaining why the skip did not happen. Report the real outcome instead, and stop re-running a build that cannot produce a different result: - Cache build succeeds: unchanged, "Build" is skipped. - Cache build fails with MSBuild errors that are not attributable to MSBuildCache: the sources do not build, so the Arcade fallback would only reproduce the same errors minutes later. Surface those errors on this step and fail the job here. A cache hit only materializes stored outputs and never runs the compiler, so any `error XXnnnn:` came from a project that really executed, exactly as the fallback would run it. - Cache build fails for a reason the fallback can plausibly fix (MSBuildCache plugin/cache/file-access problems, NU* restore failures, or a crash that never reached MSBuild's error summary): warn and run the Arcade build as before. The step stays green on purpose, because a `SucceededWithIssues` step makes the job PartiallySucceeded, which Azure Repos build-validation policies treat as a failure. Also log explicitly, on both cache steps, whether the "Build" step is going to be skipped, so the decision is visible without reading conditions. Verified against the captured log of build 1529362: the xlf error is now classified as a source break, so the redundant 7m18s Arcade build is skipped and the failure is reported after the 4m10s cache build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
There was a problem hiding this comment.
Pull request overview
Updates the Windows CI cache canary to fail fast on classified build errors while retaining Arcade fallback for cache, restore, or infrastructure failures.
Changes:
- Captures and classifies graph-build output.
- Skips redundant fallback builds after genuine errors.
- Clarifies cache-step outcomes in pipeline logs.
Show a summary per file
| File | Description |
|---|---|
azure-pipelines.yml |
Adds MSBuildCache failure classification and fallback control. |
Review details
Comments suppressed due to low confidence (1)
azure-pipelines.yml:445
- This only makes a nonzero child-process exit non-blocking. Now that
continueOnErroris gone, any terminating PowerShell error before or after the invocation fails the step outright; because the fallbackBuildrequiressucceeded(), it will not run. That contradicts the stated contract that any preparation failure switches back to the regular build. Add top-level error handling that resets the variable, logs the warning, and exits 0 for unexpected preparation-script failures.
if ($exitCode -ne 0) {
# Same reasoning as the graph build above: staying green keeps a run that the fallback repairs from
# being reported as PartiallySucceeded and blocking the merge.
Write-Host "##vso[task.logissue type=warning]Preparing cached outputs failed with exit code $exitCode; continuing with the regular Arcade build."
exit 0
- Files reviewed: 1/1 changed files
- Comments generated: 2
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
azure-pipelines.yml:386
- The developer guide still says that every cache-build failure is cleaned up and sent through the regular Arcade fallback (
docs/dev-guide.md:89). This branch now deliberately fails the job and skips that fallback for classified build errors, so the documented operational behavior becomes incorrect. Please update the MSBuildCache section in the same change to describe the three outcomes and the new fail-fast case.
if ($reportedErrorCount -gt 0 -and $errorLines.Count -gt 0 -and $mayHelpLines.Count -eq 0) {
foreach ($line in @($errorLines | Select-Object -First 20)) {
Write-Host "##vso[task.logissue type=error]$line"
}
Write-Host "The solution does not build. The Arcade 'Build' step would report the same $reportedErrorCount error(s), so the build is failed here instead of repeating it."
exit 1
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Medium
Removing `continueOnError: true` made both MSBuildCache steps able to fail the job, which is the point for a classified source break - but it also meant an *unexpected* terminating error inside the wrapper itself (a Tee-Object failure, log reading, the classification code) would fail the job outright instead of handing over to the Arcade build. That is an infrastructure failure and belongs on the fallback path, exactly like a crash of the build itself. Add a `trap` to both steps that sets MSBuildCacheBuildSucceeded=false, warns and exits 0. `exit` is flow control rather than a terminating error, so the deliberate `exit 1` for a classified source break still fails the step as intended (verified). Also note in-place that the error-summary regex matches MSBuild's English output, and that a localized agent leaves the count at -1 and therefore takes the fallback - the safe direction. Verified by replaying the captured log of build 1529362 plus synthetic logs through both step scripts, with AzDO's injected `$ErrorActionPreference = 'stop'`: source breaks fail fast; cache, NuGet, crash, localized-summary, zero-error-summary and now terminating-error cases all fall back; success still skips the build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
If a fail-fast verdict is ever wrong - an error that is an artifact of a cached dependency rather than a genuine source break - the person reading the failure needs to know where to look. The "Preserve cache diagnostics" step already publishes the per-project MSBuildCache logs; name that location in the failure message so the mis-classification is diagnosable without re-running anything. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
An MSBuild worker crash is reported as `MSBUILD : error MSB4166: Child node ... exited prematurely` and *is* followed by a normal non-zero `N Error(s)` summary, so it matched the fail-fast branch: it looks like a summarized error, and it matches none of the cache/restore terms. That is a misclassification - the node died before the projects it owned could report their real result, so this is transient infrastructure and the Arcade invocation can legitimately succeed where this one did not. Add an infrastructure-failure check covering MSB4166 (node exited prematurely), MSB0001 and MSB1025 (internal MSBuild errors), MSB4017 (logger failure) and OutOfMemoryException. It is matched against the whole log rather than just the extracted error lines, because the diagnostic detail for a crash usually continues on following lines that are not themselves formatted as errors. This build runs the 204-node graph with /m, so a worker crash is a realistic failure mode rather than a theoretical one. Verified across 13 scenarios: MSB4166 alone, MSB4166 alongside a genuine CS error, a summarized OOM and MSB0001 all take the fallback, while the real captured xlf break and a plain CS1002 still fail fast. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
azure-pipelines.yml:391
- This negative classifier still hard-fails MSBuild errors that the Arcade fallback can legitimately recover from. For example,
MSB4166is a child-node crash (already recognized attest/Utilities/Microsoft.Testing.TestInfrastructure/DotnetCli.cs:182-185), andMSB4260is a static-graph-only reference failure documented at.agents/skills/build-perf-baseline/SKILL.md:306-314. Both can appear with a normal error summary, match$errorLines, and then satisfy the fail-fast condition even though rerunning without/graphmay succeed. Route these infrastructure/graph failures to the fallback rather than treating every non-cache/non-NU MSBuild code as a reproducible source error.
$fallbackMayHelp = '(?i)MSBuildCache|ProjectCache|project cache|cache plugin|CacheClient|file access|\bNU\d{4}\b'
- Files reviewed: 1/1 changed files
- Comments generated: 0 new
- Review effort level: Medium
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
azure-pipelines.yml:391
- This does not catch every error emitted by MSBuildCache itself. The pinned plugin reports duplicate outputs as
Node ... produced output ... which was already produced by another node ...; MSBuild's project-cache logger adds no plugin/category prefix, so that line matches$errorLinesbut none of these tokens. With a normal error summary, the step therefore exits 1 and suppresses the fallback for a cache-plugin failure—the opposite of the intended classification. Include the plugin's duplicate-output diagnostic in the fallback pattern.
$fallbackMayHelp = '(?i)MSBuildCache|ProjectCache|project cache|cache plugin|CacheClient|file access|\bNU\d{4}\b'
- Files reviewed: 1/1 changed files
- Comments generated: 0 new
- Review effort level: Medium
Two follow-ups from review:
MSB4260 ("project reference could not be resolved with static graph") is a
limitation of the /graph switch that only this step passes - the Arcade
fallback does not build the graph statically, so it can resolve the same
reference and succeed. It reaches the error summary and matches no cache
or restore term, so it was satisfying the fail-fast condition. Add it
alongside the engine-crash codes and rename the pattern to
$fallbackCanDiffer, since a graph-mode limitation is not "infrastructure".
docs/dev-guide.md still stated that *every* cache-build failure runs the
regular Arcade build as a fallback, which this PR made untrue. Replace
that sentence with the three actual outcomes, including which failures
stay on the fallback path and why the cache step deliberately stays green
when it falls back.
Verified across 14 scenarios: MSB4260 alone and MSB4260 alongside a
genuine CS error both take the fallback; the real captured xlf break and a
plain CS1002 still fail fast.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
|
Picking up the three items that landed in review summaries as "suppressed due to low confidence" rather than as inline threads, since they have no thread to resolve. All three were correct and all three are now implemented.
Correct, and it applied to both cache steps. AzDO injects
This is the better of the two examples, and I had missed it.
Also correct - this PR made that sentence untrue. Replaced it with the three actual outcomes: cache hit, fail-fast on reproducible build errors, and fallback for everything else (cache/plugin problems, Classifier verification is now 14 scenarios, 0 mismatches, run through the real step script with AzDO's |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (1)
azure-pipelines.yml:405
- The fallback classifier still hard-fails standard transient file-lock errors. For example,
MSB3021/MSB3027(“process cannot access the file because it is being used by another process” / “exceeded retry count”) produce a normal error line and summary, match none of these markers, and therefore suppress the Arcade retry even though a differently scheduled/retried build can succeed. Add these copy/locking markers (including continuation-line text) to the fallback-eligible set rather than treating them as source breaks.
$fallbackCanDiffer = '(?i)\bMSB4166\b|\bMSB0001\b|\bMSB1025\b|\bMSB4017\b|\bMSB4260\b|exited prematurely|OutOfMemoryException'
$fallbackCanDifferLines = @($log | Where-Object { $_ -match $fallbackCanDiffer })
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Medium
Two more classifier holes found in review, both verified against the
pinned plugin binary (Microsoft.MSBuildCache.AzurePipelines 0.1.328-preview)
rather than assumed:
- MSBuildCache reports a duplicate output as "Node {0} produced output {1}
with hash {2} which was already produced by another node {3} with a
different hash {4}." That message carries no plugin or cache token of its
own, so it read as an ordinary build error and hard-failed - the exact
opposite of the intent that everything the plugin reports goes to the
fallback. This is reachable because MSBuildCacheIdenticalDuplicateOutputPatterns
only tolerates duplicates with *identical* content.
- MSB3021/MSB3027 ("the process cannot access the file because it is being
used by another process" / "exceeded retry count") are timing-dependent
copy contention. Copying is heavier here than in a default build because
the cache uses copy rather than hardlink semantics, so a differently
scheduled build can legitimately succeed.
Both markers join the whole-log $fallbackCanDiffer set so continuation
lines are covered too. Documented in the dev guide alongside the other
fallback-eligible failures.
Verified across 16 scenarios using the plugin's exact message wording,
including the duplicate-output diagnostic alongside a genuine CS error;
the real captured xlf break and a plain CS1002 still fail fast.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3a30b356-352a-4c3f-aea6-93e9f1decc08
|
Two more suppressed-confidence items from the latest review passes. Both were right, and I verified each against the pinned plugin binary (
Confirmed verbatim in the assembly: No plugin or cache token anywhere in it, so it read as an ordinary build error and hard-failed - the exact opposite of the contract that everything the plugin reports goes to the fallback. It is reachable too:
Also confirmed ( Both markers went into the whole-log Verification is now 16 scenarios, 0 mismatches, using the plugin's exact message wording. That includes the duplicate-output diagnostic appearing alongside a genuine |
The symptom
In build 1529362 the
Build solution graph with MSBuildCachestep is a green check after 4m 10s — and then the full ArcadeBuildstep runs anyway for another 7m 18s. Same shape on 1529356, 1529364 and 1529373.Why the skip "never works"
The step was not actually green. It ended with:
The wrapper swallowed the exit code with
exit 0, and##vso[task.logissue type=warning]does not downgrade a task result. The AzDO timeline for that step literally recordsresult=succeeded, errorCount=0, warningCount=1— a plain green check on a build that failed. So the skip logic worked exactly as designed; the step just lied about its outcome, and the same error was then reported a second time 7 minutes later.The mechanism itself is fine — on healthy runs it does skip. E.g. 1529231: graph build 9.7m + prepare/pack 2.9m,
Build→ skipped.What this changes
The graph build step now reports its real outcome, and stops re-running a build that cannot produce a different result:
BuildstepThe middle row is the new behaviour. A cache hit only materializes previously stored outputs — it never runs the compiler — so every
error XXnnnn:in that log came from a project that genuinely executed, exactly as the fallback would execute it. Running the whole solution through Arcade again just reproduces the same errors several minutes later.Three failure classes deliberately stay on the fallback path, because the non-cached build can legitimately behave differently:
NU*, often transient),msbuild.ps1throwing).Those keep the step green on purpose: a
SucceededWithIssuesstep makes the jobPartiallySucceeded, which Azure Repos build-validation policies treat as a failure and would block merges on runs that ultimately build fine — the same reasoning already documented for_MacOSNonBlockingTrailer.Both cache steps now also log explicitly whether
Buildis going to be skipped, so the decision is readable without reverse-engineering step conditions.Verification
Replayed the captured log of build 1529362 through the new script, plus synthetic logs for each failure class:
Buildstep1error CS100210NU1301000Also confirmed: YAML parses, the inline PowerShell parses clean,
$LASTEXITCODEsurvives theTee-Objectpipe, and no trailing whitespace.stderris deliberately left unredirected so the step's native-command error handling is byte-for-byte the previous behaviour.Not fixed here —
mainis currently brokenWorth flagging separately: every PR is red right now because of a genuine break on
main, not because of this pipeline.866ed183f(OneLocBuild check-in #10268) wrote CRLF line endings inside the<source>elements ofsrc/Analyzers/MSTest.Analyzers/xlf/*.xlf, whileResources.resxuses LF. All 211 keys are present and the text is identical — only the line endings differ — so XliffTasks reports 12 entries as out-of-date and fails the build. That needs its own fix (/t:UpdateXlf).