Reading the Memory Watchdog
MonolithMeshTestBase carries a memory watchdog: a timer that samples the test host's working set
every few seconds, writes a MEM_WATCHDOG heartbeat into the test trace, and on CI FailFasts the
host when RSS crosses 6 GiB — before the runner's OOM killer turns the shard into a silent
wall-clock timeout. The signal it produces is exit=134 SIGNAL SIGABRT plus a synthetic
<Project>.HOST_CRASHED trx entry.
Until 2026-09-13 its message also named the cause: "This is the cumulative [container] Reflection.Emit factory leak from non-shared MonolithMeshTestBase classes." (the container then in use was Autofac, since removed in MeshWeaver#7145) This page records the occurrence on which the watchdog's own trace refuted that sentence, what the trace actually shows, and the rule for reading one.
The occurrence (#4127)
Core run 34704176793, Run tests (shard 5), Memex.Portal.Shared.Test, 2026-09-12:
MEM_CRITICAL_FAILFAST rss=6384MiB threshold=6144MiB managed=294MiB
vmsz=44384MiB rssAnon=6102MiB rssFile=125MiB
managed=294MiB against rssAnon=6102MiB reads, at first sight, as the message says: growth
outside the managed heap, i.e. native memory pinned by something GC cannot reclaim. The heartbeat
trajectory in collected-logs/_meshweaver-test-trace.log says otherwise:
| time | rss | what the trace shows |
|---|---|---|
| 16:39:29 → 16:39:59 | 276 → 607 MiB | ordinary ramp through the first mesh classes |
| 16:40:01.654 | 609 MiB | LocalSourceContentVersionTest DISPOSE_MEM — the last mesh class before the gap |
| 16:40:04 → 16:40:09 | 1601 → 2407 MiB | no MonolithMeshTestBase class active; gen2 collections 11 → 26 |
| 16:40:12.548 | 2417 MiB | FetchedSourceStillSeedsOnceTest CTOR — the next mesh class |
| 16:40:12 → 16:42:01 | 2300 → 2495 MiB | ~40 mesh classes, every DISPOSE_MEM Δ within ±3 MiB, alc=1 throughout |
| 16:42:01.143 | 2495 MiB | SitemapDescentTest DISPOSE_MEM — the last line before the crash |
| 16:42:02.9 | — | stderr: [MeshWeaver.Mesh.FallbackModule] 'MeshWeaver.Test.ParkedViewPack' … |
| 16:42:04.922 | 6384 MiB | FailFast — vmsz +4 GB in 3.8 s, no mesh class active |
Two steps, not a ramp. Both fell in windows with no MonolithMeshTestBase class between its
INIT_MEM and DISPOSE_MEM lines, and every mesh class on the shard released what it took. A
cumulative per-class leak cannot produce that shape.
MeshWeaver.Test.ParkedViewPack is a module name ModulePlatformLinkTest writes — a plain xUnit
class whose Emit helper compiled with Roslyn against every entry of
TRUSTED_PLATFORM_ASSEMBLIES, ~520 assemblies and ~177 MB of PE images (the test's own bin: 204
DLLs / 82 MB; the runtime: 172 / 66 MB; ASP.NET Core: 141 / 29 MB), through
MetadataReference.CreateFromFile per call. Roslyn reads every reference's metadata when it
binds a compilation, so each call re-read the whole platform into memory that only a finalizer
releases. The arithmetic closes: 19 emits × 177 MB ≈ 3.4 GB against the +3.9 GB step;
ModuleLinkVersionTest's 11 emits ≈ 1.9 GB against the +1.8 GB step in the first gap. The second
occurrence, shard 2 of the same PR (MeshWeaver.Compiler.Pipeline.Test, managed=41MiB), carries
five classes of the same shape — one of them rebuilding the set as an instance field xUnit
re-creates for every test, under a comment explaining that it is "never static".
What decided pass or fail between the two attempts was finalizer timing against the step: attempt 2 passed with no push. That is why it read as a rate rather than a defect.
What the fields measure
| field | source | what it is — and is not |
|---|---|---|
rss |
Process.WorkingSet64 |
resident pages of every kind: managed heap (live AND committed-but-empty), native heap, mapped files, JIT code, stacks |
managed |
GC.GetTotalMemory(false) |
the GC's estimate of LIVE managed bytes. Not what the GC has committed — a heap that grew to absorb a burst and has not decommitted counts in rssAnon exactly like a native block |
gcCommitted / gcHeap |
GC.GetGCMemoryInfo() |
added on #4127: the committed and total heap sizes, so managed small + rssAnon large can be split into "the GC is holding it" and "something native is" |
rssAnon |
/proc/self/status (Linux) |
anonymous resident pages — native heap, GC heap commit, JIT code, stacks. Not file-backed |
rssFile |
/proc/self/status (Linux) |
file-backed resident pages — memory-mapped assemblies, including an ALC's loaded images |
alc= / asm= |
the trace's INIT_MEM/DISPOSE_MEM |
collectible load contexts alive / assemblies loaded — the fingerprint of an ALC that did not unload |
The thresholds are unchanged: soft MEM_PRESSURE at 4 GiB, FailFast at 6 GiB, CI only. Raising
either would be a bound-widening.
How to read a trajectory
- Find the shape. Take the
MEM_WATCHDOGlines (every 5 s) and theINIT_MEM/DISPOSE_MEMpairs. A ramp whoseDISPOSE_MEMdeltas are positive across many mesh classes is per-mesh retention — an ALC that did not unload (alc=climbing; see Debugging Native Crashes for theLoaderAllocatorroot walk), a container root, a hosted service that outlived its mesh. A step inside a window with no mesh class active is a plain test class: a Roslyn compile, a large allocation, a file read into memory. - Name the class in the gap. The test reporter prints a test's output when it finishes and
prints nothing for a test that wrote none, so a plain class is invisible in the console log. The
stderr lines the runtime writes (
[MeshWeaver.Mesh.FallbackModule],[ModuleSet]) carry names the test chose; grep the test tree for them. The xUnit-list classesorder is not the run order, so do not infer the neighbour from the source tree. - Check the arithmetic before believing the mechanism. Count the calls in the window and the bytes each would take; a mechanism that does not reproduce the step's size is not the mechanism.
- Do not read
managedagainstrssAnonas "native". ReadgcCommittedfirst.
The fix
The tests now share the process-wide reference set production compiles against —
CompileReferences.Default, built once at type load and allowlisted as the one Roslyn reference
holder — filtered per call (PlatformReferences.Platform(excludeReference)) instead of rebuilding
it. Roslyn caches each reference's metadata and symbol table against the instance, so the platform
is read once per process. Measured on one machine (/usr/bin/time -l, peak RSS of the class run
alone, before → after):
| class | tests | peak RSS | wall time |
|---|---|---|---|
ModulePlatformLinkTest |
23 | 1054 → 350 MiB | 3.7 → 0.9 s |
ModuleLinkVersionTest |
11 | 615 → 367 MiB | 5.1 → 2.0 s |
the three Memex.Portal.Shared.Test classes together |
47 | 1250 → 457 MiB | 7.8 → 3.0 s |
ConfiguredModuleActivationTest |
34 | 1109 → 322 MiB | 6.0 → 1.2 s |
CompileFailureReportedOnceTest |
8 | 727 → 403 MiB | 0.8 → 0.5 s |
CompileFailureReportOrderTest |
5 | 729 → 287 MiB | 0.6 → 0.3 s |
EmitToDiskWithRetryTest |
10 | 680 → 300 MiB | 1.1 → 0.5 s |
A mesh class on the same machine peaks at ~180 MiB, which is the floor the emitting classes now sit just above.
Two sites of the same shape remain at low volume and are named rather than changed here:
MeshTestSuiteTest.ProbeReferences (MeshWeaver.PluginTester.Test, 7 tests) and
ModulesAssemblyLoadContextTest.Emit (MeshWeaver.Hosting.Orleans.Test, 3 tests).
The second occurrence: disposed meshes that stayed reachable
Plugins #4230, run 40214543622, portal-hosts (Memex.Portal.Shared.Test): the host aborted after
10 min 42 s at rss=6281MiB managed=4932MiB gcHeap=4991MiB. The suite has no baseline, because
the shard had not run in the previous 25 runs. This time the trajectory WAS a ramp across mesh
classes.
Read DISPOSE_MEM with a forced collection. Without MESHWEAVER_TEST_FORCE_GC=1, managed=
counts garbage that has not been collected yet. A workstation-GC host can go minutes between gen-2
collections, so the ramp then mixes retention with uncollected garbage. With the variable set,
consecutive DISPOSE_MEM lines are live bytes after a full compacting collection. Measured
locally, that live figure still climbed from 221 MiB to about 1060 MiB in the first two minutes.
Then ask the heap who holds a disposed mesh. Take a heap dump of the running host:
dotnet-dump collect --type Heap, with TMPDIR set to the host's, because the diagnostic socket
lives there. Read it with dumpheap -stat -live -ignoreGCState, then gcroot one per-mesh
singleton (here TypeDisplayMetadataCache, one per mesh-root TypeRegistry) for every instance,
and classify the first non-infrastructure object on each path. The reading three minutes in:
| what | count |
|---|---|
| live heap | 1.14 GB, 835 MB of it strings |
meshes actually running (per-mesh NodeAssemblyLoadContext maps) |
23 |
live mesh-root registries (TypeDisplayMetadataCache) |
84 |
… rooted through IHttpClientFactory (ActiveHandlerTrackingEntry, DefaultHttpClientFactory) |
68 |
| … rooted through a running mesh's stream-cache sweep | 14 |
… rooted through AccessService+AccessContextScope in a timer's captured ExecutionContext |
1 |
live XmlMemberDoc (the per-mesh XML documentation reader) |
1,032,764 |
Three retention edges, each fixed where it lives:
IHttpClientFactory's handler-expiry timer. Each handler gets a two-minute lifetime, enforced by aTimerin the process-wide timer queue. The timer's state is the tracking entry, the entry holds the service scope the handler was built in, and the scope reaches the whole container. Disposed meshes therefore stayed reachable for two minutes, which is about 80 at this suite's rate. Of 81 live entries, 61 were thedeployment-reportclient, which every mesh creates inDeploymentReportService's constructor. Test meshes now set an infinite handler lifetime (MeshScopedHttpHandlers, applied inConfigureMeshBase), so the factory starts no timer. A production mesh lives as long as its process, so this retention never arises there.- The test logger closed over the test.
TestBaseregisterednew XUnitFileLoggerProvider(() => FileOutput, …), which capturedthis. Every logger the mesh handed out could then reach the test instance, and from there itsServices, theMeshBuilderand the mesh. Anything that keeps a logger alive past the mesh kept the whole mesh. The closure now captures the output helper alone. - The access scope marker was the scope.
AccessServicemarks the flow with anAsyncLocalvalue. That value was theAccessContextScopeitself, which holds the service, and so the service's logger. EveryExecutionContextcaptured inside a scope carried it: each timer, thread-pool item and Rx scheduled action started under an impersonation. The marker is now an opaque token.
What made each retained mesh expensive is a separate cost, and this fix does not address it. XML documentation is now read once per mesh rather than once per process, about 10 MB per mesh here. Retention multiplies that cost by the number of meshes held.
Measured on one machine, the full Memex.Portal.Shared.Test run (3158 tests, same 15
environment-bound local failures before and after), peak RSS sampled every 10 s:
| peak RSS | wall time | |
|---|---|---|
| before | 7941 MiB | 10 min 40 s |
| after | 4858 MiB | 10 min 6 s |
At the same point in a forced-GC run, the live heap was 1.14 GB before and 456 MB after. Live mesh-root registries fell from 84, with 23 meshes running, to 30, with 29 running: no disposed mesh remained reachable.
Regression tests: TestMeshHttpHandlersDieWithTheMeshTest, which carries its own negative
control, plus TestLoggerDoesNotRootTheTestTest (MeshWeaver.Hosting.Test) and
AccessScopeDoesNotRootTheServiceTest (MeshWeaver.Messaging.Hub.Test). Each of the last two
fails with its fix reverted.
After both fixes (retention, plus the shared XML-doc text of MeshWeaver#7213)
The same suite, now run 4-way parallel (its xunit.runner.json), against a core that carries both
the retention fix and #7213's shared, interned doc text. It ran 3163 tests with none failing. Peak
RSS was sampled every 5 s in a run with no dump attached.
| peak RSS | wall time | live heap ~3 min | strings | XmlMemberDoc |
readers / mesh-root registries / running meshes | |
|---|---|---|---|---|---|---|
| before | 7941 MiB | 10 min 40 s | 1.14 GB | 835 MB | 1,032,764 | — / 84 / 23 |
| retention fix only | 4858 MiB | 10 min 6 s | 456 MB | 298 MB | ~357k | 30 / 30 / 29 |
| both fixes | 1199 MiB | 5 min 56 s | 159 MB | 32.8 MB | ~348k | 29 / 29 / 28 |
The rows are not strictly like-for-like: the earlier two ran serially and the last one 4-way parallel. Readers now track running meshes one to one, so no disposed mesh holds its docs. What remains is the doc index of each running mesh: its dictionary nodes and entries, about 1.3 MB per mesh. A production portal runs one mesh, so a lazier per-member snapshot would save about 1.3 MB there at the cost of a cache and a second parse path. It was therefore not built.
The rule
A guard's message states what it MEASURED and where the cause can be read. It does not name the cause. The watchdog measures RSS against a threshold; the trace measures which class was active and what each released; neither measures the DI container. A message that names a mechanism its instrument cannot see sends the reader to the wrong repository of fixes — here, to the mesh classes that were flat, away from the plain ones that were not.
The same rule as Controls That Cannot Fail and Reading a Disposal Stall Verdict, one instrument over.
Where the code is
| Piece | File |
|---|---|
| the watchdog | test/MeshWeaver.Hosting.Monolith.TestBase/MonolithMeshTestBase.cs (_memWatchdog, MemCriticalBytes) |
| test meshes' HTTP handler lifetime | test/MeshWeaver.Hosting.Monolith.TestBase/MeshScopedHttpHandlers.cs |
| the test logger provider | test/MeshWeaver.Fixture/TestBase.cs |
| the access scope marker | src/MeshWeaver.Messaging.Hub/AccessService.cs (AccessContextScope) |
| the shared reference set | src/MeshWeaver.Compiler/CompileReferences.cs (Default) |
| the test-side filter | test/Memex.Portal.Shared.Test/PlatformReferences.cs, test/MeshWeaver.Compiler.Pipeline.Test/PlatformReferences.cs |
| where the trace lands on CI | .github/workflows/dotnet-test.yml → collected-logs/_meshweaver-test-trace.log, _meshweaver-memory-delta.log |