Repository navigation
Description
Package: Microsoft.Testing.Extensions.CodeCoverage 18.8.0 (also reproduced on 18.9.0)
Repo: https://github.com/microsoft/codecoverage
Runtime: .NET 8 (8.0.28) and .NET 10 hosts, Linux x64 (Ubuntu container), xunit.v3 3.2.2 on Microsoft.Testing.Platform
Invocation: dotnet test --project <proj> -c Release --coverage --coverage-output-format cobertura
Summary
Static instrumentation prefixes every basic block of every instrumented assembly with an unconditional raw byte store through a static pointer:
ldsfld uint8* Tracker::Begin
ldc.i4 N
add
ldc.i4.1
stind.i1which the JIT compiles to a single mov byte ptr [reg+idx], 1.
Begin is obtained as
Begin = (byte*)_view.SafeMemoryMappedViewHandle.DangerousGetHandle();with no DangerousAddRef, over a small file-backed mapping (MemoryMappedFile.CreateFromFile("/tmp/CodeCoverage.<sessionGuid>.<moduleGuid>", FileMode.Open, null, <bufferSize>, MemoryMappedFileAccess.ReadWrite)).
Because the store is unguarded and executes on every basic block, any condition that makes that mapped page unbacked turns the next executed instrumented method into a process-fatal AccessViolationException. The reported stack is whichever user method happened to run at that instant, so the failure is systematically misattributed to innocent user code — including code that provably cannot fault, such as a two-field constructor.
Impact
- A test host dies with
Fatal error. System.AccessViolationException/ SIGABRT (exit 134). - The blamed frame is arbitrary and varies run to run, so users chase phantom concurrency and memory-safety bugs in their own code. We spent a substantial investigation before establishing that the faulting instruction was the injected probe rather than our code.
- On CI this reds unrelated pipelines.
Reproducer (self-contained, no proprietary code)
Reproduced deterministically, 3/3, on a synthetic solution containing only ordinary safe C# (no unsafe, no pointers, no P/Invoke, no interop):
- Create a solution with a class library
LibAand an MTP test project referencing it (xunit.v3 +Microsoft.Testing.Extensions.CodeCoverage,global.jsonwith"test": { "runner": "Microsoft.Testing.Platform" }). - Run
dotnet test --project Tests -c Release --coverage --coverage-output-format coberturaonce and terminate it while the rewritten assemblies are on disk (the run leavesLibA.dllrewritten plusLibA.dll.orig). This step just makes the instrumented artifact available for direct execution. - Read the buffer path baked into the rewritten library:
strings -el LibA.dll | grep '^/tmp/CodeCoverage\.' - Create that file at the buffer size and confirm the probes are live:
dd if=/dev/zero of=$BUF bs=1 count=<bufferSize>then run the test host directly
(dotnet exec Tests.dll) and observe non-zero bytes appear in$BUF. - Run the test host again and, while it is executing, truncate the buffer:
truncate -s 0 $BUF.
Result (3/3):
Fatal error. System.AccessViolationException: Attempted to read or write protected memory.
at BigRepro.LibA.Widget4.Touch(Int32, Int32, Int32, Int64)
at BigRepro.Tests.Suite34.Calc10Works(Int32)
Fatal error. System.AccessViolationException: ...
at BigRepro.LibA.Widget12..ctor(Double, Double, Int64, Boolean)
at BigRepro.Tests.Suite12+<>c.<RejectsBadConfig>b__1_0()
Fatal error. System.AccessViolationException: ...
at BigRepro.LibA.Widget6.Evict(Int32, Int32, Int32)
at BigRepro.Tests.Suite46+<ConcurrentTouchVsEvict>d__0.MoveNext()
Note the second one: the faulting "user code" is a constructor that only validates arguments and assigns fields.
Specificity control: unlinking and recreating the buffer file (new inode) does not crash the host — the existing mapping keeps the old inode alive. Only losing backing for the mapped page faults. So this is specifically about page backing, not about the file being touched.
Evidence from a naturally-occurring crash (not induced)
We independently captured core dumps of the same failure occurring spontaneously in CI-shaped runs (~0.5-1% of invocations). In those dumps:
- SOS
clrstack -fshows aFaultingExceptionFramedirectly above a trivial property getter. - Disassembly at the faulting offset is the probe store, before any user IL executes:
513e: 48 8b 3d cb 4d aa ff mov rdi, [rip-0x55b235] ; Tracker::Begin
5145: b8 c9 00 00 00 mov eax, 0xc9 ; probe index 201
514a: 48 98 cdqe
514c: c6 04 07 01 mov byte ptr [rdi+rax], 1 ; <-- faulting instruction
5150: ... ; user code starts only here
Begin = 0x7824061b5000(non-null), buffer size0x99b(2459), probe index 201 (in range), and the core'sNT_FILEmaps/tmp/CodeCoverage.<session>.<module>at exactly0x7824061b5000. So: valid pointer, in-range index, mapping present — the page was simply not backed at the instant of the store.- The managed heap verifies clean (
verifyheap: 0 errors in one dump; the only "errors" in
others areInvalidMethodTableat a thread'salloc_limit, i.e. allocation-boundary artifacts). So this is not heap corruption by user code. - The process then FailFasts; dumps carry
ExecutionEngineExceptionHResult 0x80131506.
We also observed, with a 5 ms poller during a normal (non-crashing) run, that buffer files are rewritten in place while mapped (size regressions such as 2459 -> 2458 on 2 of 158 buffer files), which suggests these files are not stable for the lifetime of the mapping.
Plausible real-world causes of lost page backing include truncation/rewrite of the buffer file by another participant, and filesystem exhaustion (a dirty page of a file-backed mapping that cannot be allocated yields SIGBUS; CoreCLR's PAL reports both SIGBUS and SIGSEGV as
EXCEPTION_ACCESS_VIOLATION). Our failures cluster on a small, disk-pressured CI runner.
Suggested remedies
- Hold a reference for the lifetime of the pointer — use
DangerousAddRef/DangerousRelease(or keep and use theSafeMemoryMappedViewHandledirectly) so the mapping cannot be released while probes may still execute. - Do not use a shared, world-writable, guessable path.
/tmp/CodeCoverage.<guid>.<guid>is writable by any process on the machine; a truncation by anything else is fatal to unrelated test hosts. Consider an unlinked/anonymous mapping,memfd_create, or a private directory. - Do not let instrumentation faults kill the host. A coverage probe failing should at worst lose coverage data, never terminate the process — and never surface as an
AccessViolationExceptionattributed to user code. Even a one-time validity check with a graceful disable would prevent the misattribution. - If the buffer must be file-backed, preallocate and fsync it, and handle SIGBUS.
Why this is easy to misdiagnose (please consider the diagnostics angle)
Because the injected store is attributed to the enclosing user method, the crash looks exactly like a memory-safety bug in the user's own code. In our case it repeatedly pointed at a lock-protected ConcurrentDictionary wrapper and at trivial constructors. Anything that makes the probe's provenance visible — a marker frame, a distinguishable exception, or a documented signature — would save considerable investigation time.
Activity
Identify the writer that shrinks the mapped coverage buffer (#32)
Instrument the Linux coverage lane to identify the AccessViolation so…
Identify the writer that shrinks the mapped coverage buffer (#32)
Instrument the Linux coverage lane to identify the AccessViolation so…
Identify the writer that shrinks the mapped coverage buffer (#32)
Instrument the Linux coverage lane to identify the AccessViolation so…
Identify the writer that shrinks the mapped coverage buffer (#32)
Make the issue-reference ban enforceable, and wire two unrun quality …
fhnaseer commentedon Aug 12, 2026
Please use 18.10.0 version and see if it fixes the issue for you or not.
pcshrosbree commentedon Aug 13, 2026
Thanks — I've tested 18.10.0.
Short answer: no, it does not fix it. The two changes 18.10.0 does make are real and address parts of remedies 1 and 4, but the reported failure mode is unchanged: losing page backing for the mapped buffer still kills the test host with an
AccessViolationExceptionattributed to innocent user code. Reproduced 3/3 on net8.0 and 1/1 on net10.0 with 18.10.0, against 0/4 for the same runs without the induced truncation.What 18.10.0 changed
Decompiling
lib/netstandard2.0/Microsoft.CodeCoverage.Instrumentation.Core.dll, these are the only two functional differences between 18.9.0 and 18.10.0:Both are genuine improvements and I appreciate them:
AcquirePointertakes the ref (DangerousAddRef), so the mapping can no longer be released out from under a liveBegin. (FWIW there is no matchingReleasePointeranywhere in the assembly —AcquirePointerappears once,ReleasePointerzero times — so the ref is held permanently. That is the right lifetime for this use, but it may be worth an explicit comment so it isn't "fixed" later as a leak.)FileMode.Create, which should genuinely reduce the filesystem-exhaustion path — an unwritten hole in a sparse file is exactly the case where a dirty page can't be allocated and you get SIGBUS. That is the cause I suspected for our naturally-occurring CI crashes, so this may well reduce their frequency. The other half is missing:fileStream.Flush()is the managed overload and does not fsync (Flush(flushToDisk: true)would), and there is still no SIGBUS handling.What did not change
Remedy 2 — the buffer is still a shared, world-writable, guessable path. Still
Path.Combine(Path.GetTempPath(), …). Observed in all 10 runs below, e.g.:Remedy 3 — the probe store is still unguarded. Capturing the rewritten
LibA.dllmid-run under 18.10.0 and disassembling, the injected prologue is byte-for-byte the shape originally reported:(Static rewriting in place is also unchanged —
LibA.dll.origandLibA.pdb.origappear in the output directory during every run.)So
AcquirePointerguarantees the mapping object stays alive, but that was never the only way to lose page backing. Anything that unbacks the mapped page still turns the next basic block of any instrumented method into a fatal AV, and it is still reported against whatever user method happened to be executing.Results
Same solution, same harness, only the package version and the induced truncation varying.
truncate=truncate -s 0on the buffer file 6 s into a run while instrumented code is executing continuously;observe= the identical run with no truncation (the specificity control).Representative failure under 18.10.0:
One incidental behaviour change worth knowing about: the host still FailFasts and still prints
Fatal error. System.AccessViolationException, but the exit code the platform reports moved from 134 (SIGABRT) on 18.9.0 to 7 on 18.10.0, consistently. I haven't chased why. If anything that makes it slightly harder to spot in CI, since 134 is recognisably a crash.Environment
Microsoft.NETCore.App8.0.26 and 10.0.10Reproducer
Self-contained — the script below generates the whole solution (ordinary safe C# only; no
unsafe, no pointers, no interop), and the second one runs the probe and the attack.gen_repro.py— generates the solutionprobe_live.py— runs it, observes the buffer, and performs the truncationNote for anyone reproducing: with 18.9.0/18.10.0 the plain
xunit.v33.2.2 package no longer works — the run dies at startup withTypeLoadException: Could not load type 'Microsoft.Testing.Platform.Extensions.TestHost.IDataConsumer' from assembly 'Microsoft.Testing.Platform, Version=2.3.3.0', becausexunit.v3pullsxunit.v3.mtp-v1while the coverage package requires Microsoft.Testing.Platform 2.3.x. Referencingxunit.v3.mtp-v2instead fixes it. That is a separate packaging matter, not part of this issue, but it will block a naive repro attempt.What would actually close this
Remedy 3 is the one that matters, and it is independent of how the buffer is created or referenced: a coverage probe failing should never terminate the process, and should never be attributed to user code. Even a one-time validity check with a graceful disable, or a marker frame that makes the probe's provenance visible in the stack, would have saved us the investigation that produced this report. Remedy 2 (a private or anonymous mapping —
memfd_create, or at minimum a private directory rather than a path any process on the machine cantruncate) removes the most easily triggered cause.Happy to test any prerelease build against the same harness.
Retry the test run when the coverage probe crashes
A user who exists, and cannot be assigned (#125) (#135)
fhnaseer commentedon Sep 2, 2026
Please use 18.11.0 version and see if it fixes the issue for you or not.
pcshrosbree commentedon Sep 3, 2026
Thanks — I've tested 18.11.0 against the same harness.
Short answer: the case I originally reported no longer kills the host, but the new guard is a check-then-write race, and a tight loop of truncations still takes the process down — 3/3 — now from inside the coverage code rather than user code. Two further things surfaced that you may want to know about: a truncated buffer now produces a green run with an empty report, and 18.11.0 leaves about 52 six-byte files per test run in
/tmpthat are not removed when the run ends.Results
Same solution, same probe, one added mode.
truncate= a singletruncate -s 0on the buffer 6 s into a run;hammer= from 6 s, looptruncate(0)/truncate(size)as fast as possible for 15 s;observe= no attack (control). The 18.10.0 row is the positive control for the harness.line-rate="1"StaticManagedTrackerTemplate.Flush()←PeriodicFlushLoop()line-rate="0.909", 341 KBStaticManagedTrackerTemplate.Flush()←PeriodicFlushLoop(); then collector:SharedMemoryStream.GetCoverageBuffer←LoggerBase.Shutdown←TestingPlatformCoverageProcessMonitor.OnTestHostProcessExitedAsync(corrected: both, not collector only)line-rate="0.909"Both hammer runs died within about 1–2 s of the loop starting (40k–91k truncate cycles logged before exit).
What 18.11.0 changed (decompiled
Microsoft.CodeCoverage.Instrumentation.Core, 18.10.0 → 18.11.0)On non-Windows,
StaticManagedTrackerTemplateno longer pointsBeginat the mapping.Beginis now a GC-pinned privatebyte[](_cachedHits), and a background thread (AboveNormal, every 10 ms) copies set bytes into theMemoryMappedViewAccessor— the same copy runs onProcessExit, andSharedBufferReconcilerin the extension host repeats it. Each copy is gated onHasIntactBacking(), which isnew FileInfo(BufferName).Length >= BufferSize:That is exactly what the results show:
ldsfld Begin / ldc.i4 / add / ldc.i4.1 / stind.i1), but it targets private memory, so a probe can no longer fault, whatever happens to the file. This is the substance of remedy 3 and it is what makes the single-truncation rows pass. Thank you.HasIntactBackingstats the file, then the loop writes through the mapping; nothing stops the file shrinking in between. The hammer arm just makes the window easy to hit — a single truncation lands in it with probability roughly (flush duration ÷ 10 ms), which is why thetruncaterows are green and thehammerrows are not. The same shape applies to theProcessExitcopy and toSharedBufferReconciler.SharedMemoryStream.GetCoverageBufferchecksHasIntactBackingand returns a cleared array beforeCreateViewAccessor/ReadArray, and that check already existed in 18.10.0.) On net10.0, after the host's flush thread had faulted, the collector also died:LoggerBase.Shutdownpassed the length check, thenUnmanagedMemoryAccessor.ReadArrayhit the truncated page. That path is taken exactly when the host has already gone wrong, so it compounds the failure and — because it is the collector — no report is written at all.The other change in this release —
DataStoreTrackerTemplate.Initializegaining a0700session directory,0600files andFlush(flushToDisk: true)— does not apply to the probe path this issue is about (corrected: the static instrumenter does clone that template, for a module's dependency data store when one is present; the probes never store through it, they store throughStaticManagedTrackerTemplate::Begin). The buffer the static tracker maps is created by the collector, inMicrosoft.CodeCoverage.Interprocess'sMemoryMappedFileFactory.CreateBackingFile(FileMode.Create+SetLength, i.e. still sparse), directly underPath.GetTempPath()with theCodeCoverage.<session>.<module>name, and that code is identical between 18.10.0 and 18.11.0. One correction to my previous comment while I am here: that file was already0600in 18.10.0 (my umask is002, so something sets it explicitly), so "any process on the machine" overstated it — it is any process running as the same user, which in a CI job is every process.Two things worth a look
1. Data loss is reported as success. Every
truncaterow above printedTest run summary: Passed!and produced a 178-byte cobertura file that saysline-rate="1"for zero coverable lines. A threshold gate would pass it. SinceHasIntactBackingalready knows when it has declined to flush, that seems like the place to at least log a warning, and arguably to fail the coverage step.2.
/tmplitter. Each 18.11.0 run leaves about 52 files named/tmp/CodeCoverage.<session>.<guid>, 6 bytes each, contents02 00 00 00 00 00, mode0600, and they are never removed — 674 of them after 13 runs here. 18.10.0 created one buffer file per run and removed it at exit (corrected: I first wrote "left one", which was an in-run count; a measured 18.10.0 run left nothing behind). I have not chased which component writes them.What would actually close this
The probe half is done. What remains is that the host and the collector still both write to, and read from, a file-backed mapping whose backing they do not control, behind a check that cannot be made atomic from managed code. The options I can see, in order of how much they change:
PipeHelper.ConnectAndSendData), and let the collector own the file. Then nothing in the test host touches file-backed memory and the whole class goes away.memfd_create/ an anonymous shared mapping rather than a path under$TMPDIR, so there is no file to truncate or run out of blocks for.Reproducer
gen_repro.pyis unchanged from my previous comment. The probe needed two changes for 18.11.0: match the buffer's/proc/<pid>/mapsentry on/tmp/CodeCoveragerather than on the tokenCodeCoverage(the coverage DLL paths contain it too, so the old check was matching the wrong mapping), and thehammermode. Full script below.probe_live2.pyEnvironment: .NET SDK 10.0.301, runtimes 8.0.26 / 10.0.10, Ubuntu 26.04 x64, xunit.v3 3.2.2 (
xunit.v3.mtp-v2). (Corrected:/tmphere is tmpfs, not ext4 — the buffer file showedst_blocks=8for 1633 bytes while live, i.e. fully page-backed. The truncation results stand; the sparse-file/ENOSPC path was not exercised by these runs.)Happy to run the same matrix against a prerelease.
fhnaseer commentedon Sep 11, 2026
Please use 18.11.2 version and see if it fixes the issue for you or not.
pcshrosbree commentedon Sep 11, 2026
Thanks — I've tested 18.11.2 against the same harness.
Short answer: this fixes it. No arm faulted — 0/10 attack runs, against controls in the same matrix that still die. The truncate/extend loop that killed 18.11.0 after 40k–91k cycles now survives about 8 million cycles per run with no fault and no lost host. Removing the mapping on non-Windows entirely, in both processes, closes the class by construction rather than narrowing it, and I appreciate that you went that way rather than tightening the length check.
Two things are worth your time below: the truncated-buffer case still produces a green run with an empty report, which I can now root-cause to a single byte rather than just report; and the
/tmplitter from 18.11.0 is unchanged.Results
Same solution and same probe as before, plus a
deletearm.truncate= a singletruncate -s 0on the buffer 6 s into a run;hammer= from 6 s, looptruncate(0)/truncate(size)as fast as possible for 15 s;delete=unlinkthe buffer;observe= no attack (control). The last two rows are positive controls: the same probe, against the two releases known to fault.line-rate="1"line-rate="0.909", 341 KBline-rate="0.909"StaticManagedTrackerTemplate.Flush()←PeriodicFlushLoop()A harness note, because the obvious reuse would have given you a false pass
My previous probe gated its attack on first seeing the buffer file in some process's
/proc/<pid>/maps. 18.11.2 maps nothing on Linux, so that gate can never open — every 18.11.2 row would have come back green with the attack never fired. The revised probe triggers on the buffer file existing instead, printsFIRED=True/Falseon every row, and measures liveness from buffer content (count of non-zero bytes over time) rather than file size, since the collectorSetLengths the file before the first hit.With that fixed, the mapping count is itself a measurement: 0 mapped lines on all 12 of the 18.11.2 rows, against 1 on both control rows.
What 18.11.2 changed (decompiled, 18.11.0 → 18.11.2)
HasIntactBackingis deleted from both assemblies, and every non-Windows path now goes through ordinaryFileStreamI/O:InitializeWithSharedMemorynow branches on OS. On non-Windows it calls the newValidateFileBackedBuffer(), then disposes the view and theMemoryMappedFileand nulls both, beforePinCachedHitsAndSetBegin().Flush()delegates to the newFlushFileBackedBuffer(hits), which opens the buffer, re-extends withSetLengthif it has shrunk, and writes runs of set bytes at their offsets. If the write throws it restores the hits it had already cleared and rethrows, so a failed publish loses nothing — that is a nice touch.SharedMemoryStream'sWriteMessage, bothGetCoverageBufferoverloads,IsInitializedandMarkInitializedall take a non-Windows branch through the newReadFileBuffer/WriteFileBuffer, which handle a short read by clearing the remainder.So there is no longer a mapping to lose backing for, in either process. A truncated file is a short read; a filesystem that cannot allocate a block is a caught
IOException. Both were SIGBUS before. I also noticedFailFileBackedPublishAfterRangeCount, which suggests the new publish path has fault-injection tests of its own.The injected IL is unchanged in shape, as expected — captured from a rewritten
LibA.dllunder 18.11.2, byte-for-byte what I first reported:What changed is only what
Beginpoints at.Still open 1: a lost buffer is reported as a successful run, and it is one byte
Every attacked row above printed
Test run summary: Passed!and wrote a 178-byte cobertura file withline-rate="1"over an empty<packages />. That is not caused by the data loss. It is caused by the initialization byte at offset 0, whichSharedMemoryStream.IsInitialized()reads and which gates whether a module enters the report at all —if (logStream.IsInitialized())guardsAddPackage,AddStreamandPopulateCoverageStatus. Truncation zeroes it, and the host's flush only writes indices where a probe fired, so index 0 is never restored.Two runs isolate it, rather than inferring it from a truncation that perturbs everything at once:
line-rate="1"truncateto 0, re-extend to the original length, then set offset 0 back to1line-rate="0.027"Note which direction is dangerous. When the flag survives, you report a low rate that honestly reflects the destroyed data, and any coverage threshold catches it. When the flag is lost, you report a perfect rate over zero lines, which every threshold passes. So the silent-success path is exactly the one where the module is dropped.
Since the collector knows it created the buffer and handed it to the host, a module that comes back uninitialized is a detectable anomaly rather than a normal skip. Logging a warning — or failing the coverage step — at that point would close this. It is cheap and it is the last thing standing between this and a coverage run I would trust in CI.
Still open 2:
/tmplitterUnchanged from 18.11.0: each run leaves 52 six-byte files on net8.0 and 54 on net10.0, named
/tmp/CodeCoverage.<session>.<guid>, contents02 00 00 00 00 00, mode0600, never removed.TryDeleteBackingFiledoes clean up the real coverage buffer — the 1,633-byte one — so it is only these small files that leak. For comparison, 18.10.0 left zero behind in this same matrix.One scoping note
The private-memory change is non-Windows only: on Windows, with
ForcePrivateProbeMemoryunset, the probe still stores through the raw pointer fromAcquirePointer. That mapping is a named section rather than a file, so the attack I have been using does not transfer to it, and nothing in this matrix says whether it is reachable some other way. Flagging it only so the fix is not remembered as broader than it is.Reproducer
gen_repro.pyis unchanged from my first comment. The probe needed the trigger change described above; full script below.probe_live3.pyEnvironment: .NET SDK 10.0.301, runtimes 8.0.26 / 10.0.12, Ubuntu 26.04 x64, xunit.v3 3.2.2 (
xunit.v3.mtp-v2)./tmphere is tmpfs, so the buffer is fully page-backed and the sparse-file/ENOSPC path is still not exercised by these runs — though with no mapping left, that path should now surface as a caughtIOExceptionrather than a fault.Thanks for sticking with this one.
pcshrosbree commentedon Oct 1, 2026
Closing this: 18.11.2 fixes the reported fault on Linux (results in the comment above), and it is still the latest release. Thanks again for the fix.
I've filed the two remaining items separately so they aren't lost when this closes:
/tmp.