Skip to content

[browser][coreCLR] R2R: EventPipe rundown traps with null function and omits precompiled method events #132410

Description

@pavelsavara

Description

With ReadyToRun enabled for CoreCLR browser-wasm (#132339), stopping an EventPipe session that requests method rundown traps the runtime:

DOTNET: Unhandled error:  null function
RuntimeError: null function
    at dotnet.native.wasm:wasm-function[1470]:0x7f04d
    at dotnet.native.wasm:wasm-function[3180]:0x14108f
    at dotnet.native.wasm:wasm-function[3182]:0x1418d4
    at dotnet.native.wasm:wasm-function[1519]:0x880af
    at dotnet.native.wasm:wasm-function[5522]:0x26c703
    at dotnet.native.wasm:wasm-function[7782]:0x2d01f4
    at dotnet.native.js:1:69544
    at callUserCallback (dotnet.native.js:1:109091)

Symbolicated via dotnet.native.js.symbols (innermost first):

index symbol
1470 ETW::MethodLog::SendMethodEvent(MethodDesc*, ...)
3180 ETW::EnumerationLog::IterateModule(Module*, unsigned int)
3182 ETW::EnumerationLog::IterateAppDomain(unsigned int)
1519 stop_session(unsigned long long)
5522 server_loop_tick(void*)
7782 SystemJS_ExecuteDiagnosticServerCallback

After the trap the runtime is dead — every subsequent call reports Assert failed: The runtime is not running.

Root cause

IterateModule calls ETW::MethodLog::SendEventsForNgenMethods (inlined in the stack above), whose entire body is gated on the module being R2R:

#ifdef FEATURE_READYTORUN
    if (pModule->IsReadyToRun())
    {
        ReadyToRunInfo::MethodIterator mi(pModule->GetReadyToRunInfo());
        while (mi.Next())
        {
            // Call GetMethodDesc_NoRestore instead of GetMethodDesc to avoid restoring methods at shutdown.
            MethodDesc *hotDesc = (MethodDesc *)mi.GetMethodDesc_NoRestore();
            if (hotDesc != NULL)
                ETW::MethodLog::SendMethodEvent(hotDesc, dwEventOptions, FALSE);
        }
        return;
    }
#endif

So this path is unreachable without R2R, which is why the failure appeared only once R2R was turned on.

SendMethodEvent is called with pNativeCodeStartAddress == NULL, so it uses pMethodDesc->GetNativeCode(), then:

TADDR start = MethodAndStartAddressToEECodeInfoPointer(pMethodDesc, pNativeCodeStartAddress);
if (start == 0) return;          // only guards a null address
EECodeInfo codeInfo(start);
codeInfo.GetMethodRegionInfo(&methodRegionInfo);   // unguarded

EECodeInfo::Init takes its Invalid: path when ExecutionManager::FindCodeRange (or JitCodeToMethodInfo) fails, leaving m_pJM = NULL. GetMethodRegionInfo then does GetJitManager()->JitTokenToMethodRegionInfo(...) — a virtual dispatch through a null pointer. On x64 that is a null-deref AV; on wasm a call_indirect through an empty function-table slot is reported as RuntimeError: null function.

EECodeInfo::IsValid() is the established guard for exactly this and is used in ~37 places across the VM, including neighbours making the identical call (perfmap.cpp, gccover.cpp). eventtrace.cpp had zero uses of it.

Two separable problems

  1. Missing validity guard (latent on all platforms). SendMethodEvent and SendMethodILToNativeMapEvent use EECodeInfo without checking IsValid(). Addressed by adding the guard; the affected method is then skipped rather than killing the runtime.

  2. R2R code addresses do not resolve on wasm (the actual gap). With the guard in place the crash disappears, but every R2R-precompiled method is silently omitted from the rundown, so traces are missing MethodLoad/MethodDCEnd events for all framework code. Two candidates, not yet distinguished:

    • R2R code ranges are not registered with ExecutionManager on wasm (ReadyToRunJitManager range registration missing/incomplete), or
    • GetNativeCode() for an unrestored R2R method on wasm returns something that is not a code address at all (plausibly a function-table index), which can never resolve through a range lookup.

Impact

Two conditions must both hold:

  1. the session requests method rundown (NgenMethodDCEnd etc.), and
  2. the module is R2R.

Observed in Wasm.Build.Tests.Blazor.EventPipeDiagnosticsTests:

  • BlazorEventPipeTestWithHeapDumptraps. In CI the promise returned by collectGcDump never settles, so the work item ran until the 90-minute Helix executor kill.
  • BlazorEventPipeTestWithMetricspasses on the same R2R app; its session does not request method rundown, confirming condition (1) is required.

Reproduction

Local, with R2R on (the default for CoreCLR browser-wasm):

$env:EMSDK_PATH="<repo>\.dotnet\wasm-tools\emscripten\<ver>-windows-x64"
.\dotnet.cmd build /p:TargetOS=browser /p:TargetArchitecture=wasm /p:Configuration=Release `
  /p:RuntimeFlavor=CoreCLR /t:Test `
  /p:XUnitMethodName=Wasm.Build.Tests.Blazor.EventPipeDiagnosticsTests.BlazorEventPipeTestWithHeapDump `
  src/mono/wasm/Wasm.Build.Tests

Or directly in the browser against a built app:

await globalThis.getDotnetRuntime(0).collectGcDump({ durationSeconds: 5.0, skipDownload: true });

Bisected by rebuilding the same app with R2R off — same app, same dotnet.native.wasm:

build System.Private.CoreLib served collectGcDump
PublishReadyToRun=true 29,098,176 B (R2R image) traps, runtime dead, promise never settles
PublishReadyToRun=false (clean rebuild) 5,622,045 B (IL/webcil) resolves with a valid Nettrace payload

An incremental rebuild with -p:PublishReadyToRun=false does not re-stage the assemblies and still serves the R2R images, giving a false negative. Delete obj/ and bin/ first.

Ask

Item 2 above: make R2R method code addresses resolvable on wasm so rundown emits method events for precompiled code, and re-enable the tests skipped against this issue.

Note

This issue body was generated by GitHub Copilot from a local investigation (symbolication, source analysis and an R2R on/off bisect) and reviewed before posting.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions