From 7dbe9174431f66dc2895b59f92acab6fb2bee231 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 25 Mar 2026 16:16:15 +0100 Subject: [PATCH 01/13] [wasm] Fix interpreter crash with MethodImpl .override on PortableEntryPoints Resolve MethodImpl overrides in getCallInfo for direct calls when FEATURE_PORTABLE_ENTRYPOINTS is enabled. On non-WASM, this resolution happens in getFunctionEntryPoint via MapMethodDeclToMethodImpl, but that function is not available with portable entry points. Adding the resolution to getCallInfo ensures the interpreter compiler receives the correct target MethodDesc at compile time. This fixes a crash where a non-virtual call to a MethodImpl-overridden method (e.g. call instance MyBar::DoBar() with .override pointing to DoBarOverride) would target the wrong method, leading to uninitialized interpreter code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/jitinterface.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index f1af7efa20c416..2cb3fbdb79054a 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -5372,6 +5372,20 @@ void CEEInfo::getCallInfo( #endif // STUB_DISPATCH_PORTABLE } +#ifdef FEATURE_PORTABLE_ENTRYPOINTS + // On portable entry points, getFunctionEntryPoint is not available to resolve MethodImpl + // overrides. Do the resolution here so the interpreter compiler gets the right target + // method for non-virtual calls to MethodImpl-overridden methods. + if (directCall) + { + MethodDesc* pResolvedMD = MethodTable::MapMethodDeclToMethodImpl(pTargetMD); + if (pResolvedMD != pTargetMD) + { + pTargetMD = pResolvedMD; + } + } +#endif // FEATURE_PORTABLE_ENTRYPOINTS + pResult->hMethod = CORINFO_METHOD_HANDLE(pTargetMD); pResult->accessAllowed = CORINFO_ACCESS_ALLOWED; From 25941d01616c3e8db2254c3c2dc3ccc423e31fc4 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 8 Apr 2026 14:26:09 +0200 Subject: [PATCH 02/13] [wasm] Fix interpreter crash with .override on PortableEntryPoints Fix crash when a .override directive replaces a virtual method's vtable slot on WASM (PortableEntryPoints). The .override directive causes the overriding method's entry point to be placed in the overridden method's vtable slot. This makes SetNativeCode CAS fail for the overridden method (the slot no longer belongs to it), so SetInterpreterCode is never reached and GetInterpreterCode returns NULL, leading to a crash. Two fixes: - jitinterface.cpp: Resolve the .override at compile time in getCallInfo so the interpreter compiler targets the overriding method directly for non-virtual calls. - interpexec.cpp: In PrepareInterpreterCode, when GetInterpreterCode returns NULL and the vtable slot points to a different method due to .override, follow the redirect to prepare and cache the overriding method's interpreter code. This handles runtime paths (delegates, reflection) where the target is not known at compile time. Add delegate test coverage to self_override5.il using Delegate.CreateDelegate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/interpexec.cpp | 24 ++++++++ src/coreclr/vm/jitinterface.cpp | 5 +- .../MethodImpl/Desktop/self_override5.il | 61 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/coreclr/vm/interpexec.cpp b/src/coreclr/vm/interpexec.cpp index 3f4c2279c4834d..4d35fc4e51393c 100644 --- a/src/coreclr/vm/interpexec.cpp +++ b/src/coreclr/vm/interpexec.cpp @@ -1159,6 +1159,30 @@ static InterpByteCodeStart* PrepareInterpreterCode(MethodDesc* targetMethod, Int } } InterpByteCodeStart* targetIp = targetMethod->GetInterpreterCode(); + +#ifdef FEATURE_PORTABLE_ENTRYPOINTS + // Handle the case where .override replaces a virtual method's vtable slot. + // The targetMethod->GetInterpreterCode() above fails and we need to use the overriding + // method's interpreter code instead. + if (targetIp == NULL && !targetMethod->IsInterpreterCodePoisoned() + && targetMethod->IsVtableSlot()) + { + PCODE entryPoint = targetMethod->GetMethodEntryPointIfExists(); + if (entryPoint != (PCODE)NULL) + { + MethodDesc* pSlotMD = PortableEntryPoint::GetMethodDesc(entryPoint); + if (pSlotMD != NULL && pSlotMD != targetMethod + && pSlotMD->IsMethodImpl()) + { + targetIp = PrepareInterpreterCode(pSlotMD, pFrame, pInterpreterFrame, ip); + if (targetIp != NULL) + { + targetMethod->SetInterpreterCode(targetIp); + } + } + } + } +#endif // FEATURE_PORTABLE_ENTRYPOINTS if (targetIp == NULL) { // The prestub wasn't able to setup an interpreter code, so it will never be able to. diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index 2cb3fbdb79054a..f02cddfc21599f 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -5373,9 +5373,8 @@ void CEEInfo::getCallInfo( } #ifdef FEATURE_PORTABLE_ENTRYPOINTS - // On portable entry points, getFunctionEntryPoint is not available to resolve MethodImpl - // overrides. Do the resolution here so the interpreter compiler gets the right target - // method for non-virtual calls to MethodImpl-overridden methods. + // Resolve the MethodImpl override here so that we call the overriding method directly, + // avoiding a DoPrestub failure when trying to set a non-overridden entry point in the slot. if (directCall) { MethodDesc* pResolvedMD = MethodTable::MapMethodDeclToMethodImpl(pTargetMD); diff --git a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il index 6a4f305a4e34dc..157eca1c829e4a 100644 --- a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il +++ b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il @@ -124,6 +124,30 @@ L0: L1: + // Test delegate path: create a delegate to DoBar on a MyBar instance. + // Since DoBarOverride .overrides DoBar, invoking the delegate should + // execute MyBar::DoBarOverride and return 5. + ldc.i4.5 + newobj instance void MyBar::.ctor() + call bool CMain::TestDelegateDoBar(int32, class MyBar) + brtrue.s L2 + ldc.i4.0 + stloc.0 + +L2: + + // Test delegate path: create a delegate to DoBar on a MyFoo instance. + // Virtual dispatch through the delegate should execute + // MyFoo::DoBarOverride and return 6. + ldc.i4.6 + newobj instance void MyFoo::.ctor() + call bool CMain::TestDelegateDoBar(int32, class MyBar) + brtrue.s L3 + ldc.i4.0 + stloc.0 + +L3: + // return a status IL_0034: ldloc.0 IL_0035: brtrue.s IL_003b @@ -144,6 +168,43 @@ L1: IL_0041: ret } // end of method CMain::Main + // Helper method: creates a Func delegate targeting MyBar::DoBar on the + // given object, invokes it, and checks the result against the expected value. + .method public hidebysig static bool TestDelegateDoBar(int32 expected, class MyBar obj) cil managed + { + .maxstack 8 + .locals init (class [mscorlib]System.Func`1 del, + int32 result) + + // Func del = (Func)Delegate.CreateDelegate(typeof(Func), obj, "DoBar"); + ldtoken class [mscorlib]System.Func`1 + call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle) + ldarg.1 + ldstr "DoBar" + call class [mscorlib]System.Delegate [mscorlib]System.Delegate::CreateDelegate(class [mscorlib]System.Type, object, string) + castclass class [mscorlib]System.Func`1 + stloc.0 + + // int result = del(); + ldloc.0 + callvirt instance !0 class [mscorlib]System.Func`1::Invoke() + stloc.1 + + // if (result == expected) return true; + ldloc.1 + ldarg.0 + beq.s DELEGATE_PASS + + ldstr "FAIL: delegate to DoBar returned wrong value" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + ret + +DELEGATE_PASS: + ldc.i4.1 + ret + } // end of method CMain::TestDelegateDoBar + .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { From cf1c49abea6f5324cb80548a203139782758ab4b Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 8 Apr 2026 14:45:19 +0200 Subject: [PATCH 03/13] Update comment: use .override instead of MethodImpl Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/jitinterface.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index f02cddfc21599f..ebc2ef1af7b2a9 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -5373,7 +5373,7 @@ void CEEInfo::getCallInfo( } #ifdef FEATURE_PORTABLE_ENTRYPOINTS - // Resolve the MethodImpl override here so that we call the overriding method directly, + // Resolve the .override here so that we call the overriding method directly, // avoiding a DoPrestub failure when trying to set a non-overridden entry point in the slot. if (directCall) { From 80a782eeae2591c414209d99afe6e112c8ff7f09 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 8 Apr 2026 20:02:55 +0200 Subject: [PATCH 04/13] Replace compile-time .override resolution with ShouldCallPrestub fix Drop the MapMethodDeclToMethodImpl resolution in getCallInfo (Option D) which ran on every direct call (32,405 times per Loader suite, 1 hit). Instead, fix ShouldCallPrestub to check the method's own PortableEntryPoint when a .override directive has remapped its vtable slot. This is cheaper (IsVtableSlot flag check) and fixes the root cause for all callers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/jitinterface.cpp | 13 ------------- src/coreclr/vm/method.cpp | 9 +++++++++ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/coreclr/vm/jitinterface.cpp b/src/coreclr/vm/jitinterface.cpp index ebc2ef1af7b2a9..f1af7efa20c416 100644 --- a/src/coreclr/vm/jitinterface.cpp +++ b/src/coreclr/vm/jitinterface.cpp @@ -5372,19 +5372,6 @@ void CEEInfo::getCallInfo( #endif // STUB_DISPATCH_PORTABLE } -#ifdef FEATURE_PORTABLE_ENTRYPOINTS - // Resolve the .override here so that we call the overriding method directly, - // avoiding a DoPrestub failure when trying to set a non-overridden entry point in the slot. - if (directCall) - { - MethodDesc* pResolvedMD = MethodTable::MapMethodDeclToMethodImpl(pTargetMD); - if (pResolvedMD != pTargetMD) - { - pTargetMD = pResolvedMD; - } - } -#endif // FEATURE_PORTABLE_ENTRYPOINTS - pResult->hMethod = CORINFO_METHOD_HANDLE(pTargetMD); pResult->accessAllowed = CORINFO_ACCESS_ALLOWED; diff --git a/src/coreclr/vm/method.cpp b/src/coreclr/vm/method.cpp index 32d9a717280647..0e58a8021f6a8f 100644 --- a/src/coreclr/vm/method.cpp +++ b/src/coreclr/vm/method.cpp @@ -2485,6 +2485,15 @@ BOOL MethodDesc::ShouldCallPrestub() #ifdef FEATURE_PORTABLE_ENTRYPOINTS methodEntryPoint = GetStableEntryPoint(); + // When the .override directive remaps this method's vtable slot, the entry point + // may be a different method's PortableEntryPoint (the overriding method). In that case, check this + // method's own PortableEntryPoint instead. + if (IsVtableSlot()) + { + PCODE ownEntryPoint = GetPortableEntryPointIfExists(); + if (ownEntryPoint != (PCODE)NULL && ownEntryPoint != methodEntryPoint) + methodEntryPoint = ownEntryPoint; + } return methodEntryPoint == (PCODE)NULL || (!PortableEntryPoint::HasInterpreterData(methodEntryPoint) && !PortableEntryPoint::HasNativeEntryPoint(methodEntryPoint)); From 4cc14e63d9e7bf6ca31f638b6195150c91430f4a Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 22 Apr 2026 17:10:26 +0200 Subject: [PATCH 05/13] Add PrepareMethod + .override regression test Test RuntimeHelpers.PrepareMethod on a method whose vtable slot has been remapped by a .override directive (scenario from jkotas review comment). Verifies that PrepareMethod correctly prepares the overriding method's body, and that subsequent virtual, non-virtual, and direct calls all execute the override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Desktop/self_override_preparemethod.il | 170 ++++++++++++++++++ .../self_override_preparemethod.ilproj | 13 ++ 2 files changed, 183 insertions(+) create mode 100644 src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il create mode 100644 src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj diff --git a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il new file mode 100644 index 00000000000000..a5519ba6677746 --- /dev/null +++ b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Regression test for RuntimeHelpers.PrepareMethod with .override (MethodImpl). +// +// MyClass::B explicitly overrides MyClass::A via .override directive. +// The test calls RuntimeHelpers.PrepareMethod on A's MethodHandle, which must +// also correctly handle B's body (since B replaces A's slot). On WASM with +// PortableEntryPoints, this could crash if the override is not accounted for +// during method preparation. +// +// After PrepareMethod, we verify that virtual and non-virtual calls to A +// both execute B's body (as expected with .override), and that calling B +// directly also works. + +.assembly extern mscorlib{} +.assembly extern xunit.core {} +.assembly self_override_preparemethod{} + +.class public MyClass extends [mscorlib]System.Object +{ + .method public virtual newslot instance int32 A() + { + .maxstack 8 + ldstr "In MyClass::A" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.4 + ret + } + + .method public virtual newslot instance int32 B() + { + .override MyClass::A + .maxstack 8 + ldstr "In MyClass::B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.5 + ret + } + + .method public hidebysig specialname rtspecialname + instance void .ctor() + { + .maxstack 8 + ldarg.0 + call instance void [mscorlib]System.Object::.ctor() + ret + } +} + +.class public beforefieldinit CMain extends [mscorlib]System.Object +{ + .method public hidebysig static int32 Main() cil managed + { + .custom instance void [xunit.core]Xunit.FactAttribute::.ctor() = ( + 01 00 00 00 + ) + .entrypoint + .maxstack 4 + + .locals init ( + bool V_0, + class MyClass V_1, + int32 V_2, + valuetype [mscorlib]System.RuntimeMethodHandle V_3 + ) + + ldc.i4.1 + stloc.0 + + // Create an instance of MyClass + newobj instance void MyClass::.ctor() + stloc.1 + + // Get typeof(MyClass).GetMethod("A").MethodHandle + ldtoken MyClass + call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle) + ldstr "A" + callvirt instance class [mscorlib]System.Reflection.MethodInfo [mscorlib]System.Type::GetMethod(string) + callvirt instance valuetype [mscorlib]System.RuntimeMethodHandle [mscorlib]System.Reflection.MethodBase::get_MethodHandle() + stloc.3 + + // Call RuntimeHelpers.PrepareMethod(h) - this is the key operation under test. + // On WASM/PortableEntryPoints, this must not crash even though B overrides A. + ldloc.3 + call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::PrepareMethod(valuetype [mscorlib]System.RuntimeMethodHandle) + + // Test 1: Virtual call to A() should execute B's body (returns 5) + // because B has .override on A, replacing A's vtable slot. + ldc.i4.5 + ldloc.1 + callvirt instance int32 MyClass::A() + beq.s TEST1_PASS + + ldstr "FAIL: Virtual call to A() did not execute B's body" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.0 + br.s TEST2 + + TEST1_PASS: + ldstr "PASS: Virtual call to A() correctly executed B's body" + call void [mscorlib]System.Console::WriteLine(string) + + TEST2: + // Test 2: Non-virtual call to A() should also execute B's body (returns 5) + // because .override replaces the method body at the slot level. + ldc.i4.5 + ldloc.1 + call instance int32 MyClass::A() + beq.s TEST2_PASS + + ldstr "FAIL: Non-virtual call to A() did not execute B's body" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.0 + br.s TEST3 + + TEST2_PASS: + ldstr "PASS: Non-virtual call to A() correctly executed B's body" + call void [mscorlib]System.Console::WriteLine(string) + + TEST3: + // Test 3: Direct virtual call to B() should execute B's body (returns 5) + ldc.i4.5 + ldloc.1 + callvirt instance int32 MyClass::B() + beq.s TEST3_PASS + + ldstr "FAIL: Call to B() did not return expected value" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.0 + br.s DONE + + TEST3_PASS: + ldstr "PASS: Call to B() returned expected value" + call void [mscorlib]System.Console::WriteLine(string) + + DONE: + // Return 100 for PASS, 101 for FAIL + ldloc.0 + brtrue.s PASS + + ldc.i4.s 101 + stloc.2 + ldstr "FAIL" + call void [mscorlib]System.Console::WriteLine(string) + br.s EXIT + + PASS: + ldc.i4.s 100 + stloc.2 + ldstr "PASS" + call void [mscorlib]System.Console::WriteLine(string) + + EXIT: + ldloc.2 + ret + } // end of method CMain::Main + + .method public hidebysig specialname rtspecialname + instance void .ctor() cil managed + { + .maxstack 8 + ldarg.0 + call instance void [mscorlib]System.Object::.ctor() + ret + } +} // end of class CMain diff --git a/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj b/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj new file mode 100644 index 00000000000000..2082409ea4431f --- /dev/null +++ b/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj @@ -0,0 +1,13 @@ + + + 1 + + true + + + pdbonly + + + + + From 09127984e8d69aee1a625cda43416c9a99b4e1a6 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 22 Apr 2026 17:36:26 +0200 Subject: [PATCH 06/13] Resolve .override upfront in PrepareInterpreterCode Replace the post-DoPrestub retry with an upfront MapMethodDeclToMethodImpl call, so we compile the correct (overriding) method body on the first attempt. Cache the result on the original MethodDesc so callers that check IsInterpreterCodeInitialized don't re-resolve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/interpexec.cpp | 48 ++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/coreclr/vm/interpexec.cpp b/src/coreclr/vm/interpexec.cpp index e704d1f38133ba..b92bee87d7255d 100644 --- a/src/coreclr/vm/interpexec.cpp +++ b/src/coreclr/vm/interpexec.cpp @@ -1148,6 +1148,21 @@ static InterpByteCodeStart* PrepareInterpreterCode(MethodDesc* targetMethod, Int // small subset of frames high. pFrame->ip = ip; pInterpreterFrame->SetTopInterpMethodContextFrame(pFrame); + +#ifdef FEATURE_PORTABLE_ENTRYPOINTS + // Resolve .override before compilation: if a MethodImpl has remapped + // targetMethod's vtable slot, switch to the overriding method so we + // compile the correct body. Cache the result on the original MethodDesc + // so callers that check IsInterpreterCodeInitialized don't re-resolve. + MethodDesc* pOriginalMethod = targetMethod; + if (targetMethod->IsVtableSlot()) + { + MethodDesc* pResolved = MethodTable::MapMethodDeclToMethodImpl(targetMethod); + if (pResolved != targetMethod) + targetMethod = pResolved; + } +#endif // FEATURE_PORTABLE_ENTRYPOINTS + { GCX_PREEMP(); if (targetMethod->ShouldCallPrestub()) @@ -1160,34 +1175,21 @@ static InterpByteCodeStart* PrepareInterpreterCode(MethodDesc* targetMethod, Int } InterpByteCodeStart* targetIp = targetMethod->GetInterpreterCode(); -#ifdef FEATURE_PORTABLE_ENTRYPOINTS - // Handle the case where .override replaces a virtual method's vtable slot. - // The targetMethod->GetInterpreterCode() above fails and we need to use the overriding - // method's interpreter code instead. - if (targetIp == NULL && !targetMethod->IsInterpreterCodePoisoned() - && targetMethod->IsVtableSlot()) - { - PCODE entryPoint = targetMethod->GetMethodEntryPointIfExists(); - if (entryPoint != (PCODE)NULL) - { - MethodDesc* pSlotMD = PortableEntryPoint::GetMethodDesc(entryPoint); - if (pSlotMD != NULL && pSlotMD != targetMethod - && pSlotMD->IsMethodImpl()) - { - targetIp = PrepareInterpreterCode(pSlotMD, pFrame, pInterpreterFrame, ip); - if (targetIp != NULL) - { - targetMethod->SetInterpreterCode(targetIp); - } - } - } - } -#endif // FEATURE_PORTABLE_ENTRYPOINTS if (targetIp == NULL) { // The prestub wasn't able to setup an interpreter code, so it will never be able to. targetMethod->PoisonInterpreterCode(); +#ifdef FEATURE_PORTABLE_ENTRYPOINTS + if (pOriginalMethod != targetMethod) + pOriginalMethod->PoisonInterpreterCode(); +#endif } +#ifdef FEATURE_PORTABLE_ENTRYPOINTS + else if (pOriginalMethod != targetMethod) + { + pOriginalMethod->SetInterpreterCode(targetIp); + } +#endif return targetIp; } From 6e228e5b3bd2167c542a6c8b01e4a2d9bf9e4f54 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 27 Apr 2026 16:40:34 +0200 Subject: [PATCH 07/13] Delete self_override_preparemethod test Not testing anything interesting per review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Desktop/self_override_preparemethod.il | 170 ------------------ .../self_override_preparemethod.ilproj | 13 -- 2 files changed, 183 deletions(-) delete mode 100644 src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il delete mode 100644 src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj diff --git a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il deleted file mode 100644 index a5519ba6677746..00000000000000 --- a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_preparemethod.il +++ /dev/null @@ -1,170 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// Regression test for RuntimeHelpers.PrepareMethod with .override (MethodImpl). -// -// MyClass::B explicitly overrides MyClass::A via .override directive. -// The test calls RuntimeHelpers.PrepareMethod on A's MethodHandle, which must -// also correctly handle B's body (since B replaces A's slot). On WASM with -// PortableEntryPoints, this could crash if the override is not accounted for -// during method preparation. -// -// After PrepareMethod, we verify that virtual and non-virtual calls to A -// both execute B's body (as expected with .override), and that calling B -// directly also works. - -.assembly extern mscorlib{} -.assembly extern xunit.core {} -.assembly self_override_preparemethod{} - -.class public MyClass extends [mscorlib]System.Object -{ - .method public virtual newslot instance int32 A() - { - .maxstack 8 - ldstr "In MyClass::A" - call void [mscorlib]System.Console::WriteLine(string) - ldc.i4.4 - ret - } - - .method public virtual newslot instance int32 B() - { - .override MyClass::A - .maxstack 8 - ldstr "In MyClass::B" - call void [mscorlib]System.Console::WriteLine(string) - ldc.i4.5 - ret - } - - .method public hidebysig specialname rtspecialname - instance void .ctor() - { - .maxstack 8 - ldarg.0 - call instance void [mscorlib]System.Object::.ctor() - ret - } -} - -.class public beforefieldinit CMain extends [mscorlib]System.Object -{ - .method public hidebysig static int32 Main() cil managed - { - .custom instance void [xunit.core]Xunit.FactAttribute::.ctor() = ( - 01 00 00 00 - ) - .entrypoint - .maxstack 4 - - .locals init ( - bool V_0, - class MyClass V_1, - int32 V_2, - valuetype [mscorlib]System.RuntimeMethodHandle V_3 - ) - - ldc.i4.1 - stloc.0 - - // Create an instance of MyClass - newobj instance void MyClass::.ctor() - stloc.1 - - // Get typeof(MyClass).GetMethod("A").MethodHandle - ldtoken MyClass - call class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle) - ldstr "A" - callvirt instance class [mscorlib]System.Reflection.MethodInfo [mscorlib]System.Type::GetMethod(string) - callvirt instance valuetype [mscorlib]System.RuntimeMethodHandle [mscorlib]System.Reflection.MethodBase::get_MethodHandle() - stloc.3 - - // Call RuntimeHelpers.PrepareMethod(h) - this is the key operation under test. - // On WASM/PortableEntryPoints, this must not crash even though B overrides A. - ldloc.3 - call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::PrepareMethod(valuetype [mscorlib]System.RuntimeMethodHandle) - - // Test 1: Virtual call to A() should execute B's body (returns 5) - // because B has .override on A, replacing A's vtable slot. - ldc.i4.5 - ldloc.1 - callvirt instance int32 MyClass::A() - beq.s TEST1_PASS - - ldstr "FAIL: Virtual call to A() did not execute B's body" - call void [mscorlib]System.Console::WriteLine(string) - ldc.i4.0 - stloc.0 - br.s TEST2 - - TEST1_PASS: - ldstr "PASS: Virtual call to A() correctly executed B's body" - call void [mscorlib]System.Console::WriteLine(string) - - TEST2: - // Test 2: Non-virtual call to A() should also execute B's body (returns 5) - // because .override replaces the method body at the slot level. - ldc.i4.5 - ldloc.1 - call instance int32 MyClass::A() - beq.s TEST2_PASS - - ldstr "FAIL: Non-virtual call to A() did not execute B's body" - call void [mscorlib]System.Console::WriteLine(string) - ldc.i4.0 - stloc.0 - br.s TEST3 - - TEST2_PASS: - ldstr "PASS: Non-virtual call to A() correctly executed B's body" - call void [mscorlib]System.Console::WriteLine(string) - - TEST3: - // Test 3: Direct virtual call to B() should execute B's body (returns 5) - ldc.i4.5 - ldloc.1 - callvirt instance int32 MyClass::B() - beq.s TEST3_PASS - - ldstr "FAIL: Call to B() did not return expected value" - call void [mscorlib]System.Console::WriteLine(string) - ldc.i4.0 - stloc.0 - br.s DONE - - TEST3_PASS: - ldstr "PASS: Call to B() returned expected value" - call void [mscorlib]System.Console::WriteLine(string) - - DONE: - // Return 100 for PASS, 101 for FAIL - ldloc.0 - brtrue.s PASS - - ldc.i4.s 101 - stloc.2 - ldstr "FAIL" - call void [mscorlib]System.Console::WriteLine(string) - br.s EXIT - - PASS: - ldc.i4.s 100 - stloc.2 - ldstr "PASS" - call void [mscorlib]System.Console::WriteLine(string) - - EXIT: - ldloc.2 - ret - } // end of method CMain::Main - - .method public hidebysig specialname rtspecialname - instance void .ctor() cil managed - { - .maxstack 8 - ldarg.0 - call instance void [mscorlib]System.Object::.ctor() - ret - } -} // end of class CMain diff --git a/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj b/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj deleted file mode 100644 index 2082409ea4431f..00000000000000 --- a/src/tests/Loader/classloader/MethodImpl/self_override_preparemethod.ilproj +++ /dev/null @@ -1,13 +0,0 @@ - - - 1 - - true - - - pdbonly - - - - - From 9937500b5aec2f921e6622edb64430b9961ff9d3 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 27 Apr 2026 16:51:26 +0200 Subject: [PATCH 08/13] Add generic .override regression test Tests that .override on generic methods (B overriding A) works correctly for both reference types (string) and value types (int32), via virtual and non-virtual calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Desktop/self_override_generic.il | 130 ++++++++++++++++++ .../MethodImpl/self_override_generic.ilproj | 8 ++ 2 files changed, 138 insertions(+) create mode 100644 src/tests/Loader/classloader/MethodImpl/Desktop/self_override_generic.il create mode 100644 src/tests/Loader/classloader/MethodImpl/self_override_generic.ilproj diff --git a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_generic.il b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_generic.il new file mode 100644 index 00000000000000..b1cab5f7c9c713 --- /dev/null +++ b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override_generic.il @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Tests .override (MethodImpl) on generic methods. +// Program::B() explicitly overrides Program::A(). +// Both virtual and non-virtual calls to A() should execute B(), +// for both reference type (string) and value type (int32) instantiations. + +.assembly extern mscorlib{} +.assembly extern xunit.core {} +.assembly self_override_generic{} + +.class public Program extends [mscorlib]System.Object +{ + .method public virtual newslot instance int32 A() cil managed + { + ldstr "In A" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.1 + ret + } + + .method public virtual newslot instance int32 B() cil managed + { + .override method instance int32 Program::A<[1]>() + ldstr "In B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.2 + ret + } + + .method public hidebysig specialname rtspecialname instance void .ctor() cil managed + { + ldarg.0 + call instance void [mscorlib]System.Object::.ctor() + ret + } +} + +.class public beforefieldinit CMain extends [mscorlib]System.Object +{ + .method public hidebysig static int32 Main() cil managed + { + .custom instance void [xunit.core]Xunit.FactAttribute::.ctor() = ( + 01 00 00 00 + ) + .entrypoint + .locals init (class Program o, bool pass) + + ldc.i4.1 + stloc.1 + + newobj instance void Program::.ctor() + stloc.0 + + // Test 1: o.A() virtually - should return 2 (B called) + ldstr "Test 1: calling A() virtually..." + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.2 + ldloc.0 + callvirt instance int32 Program::A() + beq.s T1_PASS + ldstr "FAIL: A() virtual call did not execute B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.1 +T1_PASS: + + // Test 2: o.A() virtually - should return 2 (B called) + ldstr "Test 2: calling A() virtually..." + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.2 + ldloc.0 + callvirt instance int32 Program::A() + beq.s T2_PASS + ldstr "FAIL: A() virtual call did not execute B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.1 +T2_PASS: + + // Test 3: o.A() non-virtually - should return 2 (body replaced) + ldstr "Test 3: calling A() non-virtually..." + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.2 + ldloc.0 + call instance int32 Program::A() + beq.s T3_PASS + ldstr "FAIL: A() non-virtual call did not execute B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.1 +T3_PASS: + + // Test 4: o.A() non-virtually - should return 2 (body replaced) + ldstr "Test 4: calling A() non-virtually..." + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.2 + ldloc.0 + call instance int32 Program::A() + beq.s T4_PASS + ldstr "FAIL: A() non-virtual call did not execute B" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.0 + stloc.1 +T4_PASS: + + // Return result + ldloc.1 + brtrue.s PASS + + ldstr "FAIL" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.s 101 + ret + +PASS: + ldstr "PASS" + call void [mscorlib]System.Console::WriteLine(string) + ldc.i4.s 100 + ret + } + + .method public hidebysig specialname rtspecialname instance void .ctor() cil managed + { + ldarg.0 + call instance void [mscorlib]System.Object::.ctor() + ret + } +} diff --git a/src/tests/Loader/classloader/MethodImpl/self_override_generic.ilproj b/src/tests/Loader/classloader/MethodImpl/self_override_generic.ilproj new file mode 100644 index 00000000000000..b4274e96c9993e --- /dev/null +++ b/src/tests/Loader/classloader/MethodImpl/self_override_generic.ilproj @@ -0,0 +1,8 @@ + + + 1 + + + + + From 4adec2fd5be4bd88aec557b0ec6c62ec18864390 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 29 Apr 2026 16:27:11 +0200 Subject: [PATCH 09/13] Feedback Co-authored-by: Jan Kotas --- src/coreclr/vm/interpexec.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/coreclr/vm/interpexec.cpp b/src/coreclr/vm/interpexec.cpp index b92bee87d7255d..ccdbefd7311cb7 100644 --- a/src/coreclr/vm/interpexec.cpp +++ b/src/coreclr/vm/interpexec.cpp @@ -1157,9 +1157,7 @@ static InterpByteCodeStart* PrepareInterpreterCode(MethodDesc* targetMethod, Int MethodDesc* pOriginalMethod = targetMethod; if (targetMethod->IsVtableSlot()) { - MethodDesc* pResolved = MethodTable::MapMethodDeclToMethodImpl(targetMethod); - if (pResolved != targetMethod) - targetMethod = pResolved; + targetMethod = MethodTable::MapMethodDeclToMethodImpl(targetMethod); } #endif // FEATURE_PORTABLE_ENTRYPOINTS From df5c439a06ccab086e0645e6cacdee9662e4a3c1 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 24 Jun 2026 16:37:37 +0200 Subject: [PATCH 10/13] Resolve .override decl->impl in PrepareMethod, revert ShouldCallPrestub change Fix RuntimeHelpers.PrepareMethod/PrepareDelegate so that when a MethodImpl (.override) has remapped a method's vtable slot to a different method, we prepare the method that actually owns the slot's code (the impl), not the dead decl body. This mirrors getFunctionEntryPoint, which already resolves direct calls via MapMethodDeclToMethodImpl. Without it, PrepareMethod(decl) fails to JIT the body that runs on invoke - reproduced on the desktop runtime (verified on macOS/arm64). Revert the ShouldCallPrestub portable-entrypoints special case: once the interpreter (PrepareInterpreterCode) and reflection (PrepareMethodHelper) resolve decl->impl up front, DoPrestub always runs on the slot owner, for which the original ShouldCallPrestub already returns the correct state. The caller-side resolution is the established pattern; patching the callee was redundant and did not fix the reflection path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/method.cpp | 9 --------- src/coreclr/vm/reflectioninvocation.cpp | 8 ++++++++ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/coreclr/vm/method.cpp b/src/coreclr/vm/method.cpp index 67085f91b6420a..6e4772c539db5c 100644 --- a/src/coreclr/vm/method.cpp +++ b/src/coreclr/vm/method.cpp @@ -2483,15 +2483,6 @@ BOOL MethodDesc::ShouldCallPrestub() #ifdef FEATURE_PORTABLE_ENTRYPOINTS methodEntryPoint = GetStableEntryPoint(); - // When the .override directive remaps this method's vtable slot, the entry point - // may be a different method's PortableEntryPoint (the overriding method). In that case, check this - // method's own PortableEntryPoint instead. - if (IsVtableSlot()) - { - PCODE ownEntryPoint = GetPortableEntryPointIfExists(); - if (ownEntryPoint != (PCODE)NULL && ownEntryPoint != methodEntryPoint) - methodEntryPoint = ownEntryPoint; - } return methodEntryPoint == (PCODE)NULL || (!PortableEntryPoint::HasInterpreterData(methodEntryPoint) && !PortableEntryPoint::HasNativeEntryPoint(methodEntryPoint)); diff --git a/src/coreclr/vm/reflectioninvocation.cpp b/src/coreclr/vm/reflectioninvocation.cpp index 37343b4fcb9cc2..b2624e0d36e621 100644 --- a/src/coreclr/vm/reflectioninvocation.cpp +++ b/src/coreclr/vm/reflectioninvocation.cpp @@ -1282,6 +1282,14 @@ static void PrepareMethodHelper(MethodDesc * pMD) { STANDARD_VM_CONTRACT; + // If a MethodImpl (.override) has remapped this method's vtable slot to a + // different method, prepare the method that actually owns the slot's code. + // This mirrors getFunctionEntryPoint, which resolves direct calls the same + // way; without it PrepareMethod would prepare the (dead) decl body instead of + // the body that runs when the method is invoked. + if (pMD->IsVtableSlot()) + pMD = MethodTable::MapMethodDeclToMethodImpl(pMD); + pMD->EnsureActive(); if (pMD->IsWrapperStub()) From 4b7662596bbb3aab93e6195d5c1bf8b0bc7d7308 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 24 Jun 2026 18:29:57 +0200 Subject: [PATCH 11/13] Refine PrepareMethod .override comment for cross-platform accuracy Clarify that PrepareMethod must prepare the impl (the method owning the slot's code), not the decl. On portable entrypoints the no-fix path compiles the decl's dead body and fails to publish it; on other targets it is a no-op on the decl. Either way the impl body that runs on invoke is never prepared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/coreclr/vm/reflectioninvocation.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/coreclr/vm/reflectioninvocation.cpp b/src/coreclr/vm/reflectioninvocation.cpp index d5d4e139abe1d9..ef7f3f9f5f2ece 100644 --- a/src/coreclr/vm/reflectioninvocation.cpp +++ b/src/coreclr/vm/reflectioninvocation.cpp @@ -1306,10 +1306,12 @@ static void PrepareMethodHelper(MethodDesc * pMD) STANDARD_VM_CONTRACT; // If a MethodImpl (.override) has remapped this method's vtable slot to a - // different method, prepare the method that actually owns the slot's code. - // This mirrors getFunctionEntryPoint, which resolves direct calls the same - // way; without it PrepareMethod would prepare the (dead) decl body instead of - // the body that runs when the method is invoked. + // different method, prepare the method that actually owns the slot's code + // (the impl), not the decl. This mirrors getFunctionEntryPoint, which resolves + // direct calls the same way. Without this, PrepareMethod operates on the decl + // and never prepares the impl body that runs when the method is invoked - on + // portable entrypoints it even compiles the decl's dead body and then fails to + // publish it. if (pMD->IsVtableSlot()) pMD = MethodTable::MapMethodDeclToMethodImpl(pMD); From e9dc3691b04ff4a75b1e8c3d96d22394fcab4bb4 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 24 Jun 2026 18:29:57 +0200 Subject: [PATCH 12/13] Re-enable self_override5.il on wasm now that the .override crash is fixed Remove the ActiveIssue gating (dotnet/runtime#120708, PlatformDetection.IsBrowser) that disabled the test on browser-wasm while the interpreter .override crash was unfixed. This PR fixes that crash, so the test can run on wasm again. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Loader/classloader/MethodImpl/Desktop/self_override5.il | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il index 8aab1c82af1d72..157eca1c829e4a 100644 --- a/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il +++ b/src/tests/Loader/classloader/MethodImpl/Desktop/self_override5.il @@ -79,7 +79,6 @@ 01 00 00 00 ) .custom instance void [Microsoft.DotNet.XUnitExtensions]Xunit.ActiveIssueAttribute::.ctor(string, class [mscorlib]System.Type, string[]) = {string('Tests that expect TypeLoadException') type([TestLibrary]TestLibrary.Utilities) string[1] ('IsNativeAot') } - .custom instance void [Microsoft.DotNet.XUnitExtensions]Xunit.ActiveIssueAttribute::.ctor(string, class [mscorlib]System.Type, string[]) = {string('https://github.com/dotnet/runtime/issues/120708') type([TestLibrary]TestLibrary.PlatformDetection) string[1] ('IsBrowser') } .entrypoint .locals init (bool V_0, From f837dd5ebc242455b0b8b6a762808f7f77f01704 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Sun, 28 Jun 2026 19:07:54 +0200 Subject: [PATCH 13/13] Feedback Co-authored-by: Jan Kotas --- src/coreclr/vm/reflectioninvocation.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/coreclr/vm/reflectioninvocation.cpp b/src/coreclr/vm/reflectioninvocation.cpp index ef7f3f9f5f2ece..ea47eb65a156ce 100644 --- a/src/coreclr/vm/reflectioninvocation.cpp +++ b/src/coreclr/vm/reflectioninvocation.cpp @@ -1308,10 +1308,7 @@ static void PrepareMethodHelper(MethodDesc * pMD) // If a MethodImpl (.override) has remapped this method's vtable slot to a // different method, prepare the method that actually owns the slot's code // (the impl), not the decl. This mirrors getFunctionEntryPoint, which resolves - // direct calls the same way. Without this, PrepareMethod operates on the decl - // and never prepares the impl body that runs when the method is invoked - on - // portable entrypoints it even compiles the decl's dead body and then fails to - // publish it. + // direct calls the same way. if (pMD->IsVtableSlot()) pMD = MethodTable::MapMethodDeclToMethodImpl(pMD);