Skip to content

[browser][wasm] Profile-guided partial AOT: intermittent Mono assert at class-accessors.c:90 kills managed task silently or hangs renderer in uninterruptible call-free wasm loop (.NET 10.0.9) #130592

Description

@benpetermorris

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:

  1. 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).
  2. 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.

Metadata

Metadata

Assignees

Labels

arch-wasmWebAssembly architecturearea-VM-meta-mononeeds-author-actionAn issue or pull request that requires more info or actions from the author.os-browserBrowser variant of arch-wasm

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions