Skip to content

[wasm][coreCLR] R2R generic dictionary lookup traps with null function (zero import cell for GenericLookupSignature) #129821

Description

@pavelsavara

Summary

On wasm (CoreCLR, R2R), executing shared-generic R2R code that performs a generic-dictionary lookup (e.g. typeof(T) inside a __Canon instantiation) traps with:

Uncaught RuntimeError: null function or function signature mismatch

This is a call_indirect to wasm function-table index 0. The generic-lookup R2R import cell is emitted as a zero pointer and the runtime-lookup slow path calls through it.

Repro

Branch with the wasm R2R bring-up work (depends on #129766 / UCO→R2R dispatch to reach the R2R-native export). Reproduced deterministically on V8:

.\dotnet.cmd build -bl /p:TargetOS=browser /p:TargetArchitecture=wasm /p:Configuration=Debug /p:RuntimeFlavor=CoreCLR /t:Test .\src\libraries\System.Runtime.InteropServices.JavaScript\tests\System.Runtime.InteropServices.JavaScript.UnitTests\System.Runtime.InteropServices.JavaScript.Tests.csproj /p:Scenario="WasmTestOnV8" /p:WasmTestAppArgs="-method System.Runtime.InteropServices.JavaScript.Tests.JSExportTest.JsExportIJSObject"

Call chain: JsExportIJSObject (R2R) → UCO/interp→R2R thunk → JsExportTest<System.__Canon> (R2R shared-generic) → traps at the first generic-dictionary access.

Isolation (confirmed)

Staged diagnostics inside JsExportTest<T> (3 V8 runs) pinpoint the trap precisely:

step operation result
enter JsExportTest<__Canon> (no dictionary) OK
invoke == null check (no dictionary) OK
res = invoke(value, echoName) shared-generic delegate invoke OK, res non-null
typeof(T) generic-dictionary lookup TRAPS "null function"

The shared-generic delegate invoke works; only the generic-dictionary lookup traps. (This refutes an earlier delegate _methodPtr hypothesis.)

Root cause

  • getReadyToRunHelper returns false on wasm (CorInfoImpl.ReadyToRun.cs, "WebAssembly doesn't use the compact ReadyToRun helpers"), so generic-handle lookups route through the runtime-lookup path (embedGenericHandleComputeRuntimeLookupForSharedGenericToken).
  • ProcessDynamicDictionaryLookup (src/coreclr/vm/prestub.cpp) resolves the general (non-MVAR/VAR-fast-path) case as CORINFO_USEHELPER with CORINFO_HELP_RUNTIMEHANDLE_METHOD / _CLASS — a helper call made through the generic-lookup import cell.
  • That import cell is a DelayLoadHelperImport. On wasm its delay-load helper is set to null for GenericLookupSignature, and EncodeData then emits a zero pointer:

https://github.com/dotnet/runtime/blob/main/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/DelayLoadHelperImport.cs

if (factory.Target.Architecture == TargetArchitecture.Wasm32)
{
    if (instanceSignature is GenericLookupSignature)
    {
        // Generic lookups are resolved via eager fixups and don't need import thunks
        _delayLoadHelper = null;
    }
    else
    {
        _delayLoadHelper = factory.WasmImportThunkPortableEntrypoint(this);
    }
}

The comment's assumption is incorrect for method/this-dependent dictionary lookups: they are inherently per-call dynamic (they need the runtime generic-context argument) and cannot be resolved by Module::RunEagerFixups (which only processes import sections flagged Eager and asserts *fixupCell != 0). So the cell stays 0, and the USEHELPER slow path does call_indirect to index 0 → "null function".

Introduced by #127483 (commit 2ac8dc55ec2, "[WASM] Add READYTORUN_FIXUP_InjectStringThunks and R2R thunk infrastructure"), which changed wasm from always using WasmImportThunkPortableEntrypoint to nulling the helper for GenericLookupSignature.

Why it isn't a one-line revert

Simply restoring WasmImportThunkPortableEntrypoint(this) for generic-lookup cells makes crossgen2 itself crash during R2R compilation of any assembly with a generic-dictionary lookup:

System.IndexOutOfRangeException
   at ILCompiler.DependencyAnalysis.ReadyToRun.WasmImportThunk.EmitCode(...)

WasmImportThunk.EmitCode raises the wasm signature to a managed MethodSignature and walks it with an ArgIterator; the generic-lookup helper's hidden generic-context argument (HasGenericContextArg is hardcoded false) makes the offsets[] / wasm-locals arrays mismatch. This is the real reason the cell was nulled — emitting a generic-lookup delay-load thunk is deferred (WASM-TODO) work.

(Verified locally: applied the revert, rebuilt crossgen2-wasm, reproduced the IndexOutOfRangeException, then reverted again.)

Proposed fix

Implement generic-lookup (dynamic dictionary lookup) delay-load thunk support on wasm:

  1. CompilerWasmImportThunk.EmitCode to handle the generic-context argument in its arg spill/restore loops (and surface HasGenericContextArg for generic-lookup signatures).
  2. VM — a matching delay-load helper that invokes ProcessDynamicDictionaryLookup with the generic context + signature and patches the cell, then wire the appropriate ReadyToRunHelper id for generic lookups.

Alternative: emit the dictionary-lookup slow path inline on wasm without a call through a delay-load cell (larger codegen change).

Relevant files

  • src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/DelayLoadHelperImport.cs
  • src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmImportThunk.cs
  • src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs (getReadyToRunHelper, embedGenericHandle)
  • src/coreclr/vm/prestub.cpp (ProcessDynamicDictionaryLookup)
  • src/coreclr/vm/ceeload.cpp (Module::RunEagerFixups)

cc @davidwrighton @radekdoulik

Note

This issue was authored with the assistance of GitHub Copilot.

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions