From 5bf02a79014459263b0272b883c48e5293b16f1f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 12 Jun 2026 13:50:34 +0200 Subject: [PATCH 1/4] [Java.Interop] Register JNI natives via blittable JniNativeMethod The default native-method registration funnel invoked the JNI RegisterNatives function pointer typed as `delegate* unmanaged`, passing a non-blittable managed array and relying on a runtime-synthesized marshalling stub to convert it to JNINativeMethod*. crossgen2 miscompiles that stub under composite ReadyToRun + PGO (MIBC): it degrades to a raw struct blit, so the native `name`/`signature` pointers end up referencing the managed string objects instead of marshalled UTF-8 data. The registered method names are corrupted, producing NoSuchMethodError at startup (e.g. MauiApplication, net.dot.jni.ManagedPeer). Marshal JniNativeMethodRegistration[] into blittable JniNativeMethod values and dispatch to the existing RegisterNatives(JniObjectReference, ReadOnlySpan) overload, eliminating the non-blittable `delegate* unmanaged<>` call site. This matches the trimmable type-map path, which was already immune. Fixes https://github.com/dotnet/android/issues/11633 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniEnvironment.Types.cs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs index d8b9f652d..8652e18cf 100644 --- a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs +++ b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; @@ -256,7 +257,7 @@ public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegi RegisterNatives (type, methods, methods == null ? 0 : methods.Length); } - public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods) + public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods) { if ((numMethods < 0) || (numMethods > (methods?.Length ?? 0))) { @@ -275,14 +276,45 @@ public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegi } #endif // DEBUG - int r = _RegisterNatives (type, methods ?? Array.Empty(), numMethods); + if (numMethods == 0 || methods == null) { + return; + } - if (r != 0) { - throw new InvalidOperationException ( - string.Format ("Could not register native methods for class '{0}'; JNIEnv::RegisterNatives() returned {1}.", GetJniTypeNameFromClass (type), r)); + // Marshal the non-blittable JniNativeMethodRegistration[] into blittable JniNativeMethod + // values and dispatch to the blittable overload, instead of invoking the JNI + // `RegisterNatives` function pointer with a non-blittable managed-array parameter. + // The runtime marshalling stub synthesized for such a `delegate* unmanaged<>` call is + // miscompiled by crossgen2 under composite ReadyToRun + PGO: the JniNativeMethod `name` + // pointers end up referencing the managed `string` objects instead of marshalled UTF-8 + // data, which corrupts the registered method names. See https://github.com/dotnet/android/issues/11633. + var natives = new JniNativeMethod [numMethods]; + var unmanagedStrings = new IntPtr [numMethods * 2]; + try { + for (int i = 0; i < numMethods; ++i) { + var m = methods [i]; + IntPtr name = Marshal.StringToCoTaskMemUTF8 (m.Name); + IntPtr sig = Marshal.StringToCoTaskMemUTF8 (m.Signature); + unmanagedStrings [i * 2] = name; + unmanagedStrings [i * 2 + 1] = sig; + natives [i] = new JniNativeMethod ((byte*) name, (byte*) sig, GetFunctionPointerForDelegate (m.Marshaler)); + } + RegisterNatives (type, new ReadOnlySpan (natives, 0, numMethods)); + // Keep the Marshaler delegates alive at least until JNI has consumed the function pointers. + GC.KeepAlive (methods); + } finally { + for (int i = 0; i < unmanagedStrings.Length; ++i) { + Marshal.ZeroFreeCoTaskMemUTF8 (unmanagedStrings [i]); + } } } + // Native method registration via JniNativeMethodRegistration[] only runs on JIT-capable + // runtimes (MonoVM/CoreCLR). Under NativeAOT, native methods are registered through the + // trimmable type map using statically-compiled function pointers, so this path is never reached. + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Not reached under NativeAOT; only JIT-capable runtimes register via JniNativeMethodRegistration[].")] + static IntPtr GetFunctionPointerForDelegate (Delegate marshaler) => + Marshal.GetFunctionPointerForDelegate (marshaler); + /// /// Registers JNI native methods using blittable structs /// with raw function pointers and UTF-8 name/signature pointers. From 2a87d46fb372ce0faa68878724a556d1dd30ec60 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 12 Jun 2026 18:28:24 +0200 Subject: [PATCH 2/4] Preserve JNI exception propagation in blittable RegisterNatives Address PR review: the generated `_RegisterNatives` wrapper checked `ExceptionOccurred()` after the native call and rethrew/cleared any pending Java exception (e.g. NoSuchMethodError). Routing registration through the blittable `RegisterNatives(JniObjectReference, ReadOnlySpan)` overload dropped that check, which could leave a pending exception in the JNIEnv and cause subsequent JNI calls to fail or hide the real error. Add the pending-exception check (and a `type.IsValid` guard) to the blittable overload so both the array-based registration path and the trimmable type-map path surface JNI registration failures correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniEnvironment.Types.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs index 8652e18cf..bcd76a417 100644 --- a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs +++ b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs @@ -322,7 +322,11 @@ static IntPtr GetFunctionPointerForDelegate (Delegate marshaler) => /// public static unsafe void RegisterNatives (JniObjectReference type, ReadOnlySpan methods) { + if (!type.IsValid) + throw new ArgumentException ("Handle must be valid.", nameof (type)); + IntPtr env = JniEnvironment.EnvironmentPointer; + int r; fixed (JniNativeMethod* methodsPtr = methods) { #if FEATURE_JNIENVIRONMENT_JI_FUNCTION_POINTERS var registerNatives = (delegate* unmanaged) @@ -331,10 +335,19 @@ public static unsafe void RegisterNatives (JniObjectReference type, ReadOnlySpan var registerNatives = (delegate* unmanaged) JniEnvironment.CurrentInfo.Invoker.env.RegisterNatives; #endif - int r = registerNatives (env, type.Handle, methodsPtr, methods.Length); - if (r != 0) { - throw new InvalidOperationException ($"Could not register native methods for class '{GetJniTypeNameFromClass (type)}'; JNIEnv::RegisterNatives() returned {r}."); - } + r = registerNatives (env, type.Handle, methodsPtr, methods.Length); + } + + // Surface (and clear) any pending Java exception raised by JNI::RegisterNatives() + // — e.g. NoSuchMethodError — before falling back to the return-code check, matching + // the behavior of the generated `_RegisterNatives` wrapper. Leaving a pending + // exception in the JNIEnv would make subsequent JNI calls fail or abort. + var thrown = JniEnvironment.GetExceptionForLastThrowable (); + if (thrown != null) + ExceptionDispatchInfo.Capture (thrown).Throw (); + + if (r != 0) { + throw new InvalidOperationException ($"Could not register native methods for class '{GetJniTypeNameFromClass (type)}'; JNIEnv::RegisterNatives() returned {r}."); } } From 3882658619c95435e66a31603ae95e6baff537f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:39:40 +0000 Subject: [PATCH 3/4] Use stackalloc for native registration buffers Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- .../Java.Interop/JniEnvironment.Types.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs index bcd76a417..e1c18848e 100644 --- a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs +++ b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs @@ -287,8 +287,14 @@ public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMet // miscompiled by crossgen2 under composite ReadyToRun + PGO: the JniNativeMethod `name` // pointers end up referencing the managed `string` objects instead of marshalled UTF-8 // data, which corrupts the registered method names. See https://github.com/dotnet/android/issues/11633. - var natives = new JniNativeMethod [numMethods]; - var unmanagedStrings = new IntPtr [numMethods * 2]; + const int MaxStackAllocatedNativeMethods = 32; + bool useStackAllocatedBuffers = numMethods <= MaxStackAllocatedNativeMethods; + Span natives = useStackAllocatedBuffers + ? stackalloc JniNativeMethod [numMethods] + : new JniNativeMethod [numMethods]; + Span unmanagedStrings = useStackAllocatedBuffers + ? stackalloc IntPtr [numMethods * 2] + : new IntPtr [numMethods * 2]; try { for (int i = 0; i < numMethods; ++i) { var m = methods [i]; @@ -298,7 +304,7 @@ public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMet unmanagedStrings [i * 2 + 1] = sig; natives [i] = new JniNativeMethod ((byte*) name, (byte*) sig, GetFunctionPointerForDelegate (m.Marshaler)); } - RegisterNatives (type, new ReadOnlySpan (natives, 0, numMethods)); + RegisterNatives (type, natives); // Keep the Marshaler delegates alive at least until JNI has consumed the function pointers. GC.KeepAlive (methods); } finally { From 464419a03ceb3e7bb1de6475176b63906ec6afec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:52:06 +0000 Subject: [PATCH 4/4] Add RequiresDynamicCode to native registration methods Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- src/Java.Interop/Java.Interop/JniEnvironment.Types.cs | 7 +++---- src/Java.Interop/Java.Interop/JniType.cs | 1 + src/Java.Interop/Java.Interop/ManagedPeer.cs | 1 + tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs index e1c18848e..f2197e46d 100644 --- a/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs +++ b/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs @@ -252,11 +252,13 @@ static string JavaClassToJniType (string value) return value.Replace ('.', '/'); } + [RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan) overload with statically-compiled function pointers for Native AOT compatibility.")] public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods) { RegisterNatives (type, methods, methods == null ? 0 : methods.Length); } + [RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan) overload with statically-compiled function pointers for Native AOT compatibility.")] public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods) { if ((numMethods < 0) || @@ -314,10 +316,7 @@ public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMet } } - // Native method registration via JniNativeMethodRegistration[] only runs on JIT-capable - // runtimes (MonoVM/CoreCLR). Under NativeAOT, native methods are registered through the - // trimmable type map using statically-compiled function pointers, so this path is never reached. - [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Not reached under NativeAOT; only JIT-capable runtimes register via JniNativeMethodRegistration[].")] + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "RequiresDynamicCode on RegisterNatives propagates the requirement; this helper is only called from that method.")] static IntPtr GetFunctionPointerForDelegate (Delegate marshaler) => Marshal.GetFunctionPointerForDelegate (marshaler); diff --git a/src/Java.Interop/Java.Interop/JniType.cs b/src/Java.Interop/Java.Interop/JniType.cs index c17488e45..204c5630a 100644 --- a/src/Java.Interop/Java.Interop/JniType.cs +++ b/src/Java.Interop/Java.Interop/JniType.cs @@ -153,6 +153,7 @@ public bool IsInstanceOfType (JniObjectReference value) JniNativeMethodRegistration[]? methods; #pragma warning restore 0414 + [RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan) overload with statically-compiled function pointers for Native AOT compatibility.")] public void RegisterNativeMethods (params JniNativeMethodRegistration[] methods) { AssertValid (); diff --git a/src/Java.Interop/Java.Interop/ManagedPeer.cs b/src/Java.Interop/Java.Interop/ManagedPeer.cs index 0a03e916c..f41a06200 100644 --- a/src/Java.Interop/Java.Interop/ManagedPeer.cs +++ b/src/Java.Interop/Java.Interop/ManagedPeer.cs @@ -23,6 +23,7 @@ namespace Java.Interop { static readonly JniPeerMembers _members = new JniPeerMembers (JniTypeName, typeof (ManagedPeer)); + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "ManagedPeer is not compatible with Native AOT; it's only used by reflection-based JniRuntime.JniValueManager implementations.")] static ManagedPeer () { _members.JniPeerType.RegisterNativeMethods ( diff --git a/tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs b/tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs index 6996442d4..b735c7d1b 100644 --- a/tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs +++ b/tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Java.Interop; @@ -42,6 +43,7 @@ public void Ctor_ThrowsIfTypeNotFound () } [Test] + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniType.RegisterNativeMethods API.")] public unsafe void Dispose_Exceptions () { var t = new JniType ("java/lang/Object"); @@ -175,6 +177,7 @@ public void RegisterWithRuntime () } [Test] + [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniType.RegisterNativeMethods API.")] public void RegisterNativeMethods () { using (var TestType_class = new JniType ("net/dot/jni/test/CallNonvirtualBase")) {