diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs
index 2dd6625bdd286f..601a680f874af0 100644
--- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs
+++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs
@@ -1039,6 +1039,54 @@ static void Validate(ReadyToRunReader reader)
}
}
+ ///
+ /// Composite + runtime-async caller awaiting a NON-runtime-async virtual callee that the JIT
+ /// devirtualizes to a sealed receiver. Resolving the callee's async-variant thunk must unwrap it
+ /// to the underlying EcmaMethod.
+ ///
+ [Fact]
+ public void CompositeAsyncDevirtNonAsyncCallee()
+ {
+ // Compiled WITHOUT runtime-async so the awaited virtuals get synthesized async-variant thunks.
+ var nonAsyncCalleeLib = new CompiledAssembly
+ {
+ AssemblyName = "AsyncDevirtNonAsyncCalleeLib",
+ SourceResourceNames = ["RuntimeAsync/Dependencies/AsyncDevirtNonAsyncCalleeLib.cs"],
+ };
+ var main = new CompiledAssembly
+ {
+ AssemblyName = "CompositeAsyncDevirtNonAsyncCalleeMain",
+ SourceResourceNames = ["RuntimeAsync/CompositeAsyncDevirtNonAsyncCalleeMain.cs"],
+ Features = { RuntimeAsyncFeature },
+ References = [nonAsyncCalleeLib],
+ };
+
+ new R2RTestRunner(_output).Run(new R2RTestCase(
+ nameof(CompositeAsyncDevirtNonAsyncCallee),
+ [
+ new(nameof(CompositeAsyncDevirtNonAsyncCallee),
+ [
+ new CrossgenAssembly(nonAsyncCalleeLib),
+ new CrossgenAssembly(main),
+ ])
+ {
+ Options = [Crossgen2Option.Composite, Crossgen2Option.Optimize],
+ Validate = Validate,
+ },
+ ]));
+
+ static void Validate(ReadyToRunReader reader)
+ {
+ string diag;
+ Assert.True(R2RAssert.HasManifestRef(reader, "AsyncDevirtNonAsyncCalleeLib", out diag), diag);
+
+ Assert.True(R2RAssert.HasAsyncVariant(reader, "WriterBase.CompleteValueTaskAsync(", out diag), diag);
+ Assert.True(R2RAssert.HasAsyncVariant(reader, "WriterBase.CompleteTaskAsync(", out diag), diag);
+ Assert.True(R2RAssert.HasAsyncVariant(reader, "AwaitInheritedValueTask(", out diag), diag);
+ Assert.True(R2RAssert.HasAsyncVariant(reader, "AwaitInheritedTask(", out diag), diag);
+ }
+ }
+
///
/// Composite with 3 assemblies in A→B→C transitive chain.
/// Validates manifest refs for all three and transitive inlining.
diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/CompositeAsyncDevirtNonAsyncCalleeMain.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/CompositeAsyncDevirtNonAsyncCalleeMain.cs
new file mode 100644
index 00000000000000..bbfd8311e09d92
--- /dev/null
+++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/CompositeAsyncDevirtNonAsyncCalleeMain.cs
@@ -0,0 +1,20 @@
+// Runtime-async caller for the composite devirt regression test. Awaits non-runtime-async virtuals
+// (in AsyncDevirtNonAsyncCalleeLib) that the JIT devirtualizes to the sealed receiver, requesting the
+// callee's synthesized async-variant thunk.
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+
+public static class CompositeAsyncDevirtNonAsyncCallee
+{
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static async Task AwaitInheritedValueTask(Holder h)
+ {
+ await h.Writer.CompleteValueTaskAsync();
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ public static async Task AwaitInheritedTask(Holder h)
+ {
+ await h.Writer.CompleteTaskAsync();
+ }
+}
diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/Dependencies/AsyncDevirtNonAsyncCalleeLib.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/Dependencies/AsyncDevirtNonAsyncCalleeLib.cs
new file mode 100644
index 00000000000000..65000d7a5121a7
--- /dev/null
+++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/RuntimeAsync/Dependencies/AsyncDevirtNonAsyncCalleeLib.cs
@@ -0,0 +1,23 @@
+// Helper for the composite runtime-async devirt regression test. Compiled WITHOUT runtime-async, so
+// WriterBase's virtuals are classic methods that get an "async variant" thunk when a runtime-async
+// caller in another module awaits them. ConcreteWriter is sealed and doesn't override, and Holder
+// exposes it base-typed, so the caller late-devirtualizes to the inherited base method.
+using System.Threading.Tasks;
+
+public class WriterBase
+{
+ public virtual ValueTask CompleteValueTaskAsync() => default;
+
+ public virtual Task CompleteTaskAsync() => Task.CompletedTask;
+}
+
+public sealed class ConcreteWriter : WriterBase
+{
+}
+
+public sealed class Holder
+{
+ private readonly ConcreteWriter _writer = new ConcreteWriter();
+
+ public WriterBase Writer => _writer;
+}
diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs
index 8c4af968d66747..9bf0b49c148b8b 100644
--- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs
+++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.cs
@@ -1427,8 +1427,12 @@ ModuleToken _HandleToModuleToken(ref CORINFO_RESOLVED_TOKEN pResolvedToken, Meth
|| (pResolvedToken.tokenType == CorInfoTokenKind.CORINFO_TOKENKIND_DevirtualizedMethod)
|| methodDesc.IsPInvoke))
{
+ // Unwrap synthetic MethodDesc wrappers (e.g. async-variant thunks) to the
+ // underlying metadata method before resolving its token. For a devirtualized
+ // callee, resolveVirtualMethod guarantees a real methoddef token in that
+ // method's own EcmaModule, so token and module are sourced consistently here.
if ((CorTokenType)(unchecked((uint)pResolvedToken.token) & 0xFF000000u) == CorTokenType.mdtMethodDef &&
- methodDesc?.GetTypicalMethodDefinition() is EcmaMethod ecmaMethod)
+ methodDesc?.GetPrimaryMethodDesc().GetTypicalMethodDefinition() is EcmaMethod ecmaMethod)
{
mdToken token = (mdToken)MetadataTokens.GetToken(ecmaMethod.Handle);
diff --git a/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.cs b/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.cs
new file mode 100644
index 00000000000000..ae9971c5141a8c
--- /dev/null
+++ b/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Xunit;
+
+// A runtime-async caller awaits a devirtualized call to a NON-runtime-async virtual method that a
+// sealed receiver inherits without overriding. Resolving the callee's synthesized async-variant thunk
+// must unwrap it to the underlying method instead of indexing the thunk's small token table with the
+// callee's real methoddef token; otherwise composite ReadyToRun compilation corrupts token resolution
+// and crossgen2 aborts.
+public class Async2DevirtualizeInheritedNonAsync
+{
+ public class WriterBase
+ {
+ // Non-runtime-async virtuals with bodies: awaited from a runtime-async caller via a thunk.
+ [RuntimeAsyncMethodGeneration(false)]
+ public virtual ValueTask CompleteValueTaskAsync() => default;
+
+ [RuntimeAsyncMethodGeneration(false)]
+ public virtual Task CompleteTaskAsync() => Task.CompletedTask;
+ }
+
+ // Sealed and does NOT override: late devirtualization resolves to the inherited base methods.
+ public sealed class ConcreteWriter : WriterBase
+ {
+ }
+
+ public sealed class Holder
+ {
+ private readonly ConcreteWriter _writer = new ConcreteWriter();
+
+ // Base-typed accessor over the sealed concrete type, so the exact receiver is only known via
+ // late devirtualization.
+ public WriterBase Writer => _writer;
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static async Task AwaitInheritedValueTask(Holder h)
+ {
+ await h.Writer.CompleteValueTaskAsync();
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static async Task AwaitInheritedTask(Holder h)
+ {
+ await h.Writer.CompleteTaskAsync();
+ }
+
+ [Fact]
+ public static void TestEntryPoint()
+ {
+ var h = new Holder();
+ AwaitInheritedValueTask(h).GetAwaiter().GetResult();
+ AwaitInheritedTask(h).GetAwaiter().GetResult();
+ }
+}
diff --git a/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.csproj b/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.csproj
new file mode 100644
index 00000000000000..197767e2c4e249
--- /dev/null
+++ b/src/tests/async/devirtualize-inherited-nonasync/devirtualize-inherited-nonasync.csproj
@@ -0,0 +1,5 @@
+
+
+
+
+