[wasm] R2R: open-instance delegate over a virtual/interface method faults in the interpreter's INTOP_CALLDELEGATE
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
Under ReadyToRun on wasm, invoking an **open-instance delegate whose target is a virtual or interface method** faults in the interpreter: `RuntimeError: memory access out of bounds` (release) / `NullReferenceException` (checked). It passes interpreted and passes with only the corelib R2R'd; it fails when the calling method is itself R2R-compiled (so the R2R method calls the still-interpreted delegate `Invoke` via the R2R→interpreter thunk `ExecuteInterpretedMethodWithArgs_PortableEntryPoint`).
**Root cause (source-pinned):** The fault is in the interpreter's `INTOP_CALLDELEGATE` open-virtual branch (`src/coreclr/vm/interpexec.cpp:3396-3405`). Only open-instance delegates over a virtual/interface method reach this branch; it reads the receiver:
```cpp
OBJECTREF *pThisArg = LOCAL_VAR_ADDR(callArgsOffset + INTERP_STACK_SLOT_SIZE, OBJECTREF); // 3399
NULL_CHECK(*pThisArg); // 3400
... targetMethod->GetMethodDescOfVirtualizedCode(pThisArg, targetMethod->GetMethodTable()); // 3403
```
The receiver at `callArgsOffset + INTERP_STACK_SLOT_SIZE` is **mis-located** on the R2R→interpreter entry path: it holds a garbage value (observed `0x614F646C` in release), so `GetMethodDescOfVirtualizedCode` dereferences a bad MethodTable → OOB (release), or the mislocated slot is null → NRE (checked). A valid allocated receiver always has a loaded MethodTable+EEClass, so the garbage value confirms mis-location (not a type-loading/activation gap).
The suspect is the delegate arg-shuffle sizing that "removes the delegate object from the argument list": `ShiftDelegateCallArgs(stack, callArgsOffset, sizeOfArgsUpto16ByteAlignment /*ip[4]*/, targetArgsSize /*ip[5]*/)` (3422/3428/3455), computed by the interpreter compiler's `INTOP_CALLDELEGATE` lowering — vs the actual argument layout when the delegate `Invoke` is entered via the PortableEntryPoint thunk's `memcpy(sp, args, argsSize)`.
**Proof (variant matrix → source branches, 1:1):**
| Variant | Delegate / dispatch | Interp branch | Result |
|---|---|---|---|
| direct interface call, no delegate | — | no `INTOP_CALLDELEGATE` | pass |
| closed delegate | closed | `3461` GetTarget | pass |
| open delegate, non-virtual target | open, non-virtual | `3408` `NonVirtualEntry2MethodDesc` | pass |
| open delegate, virtual/interface target | open, virtual | `3396-3405` receiver deref | **fault** |
Multi-register struct return, boxing, and the shuffle thunk itself were ruled out by the variant matrix.
### Reproduction Steps
Crossgen a test to R2R that invokes an open-instance delegate over an interface method, then run under R2R on wasm:
```csharp
interface IGetInt { int GetInt(); }
struct MyStruct : IGetInt { public int a; public int GetInt() => a; }
delegate int D(IGetInt arg);
static int Main()
{
var s = new MyStruct { a = 42 };
var mi = typeof(IGetInt).GetMethod("GetInt");
var func = (D)mi.CreateDelegate(typeof(D)); // open-instance delegate
return func(s) == 42 ? 100 : 1;
}
```
JIT/Regression `Runtime_79354` reproduces it when crossgen'd to R2R.
### Expected behavior
Open-instance delegate invoke over a virtual/interface method returns correctly under R2R (exit 100), matching the interpreter.
### Actual behavior
`RuntimeError: memory access out of bounds` (release) / `NullReferenceException` (checked) inside the interpreter's `INTOP_CALLDELEGATE` open-virtual dispatch, from a mis-located receiver.
### Regression?
No — new R2R-on-wasm bring-up path.
### Known Workarounds
Run the test method without ReadyToRun (`DOTNET_ReadyToRun=0`), or use a closed delegate.
### Configuration
CoreCLR, `browser-wasm` / `wasi-wasm`, `FEATURE_PORTABLE_ENTRYPOINTS` + interpreter, ReadyToRun enabled (calling method R2R-compiled).
### Other information
Fix direction (for the interpreter/bridge owner): reconcile the `INTOP_CALLDELEGATE` open-virtual arg-shuffle sizing (ip[4]/ip[5]) and receiver slot (`callArgsOffset + INTERP_STACK_SLOT_SIZE`) with the argument layout produced by the R2R→interpreter thunk marshalling, so the receiver lands where line 3399 reads it. Exact faulting field (receiver offset vs shuffle size) pending a checked-crossgen runtime read. Related: #130634 (R2R↔interpreter bridge trap family).
> [!NOTE]
> This issue was authored with the assistance of GitHub Copilot.
Contributor guide
Assessment
This issue has not been assessed yet.