Skip to content

[wasm][coreCLR] R2R delegate* unmanaged<> call to [UnmanagedCallersOnly] method traps with indirect call signature mismatch #129857

Description

@pavelsavara

Summary

On wasm (CoreCLR interpreter, FEATURE_PORTABLE_ENTRYPOINTS) with R2R-compiled assemblies, calling an [UnmanagedCallersOnly] method through a delegate* unmanaged<> function pointer traps:

RuntimeError: indirect call signature mismatch

This is the forward counterpart of #129766 (native → managed UCO reverse calls) and #129808 (reflection invoke). #129766 fixed the reverse-pinvoke path; this function-pointer / ldftn path is still broken.

A managed delegate*<> call to an ordinary method works fine, which isolates the bug to the UnmanagedCallersOnly function-pointer path.

Repro

Minimal repro in src/mono/sample/wasm/console-node (the sample assembly is R2R-compiled via <WasmReadyToRunAssembly Include="$(AssemblyName)"/>):

using System;
using System.Runtime.InteropServices;

public static unsafe class Repro
{
    [UnmanagedCallersOnly]
    public static int UcoAdd(int a, int b) => a + b;

    public static int ManagedAdd(int a, int b) => a + b;

    public static void Run()
    {
        Console.WriteLine("[1] managed calli delegate*<int,int,int> (control)");
        delegate*<int, int, int> managed = &ManagedAdd;
        Console.WriteLine("    ok managed calli -> " + managed(2, 3));   // works

        Console.WriteLine("[2] UCO calli delegate* unmanaged<int,int,int>");
        delegate* unmanaged<int, int, int> uco = &UcoAdd;
        Console.WriteLine("    ok UCO calli -> " + uco(2, 3));           // <-- traps before printing
    }
}

public class Test
{
    public static int Main(string[] args)
    {
        Console.WriteLine("Hello World!");
        Repro.Run();
        return 0;
    }
}

Run:

.\dotnet.cmd build /p:TargetOS=browser /p:TargetArchitecture=wasm /p:Configuration=Debug /p:RuntimeFlavor=CoreClr /t:RunSample .\src\mono\sample\wasm\console-node\

Output (process aborts at [2]; the wasm trap is not a catchable managed exception, so the last printed line identifies the failing call):

Hello World!
[1] managed calli delegate*<int,int,int> (control)
    ok managed calli -> 5
[2] UCO calli delegate* unmanaged<int,int,int>

Note

Node/V8 swallows the trap text and exits 1. Running the same binary under Firefox (test-browser) prints the exact RuntimeError: indirect call signature mismatch. This matches the startup crashes observed in CI on the browser lanes for System.Runtime.Tests, System.Threading.Tests, and System.Net.Http.Functional.Tests.

Root cause

The call site and the callee are both correct; the function-pointer value is wrong.

From the R2R wasm disassembly of the sample (llvm-objdump):

  • The UCO call site is call_indirect 1, where type 1 = (i32,i32) -> i32 — the correct unmanaged signature.
  • The FUNCTION section shows UcoAdd is emitted as a single function of type 1 (correct). No interpreter↔R2R thunk is emitted for it (the !IsUnmanagedCallersOnly guard in CorInfoImpl.ReadyToRun.cs).
  • &UcoAdd is loaded from an R2R READYTORUN_FIXUP_MethodEntry cell.

VM-side resolution (jitinterface.cpp, READYTORUN_FIXUP_MethodEntry):

result = pMD->GetMultiCallableAddrOfCode(CORINFO_ACCESS_UNMANAGED_CALLER_MAYBE);

For a UCO method this takes the EnsureCodeForUnmanagedCallersOnly() branch in MethodDesc::TryGetMultiCallableAddrOfCode and returns PortableEntryPoint::GetActualCode(...) = _pActualCode. PR #129766's logic is present in GetUnmanagedCallersOnlyThunk and returns the R2R native entry for R2R methods.

Instrumenting that resolution for UcoAdd (browser, Debug, R2R) yields:

result=0x1924   isVIP=0   tableIdx=0x0
result=0x1924   nativeCode=0x409e8a8   pep=0x409e8a8   hasNative=1

Interpretation:

  • result = 0x1924 = _pActualCode — a small value, i.e. an __indirect_function_table index (not a virtual IP: IsVirtualIP=0).
  • GetNativeCode() == GetPortableEntryPointIfExists() == 0x409e8a8 = the PortableEntryPoint struct address (a ~67 MB linear-memory address).

The mechanism, confirmed from the disassembly: framework managed calls dereference the PortableEntryPoint (i32.load [PE] → _pActualCode) and then call_indirect with the managed signature shape. The unmanaged calli instead uses the handed-out value (already = _pActualCode = 0x1924) directly as the call_indirect index. So 0x1924 is the managed-callable entry index the PortableEntryPoint points to — not UcoAdd's own type-1 body index. call_indirect (type 1) against the managed-shape function at table[0x1924] → signature mismatch.

In other words: on wasm, a method's _pActualCode is the managed-calling-convention entry that managed callers reach via the PortableEntryPoint indirection. A delegate* unmanaged<> call needs the method's own unmanaged (type-1) wasm function-table index, which is not what _pActualCode holds and is not exposed via GetNativeCode() / the PortableEntryPoint.

Why the obvious fixes don't work

Two VM-side candidate substitutions were prototyped (instrument + rebuild clr.runtime) and both still trap:

  1. Translate virtual IP → table index via ExecutionManager::GetWasmFunctionTableIndexFromVirtualIP — does not apply, because result is already a table index (IsVirtualIP=0).
  2. result = pMD->GetNativeCode() — sets the index to the PortableEntryPoint struct address 0x409e8a8, which is out of table range → trap.

So this is not a one-line VM tweak: the type-1 body's table index is not published as _pActualCode and is not reachable from the standard accessors.

Proposed fix direction

Publish/expose the UCO method's own unmanaged (type-1) wasm function-table index, and hand that out for the unmanaged ldftn / READYTORUN_FIXUP_MethodEntry path (so the delegate* unmanaged<> call_indirect targets a correctly-typed slot), instead of _pActualCode (the managed-callable entry). This is a wasm R2R codegen/loader change and the forward-path sibling of:

Discovered while bringing up R2R for browser/wasm CoreCLR (#129634).

Environment

Note

This issue was authored with the assistance of GitHub Copilot.

Metadata

Metadata

Assignees

No one assigned

    Labels

    arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasmuntriagedNew issue has not been triaged by the area owner

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions