Repository navigation
Coverage collection is unreliable, and blocks the coverlet 10 upgrade #14
Description
Activity
Correction: coverlet 10 does not count more branches
Symptom 2 is stated in this issue as "a real behavioural change: coverlet 10 identifies branches the
6.x line did not". That is wrong, and the difference matters for how we decide what to do.branches-validis unchanged in every comparison:Specs 6.0.4 Specs 10.0.1 Core.Tests 6.0.4 Core.Tests 10.0.1 lines-valid / covered 4204 / 3983 4204 / 3983 2426 / 2426 2426 / 2426 branches-valid 1499 1499 553 553 branches-covered 1425 1409 553 548 The per-condition IL offsets are unchanged too —
AdvancedCircuitBreakerTResultSyntax.cs:33reports
jump offsets12and43under both versions; only their coverage moves,100% → 50%. Coverlet 10
finds the same branches. What changed is hit attribution: 6.0.4 credited the "taken" leg of a
two-way branch whenever the branch instruction was reached, regardless of which leg actually ran.
Coverlet 10 instruments both legs (coverlet PR
#1865, "Investigate and fix branch
coverage").6.0.4 is demonstrably wrong, and one line proves it.
src/Paramore.Fences.Core/Utils/Pipeline/DelegatingComponent.cs:64carrieshits="1"in both
reports — the line executed exactly once — yet 6.0.4 reports its two-way branch as100% (2/2). One
execution cannot take both legs. Coverlet 10's50% (1/2)is correct.So our current branch-coverage numbers are inflated. The true figures are:
Project Reported under 6.0.4 Actual Paramore.Fences.Specs95.06% 93.99% Paramore.Fences.Core.Tests100% 99.09% Paramore.Fences.Extensions.Tests100% 96.52% Note the third row:
Extensions.Testsfails under coverlet 10 as well. #6 aborting atSpecshid
that, so the blast radius is wider than this issue records.Testing.Testsstill passes at
100 / 100 / 100.What the 19 changed sites actually are
Reproduced locally by copying the repo and flipping only
Directory.Packages.props; 8/8 identical
runs, so this is not symptom-1 noise.18 of 19 are compiler-generated delegate-cache checks — the
<>c.<>9__x ?? (<>c.<>9__x = new …)
null-check the C# compiler emits when it caches a lambda or method group. The uncovered leg is the
cache-hit path, reachable only on a second invocation within the same generic instantiation. Two of
them (Simmy/Utils/GeneratorHelper.cs:24,Simmy/Fault/FaultGenerator.cs:65) showhits="2", but
each hit is a different generic instantiation with its own static cache field — so both are cache
misses, and coverlet 10's1/2is again right. Covering these would mean writing tests whose only
purpose is to call a method twice.The remaining one is a genuine branch:
src/Paramore.Fences/CircuitBreaker/RollingHealthMetrics.cs:79,
the short-circuit leg of awhile (_windows.Count > 0 && …). Line 78 already carries
// stryker disable once all : no means to test this— we have already judged that path untestable.What this means for the acceptance criteria
"No threshold is lowered merely to accommodate a collection defect" — adjusting the thresholds here
does not violate that. This is not a collection defect and not a loss of coverage; it is the discovery
that the previous numbers were over-credited by a tool bug. The tests cover exactly what they covered
before. Correcting an inflated baseline is the opposite of lowering a gate to hide a problem.On that reading, #6 can be taken, with the thresholds set to the true figures and
Extensions.Testsincluded in the change.One correction on symptom 1's suggested remedy
The "worth investigating" list proposes
coverlet.collector(--collect:"XPlat Code Coverage") as
the more robust path, on the theory that the datacollector is flushed through the datacollector
protocol before the host exits. That theory doesn't hold. Coverlet issue
#1983 — a full RCA of this exact
EndOfStreamException— reproduces it usingcoverlet.collector10.0.1 with
--collect:"XPlat Code Coverage"on a multi-targeted net8/9/10 project, and states that
ModuleTrackerTemplate.csand the whole data-collection hot path are byte-for-byte identical between
the two integrations. The datacollector adds an in-proc collector that usually flushes earlier, which
narrows the window materially — but it is a mitigation, not a fix, and it also introduces an
out-of-process reader that can open the hits file mid-write.The confirmed mechanism is that coverlet's hits file is flushed only by an
AppDomain.ProcessExit
hook, written non-atomically, while vstest allows the test host 100 ms to shut down before calling
TerminateProcess. Upstream measured that flush at 988 ms for a single assembly. Two cheaper levers
than the version swap: raiseVSTEST_TESTHOST_SHUTDOWN_TIMEOUT, and make the failure visible at all —
coverlet'sHits file … not foundis logged at verbose, which is why the 0% run
(32841065953) produced no
diagnostic whatsoever.Still open
The
99.95%run (33262613610)
is not the same failure as the 0% and stream-exception runs, and should not be assumed closed by
fixing them. The hits file is a length-prefixed array read in a tight loop — any short read throws, so
there is no code path yielding a valid-but-partial result. The log shows coverlet read that file
cleanly, no warning, and then reported 99.95%. The arithmetic (2425/2426,552/553) says exactly one
line and one branch went missing. That remains unexplained.Upstream Polly has dropped coverlet — see #34
The first upstream sync (#28) surfaced App-vNext/Polly@1a80392b, "Update to xunit v3" (App-vNext/Polly#3131). It is being ported as its own issue, #34. It bears directly on this one:
- coverlet is removed entirely.
coverlet.msbuildand theReportGeneratorpackage are gone. Coverage now comes fromMicrosoft.Testing.Extensions.CodeCoverage, running under Microsoft.Testing.Platform v2, not vstest. - Each target framework writes its own coverage file (
artifacts/coverage/<project>/coverage.<tfm>.xml). That rules out, by construction, one of the "worth investigating" threads above: target frameworks racing over a shared output path. - Thresholds survive but move. Projects keep
<Threshold>, but enforcement moves out ofeng/Test.targetsinto acake.cstask (__VerifyCoverageThresholds) that checks each per-framework file. - Upstream re-baselined its numbers under the new tool.
Polly.Specswent from94,94,91to96,95,93: up, not down. That is a different counter again, so the "true figures" table in the correction above (measured under coverlet 10) won't carry over as-is.
What this means here
- The hits-file /
ProcessExit/ 100 ms vstest shutdown mechanism diagnosed above is specific to coverlet under vstest. If Port upstream xunit v3 / Microsoft.Testing.Platform v2 migration (App-vNext/Polly@1a80392b) #34 lands, symptom 1 as described here should no longer be reachable. That's not the same as confirming the new tool is reliable. The acceptance criterion "coverage figures reproducible across all three target frameworks" still has to be demonstrated under it. - Bump coverlet.msbuild from 6.0.4 to 10.0.1 #6 (coverlet 10.0.1) would be superseded rather than merged. Worth deciding before spending more effort on coverlet-specific mitigations such as
VSTEST_TESTHOST_SHUTDOWN_TIMEOUT. - The coverage-policy question stays open whichever tool we use. Re-baselining thresholds under a new counter should follow this issue's rule: no threshold lowered to hide a collection defect, and each change justified by the tool's counting, the way the coverlet 10 correction above was.
- The unexplained
99.95%run can't be investigated further once coverlet is gone. If it matters, it needs looking at before Port upstream xunit v3 / Microsoft.Testing.Platform v2 migration (App-vNext/Polly@1a80392b) #34 lands.
Suggest #14 and #34 are decided together: #34 changes what #14 is about.
- coverlet is removed entirely.
Summary
How we collect coverage is unreliable, and it is now blocking a dependency update. There are two symptoms with one underlying subject — how branch and line coverage are gathered and counted — so they are worth solving together rather than separately.
Neither symptom is a real loss of test coverage. In every case below the tests themselves passed.
Symptom 1 — coverlet 6.0.4 drops coverage data non-deterministically
coverlet.msbuild6.0.4 intermittently reports coverage below threshold, or fails outright, on one target framework while the other two report the correct figure for the same tests on the same commit. It has surfaced four times in the last week, across three different test projects and both Linux and Windows:SpecsCore.TestsUnable to read beyond the end of the streamon net9.0 and net8.0Extensions.TestsUnable to read beyond the end of the streamon net9.0; threshold failure on net8.0Core.TestsEvery one cleared on
gh run rerun <id> --failedwith no code change.The last row is the concerning one. The earlier failures announced themselves as obviously bogus — 0%, or a stream exception. 99.95% does not. It reads exactly like somebody genuinely missed a line, and the only thing distinguishing it from a real regression is that two other frameworks reported 100% for identical tests. That is a subtle tell to rely on, and it is the sort of thing that eventually gets "fixed" by quietly lowering a threshold.
It is also not free: it failed the
9.0.0release build and needed a manual re-run beforepublish-nugetwould go.Symptom 2 — coverlet 10.0.1 counts branches differently, so we cannot take the update
#6 bumps
coverlet.msbuildfrom 6.0.4 to 10.0.1 and fails:This one is not a flake — it reproduces, and it is a real behavioural change: coverlet 10 identifies branches the 6.x line did not. So we are pinned to a version that flakes, and the upgrade that might fix the flakiness is gated behind deciding what our branch-coverage numbers should actually be.
Why this is one piece of work
Taking #6 means answering "is 100% branch coverage on
Paramore.Fences.Corestill the right gate, under a tool that counts branches differently?" That is a coverage-policy question, not a dependency bump, and it should not be settled as a side effect of merging a Dependabot PR.Where the configuration lives
eng/Test.targets—CollectCoverage,CoverletOutputFormat,ExcludeByAttribute, ReportGenerator wiringtest/Paramore.Fences.Core.Tests/*.csproj,Extensions.Tests,RateLimiting.Tests,Testing.Tests—<Threshold>100</Threshold>test/Paramore.Fences.Specs/*.csproj—<Threshold>94,94,91</Threshold>Worth investigating
Not proposals, just the threads that look most promising:
CollectCoverage=trueuses coverlet's MSBuild in-process collector. Thecoverlet.collectordatacollector (--collect:"XPlat Code Coverage") is generally the more robust path, and the in-process collector writing per-framework output during a multi-targeted build is a plausible source of the truncated/partial results above. Switching how we pick up coverage may make symptom 1 disappear regardless of version.Core.Tests, and whether those branches deserve tests or an exclusion.100threshold is the right instrument, given it makes any collection wobble a build failure.Acceptance