You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)"/>):
usingSystem;usingSystem.Runtime.InteropServices;publicstaticunsafeclassRepro{[UnmanagedCallersOnly]publicstaticintUcoAdd(inta,intb)=>a+b;publicstaticintManagedAdd(inta,intb)=>a+b;publicstaticvoidRun(){Console.WriteLine("[1] managed calli delegate*<int,int,int> (control)");delegate*<int,int,int>managed=&ManagedAdd;Console.WriteLine(" ok managed calli -> "+managed(2,3));// worksConsole.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}}publicclassTest{publicstaticintMain(string[]args){Console.WriteLine("Hello World!");Repro.Run();return0;}}
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.
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 = _pActualCode — a small value, i.e. an __indirect_function_table index (not a virtual IP: IsVirtualIP=0).
GetNativeCode() == GetPortableEntryPointIfExists() == 0x409e8a8 = the PortableEntryPointstruct 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 — notUcoAdd'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:
Translate virtual IP → table index via ExecutionManager::GetWasmFunctionTableIndexFromVirtualIP — does not apply, because result is already a table index (IsVirtualIP=0).
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:
Summary
On wasm (CoreCLR interpreter,
FEATURE_PORTABLE_ENTRYPOINTS) with R2R-compiled assemblies, calling an[UnmanagedCallersOnly]method through adelegate* unmanaged<>function pointer traps: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 /
ldftnpath is still broken.A managed
delegate*<>call to an ordinary method works fine, which isolates the bug to theUnmanagedCallersOnlyfunction-pointer path.Repro
Minimal repro in
src/mono/sample/wasm/console-node(the sample assembly is R2R-compiled via<WasmReadyToRunAssembly Include="$(AssemblyName)"/>):Run:
Output (process aborts at
[2]; the wasm trap is not a catchable managed exception, so the last printed line identifies the failing call):Note
Node/V8 swallows the trap text and exits 1. Running the same binary under Firefox (
test-browser) prints the exactRuntimeError: indirect call signature mismatch. This matches the startup crashes observed in CI on the browser lanes forSystem.Runtime.Tests,System.Threading.Tests, andSystem.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):call_indirect 1, where type 1 =(i32,i32) -> i32— the correct unmanaged signature.FUNCTIONsection showsUcoAddis emitted as a single function of type 1 (correct). No interpreter↔R2R thunk is emitted for it (the!IsUnmanagedCallersOnlyguard inCorInfoImpl.ReadyToRun.cs).&UcoAddis loaded from an R2RREADYTORUN_FIXUP_MethodEntrycell.VM-side resolution (
jitinterface.cpp,READYTORUN_FIXUP_MethodEntry):For a UCO method this takes the
EnsureCodeForUnmanagedCallersOnly()branch inMethodDesc::TryGetMultiCallableAddrOfCodeand returnsPortableEntryPoint::GetActualCode(...)=_pActualCode. PR #129766's logic is present inGetUnmanagedCallersOnlyThunkand returns the R2R native entry for R2R methods.Instrumenting that resolution for
UcoAdd(browser, Debug, R2R) yields:Interpretation:
result = 0x1924=_pActualCode— a small value, i.e. an__indirect_function_tableindex (not a virtual IP:IsVirtualIP=0).GetNativeCode() == GetPortableEntryPointIfExists() == 0x409e8a8= thePortableEntryPointstruct address (a ~67 MB linear-memory address).The mechanism, confirmed from the disassembly: framework managed calls dereference the PortableEntryPoint (
i32.load [PE] → _pActualCode) and thencall_indirectwith the managed signature shape. The unmanaged calli instead uses the handed-out value (already= _pActualCode = 0x1924) directly as thecall_indirectindex. So0x1924is the managed-callable entry index the PortableEntryPoint points to — notUcoAdd's own type-1 body index.call_indirect (type 1)against the managed-shape function attable[0x1924]→ signature mismatch.In other words: on wasm, a method's
_pActualCodeis the managed-calling-convention entry that managed callers reach via the PortableEntryPoint indirection. Adelegate* unmanaged<>call needs the method's own unmanaged (type-1) wasm function-table index, which is not what_pActualCodeholds and is not exposed viaGetNativeCode()/ the PortableEntryPoint.Why the obvious fixes don't work
Two VM-side candidate substitutions were prototyped (instrument + rebuild
clr.runtime) and both still trap:ExecutionManager::GetWasmFunctionTableIndexFromVirtualIP— does not apply, becauseresultis already a table index (IsVirtualIP=0).result = pMD->GetNativeCode()— sets the index to the PortableEntryPoint struct address0x409e8a8, 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
_pActualCodeand 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_MethodEntrypath (so thedelegate* unmanaged<>call_indirecttargets 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:null functionDiscovered while bringing up R2R for browser/wasm CoreCLR (#129634).
Environment
dotnet/runtimePR [browser][coreCLR] browserhost to load R2R #129634 branch, commit93ef88f0137fec51588fa891bd0e13b0bf1f70f0browser-wasm, CoreCLR interpreter + R2R,FEATURE_PORTABLE_ENTRYPOINTSNote
This issue was authored with the assistance of GitHub Copilot.