Description
Under profile-guided partial AOT (RunAOTCompilation=true + WasmAotProfilePath, profile-only method set), a browser-wasm app intermittently hits a Mono metadata assertion:
[MONO] * Assertion at /__w/1/s/src/runtime/src/mono/mono/metadata/class-accessors.c:90, condition `<disabled>' not met
with two observable failure shapes from the same defect:
- Silent managed-task death — the assert fires, the executing managed continuation dies, the app keeps running but the work it was doing is lost (in our app: a server-sync apply loop dies, leaving a permanently empty UI).
- Uninterruptible 100%-CPU hang — the renderer main thread enters a call-free wasm loop that V8 cannot interrupt (no stack check on call-free back edges): CDP
Profiler.start and Debugger.pause both time out, Runtime.evaluate never returns, one thread pins a core indefinitely.
The correlation between the assert and the hang is direct: in 3 of 5 captured hangs, the assert's console timestamp equals the hang onset to the second (the other two hangs froze without getting the assert line out).
The same workload has never exhibited either failure under the interpreter (no AOT, identical bundle otherwise), nor with the jiterpreter — see the A/B arm below — nor on the same framework's WPF/Android (non-wasm) heads.
At the v10.0.9 tag the nearest assertion to the reported line is g_assert (klass) in mono_class_get_flags (class-accessors.c:89; the CI build path suggests a servicing-branch build, so ±1 line drift) — i.e. a NULL MonoClass* reaching mono_class_get_flags.
Reproduction Steps
We cannot share a minimal repro (the app is a large private codebase), but the failure is statistically reliable and quick under an automated soak:
- App: Blazor-less
Microsoft.NET.Sdk.BlazorWebAssembly-style browser-wasm head (WebSocket client + UI framework; STJ serialization; no threading, no InvokeAsync marshalling beyond the JS module interop).
- Publish:
dotnet publish -c Release -p:RunAOTCompilation=true -p:WasmAotProfilePath=<recorded profile> -p:WasmStripILAfterAOT=false (profile recorded with WasmProfilers=aot).
- Drive the app in a headless-Chrome loop (open app → open list-heavy views → navigate back, ~45s/cycle). The failure always strikes while the client is consuming a large burst of inbound WebSocket messages (initial sync or a view-open message burst) — i.e. deserialization + data-structure churn on the main thread.
- Frequency: 5 hangs in ~40 cycles across 3 runs (first at cycle 9), plus several assert-with-silent-death boots. A 30-minute soak has been decisive every time.
We can privately share: a stacks-only minidump of the spinning renderer process, CDP CPU/process samples, console rings with the assert + wasm stacks, and the soak driver.
Expected behavior
Partial AOT (profile-guided) behaves like the interpreter: no metadata asserts, no hangs.
Actual behavior
Intermittent class-accessors.c:90 assert; depending on where it strikes, silent loss of the executing task or a permanent uninterruptible 100%-CPU wasm loop.
Regression?
Unknown — this is our first use of AOT on this workload (interpreter has been stable for months on the same code).
Known Workarounds
None that keep AOT:
- Interpreter-only publish: never reproduces.
- Disabling all jiterpreter tiers (
--no-jiterpreter-traces-enabled --no-jiterpreter-interp-entry-enabled --no-jiterpreter-jit-call-enabled) on the AOT build: reproduces identically (hang at cycle 3, same RIP signature) — the loop is in V8-compiled dotnet.native.wasm, not a jiterpreter trace module.
Configuration
- .NET SDK 10.0.301,
wasm-tools workload 10.0.109
- Runtime pack
Microsoft.NETCore.App.Runtime.Mono.browser-wasm 10.0.9
- Emscripten 3.1.56 (pack 10.0.9)
- Chrome 150.0.7871.102 (headless and headed both affected), Windows 11 x64 host
- Single-threaded wasm (no
WasmEnableThreads)
Other information
Assert + wasm stack as captured (release runtime, so symbols are function indices only; happy to run a symbolicated build if that helps):
2026-07-12T21:18:55.985Z [MONO] * Assertion at /__w/1/s/src/runtime/src/mono/mono/metadata/class-accessors.c:90, condition `<disabled>' not met
Error
at Mc (dotnet.runtime.a6jcqbs390.js:3:172283)
at dotnet.native.tozcon2nad.wasm:wasm-function[46977]:0x9fbf84
at dotnet.native.tozcon2nad.wasm:wasm-function[35492]:0x815b40
at dotnet.native.tozcon2nad.wasm:wasm-function[35397]:0x813d0f
at dotnet.native.tozcon2nad.wasm:wasm-function[35401]:0x813e40
at dotnet.native.tozcon2nad.wasm:wasm-function[35403]:0x813e87
at dotnet.native.tozcon2nad.wasm:wasm-function[35402]:0x813e58
at dotnet.native.tozcon2nad.wasm:wasm-function[36507]:0x849e23
at dotnet.native.tozcon2nad.wasm:wasm-function[38994]:0x8c1f53
at dotnet.native.tozcon2nad.wasm:wasm-function[39300]:0x8d3989
Hang forensics (why we're confident this is a runtime-internal loop, not an app loop):
- Live RIP sampling of the spinning thread (
GetThreadContext × 40 @ 100ms): every sample lands in a ~500–700 byte window of anonymous executable memory (V8-JITted wasm; all chrome module ranges elsewhere), showing two hot basic blocks — the profile of a tiny probe loop over a corrupted runtime structure (e.g. a hash-table probe chain or a linked list made circular), not application logic.
- The app's own re-scheduling paths all hop through
queueMicrotask/setTimeout, so an app-level loop keeps the event loop alive; here a single synchronous turn never ends.
- CDP
Profiler.enable/start and Debugger.enable/pause both time out against the wedged renderer — consistent with a call-free wasm loop (no interrupt check on the back edge).
Related (same file/family, none an exact match): #45304 (:83, .NET 6 FullAOT), #65957 (:89, .NET 7 CI), #64774 (:92, .NET 7 CI), #63749 (GC filler class, wasm AOT), #120883 / #121738 (:93 "should not be reached", .NET 10, debugger-attach path — different assert and trigger).
Full trip diagnosis (timeline, A/B arms, per-trip histogram)
- Trip histogram across 5 hangs: always during an inbound message burst following a view-open action; server logs show the wedged client's last wire send is the post-open sync request, then ~55 outbound messages ship and the client never requests again.
- Timeline of the first captured trip (one browser process, three page sessions):
- Session 1 boots → assert fires during initial sync → session keeps running but the sync apply task died silently (empty data, UI never populates) → driver reloads.
- Session 2 boots clean; 8 cycles run normally.
- Cycle 9: view-open click → renderer main thread never returns; heartbeat (20s
Runtime.evaluate) fails twice → trip declared 42s after the click.
- CPU: renderer process pinned at 97–99% of one core from the first post-trip sample onward; a single thread owns ~98% (525 CPU-seconds accumulated by capture time).
- Jiterpreter-disabled arm: tripped identically at cycle 3 (fresh JIT address, same anonymous-window RIP signature, 40/40 samples).
- Interpreter arm (same app, no AOT): zero occurrences ever, including months of daily development use.
Description
Under profile-guided partial AOT (
RunAOTCompilation=true+WasmAotProfilePath, profile-only method set), a browser-wasm app intermittently hits a Mono metadata assertion:with two observable failure shapes from the same defect:
Profiler.startandDebugger.pauseboth time out,Runtime.evaluatenever returns, one thread pins a core indefinitely.The correlation between the assert and the hang is direct: in 3 of 5 captured hangs, the assert's console timestamp equals the hang onset to the second (the other two hangs froze without getting the assert line out).
The same workload has never exhibited either failure under the interpreter (no AOT, identical bundle otherwise), nor with the jiterpreter — see the A/B arm below — nor on the same framework's WPF/Android (non-wasm) heads.
At the
v10.0.9tag the nearest assertion to the reported line isg_assert (klass)inmono_class_get_flags(class-accessors.c:89; the CI build path suggests a servicing-branch build, so ±1 line drift) — i.e. a NULLMonoClass*reachingmono_class_get_flags.Reproduction Steps
We cannot share a minimal repro (the app is a large private codebase), but the failure is statistically reliable and quick under an automated soak:
Microsoft.NET.Sdk.BlazorWebAssembly-style browser-wasm head (WebSocket client + UI framework; STJ serialization; no threading, noInvokeAsyncmarshalling beyond the JS module interop).dotnet publish -c Release -p:RunAOTCompilation=true -p:WasmAotProfilePath=<recorded profile> -p:WasmStripILAfterAOT=false(profile recorded withWasmProfilers=aot).We can privately share: a stacks-only minidump of the spinning renderer process, CDP CPU/process samples, console rings with the assert + wasm stacks, and the soak driver.
Expected behavior
Partial AOT (profile-guided) behaves like the interpreter: no metadata asserts, no hangs.
Actual behavior
Intermittent
class-accessors.c:90assert; depending on where it strikes, silent loss of the executing task or a permanent uninterruptible 100%-CPU wasm loop.Regression?
Unknown — this is our first use of AOT on this workload (interpreter has been stable for months on the same code).
Known Workarounds
None that keep AOT:
--no-jiterpreter-traces-enabled --no-jiterpreter-interp-entry-enabled --no-jiterpreter-jit-call-enabled) on the AOT build: reproduces identically (hang at cycle 3, same RIP signature) — the loop is in V8-compileddotnet.native.wasm, not a jiterpreter trace module.Configuration
wasm-toolsworkload 10.0.109Microsoft.NETCore.App.Runtime.Mono.browser-wasm10.0.9WasmEnableThreads)Other information
Assert + wasm stack as captured (release runtime, so symbols are function indices only; happy to run a symbolicated build if that helps):
Hang forensics (why we're confident this is a runtime-internal loop, not an app loop):
GetThreadContext× 40 @ 100ms): every sample lands in a ~500–700 byte window of anonymous executable memory (V8-JITted wasm; all chrome module ranges elsewhere), showing two hot basic blocks — the profile of a tiny probe loop over a corrupted runtime structure (e.g. a hash-table probe chain or a linked list made circular), not application logic.queueMicrotask/setTimeout, so an app-level loop keeps the event loop alive; here a single synchronous turn never ends.Profiler.enable/startandDebugger.enable/pauseboth time out against the wedged renderer — consistent with a call-free wasm loop (no interrupt check on the back edge).Related (same file/family, none an exact match): #45304 (
:83, .NET 6 FullAOT), #65957 (:89, .NET 7 CI), #64774 (:92, .NET 7 CI), #63749 (GC filler class, wasm AOT), #120883 / #121738 (:93"should not be reached", .NET 10, debugger-attach path — different assert and trigger).Full trip diagnosis (timeline, A/B arms, per-trip histogram)
Runtime.evaluate) fails twice → trip declared 42s after the click.