Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.

Commit c24a8e9

Browse files
simonrozsivaljonathanpeppers
authored andcommitted
Register JNI natives via blittable JniNativeMethod (#1474)
## Summary Register JNI native methods by marshalling into the **blittable** `JniNativeMethod` struct and calling the existing `RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>)` overload, instead of invoking the JNI `RegisterNatives` function pointer with a **non-blittable managed array** (`JniNativeMethodRegistration[]`). This avoids relying on the runtime to marshal an array of a non-blittable struct across a `delegate* unmanaged<>` call — a path that is currently miscompiled by crossgen2 under composite ReadyToRun + PGO and corrupts the registered method names. Refs dotnet/android#11633 ## Background: what breaks The default (non-trimmable / llvm-ir typemap) registration funnels through: ```csharp // JniEnvironment.Types public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods) => _RegisterNatives (type, methods, numMethods); // generated invoker: // delegate* unmanaged<IntPtr, jobject, JniNativeMethodRegistration[], int, int> RegisterNatives ``` `JniNativeMethodRegistration` is non-blittable (`string Name; string Signature; Delegate Marshaler`). Passing `JniNativeMethodRegistration[]` through the `delegate* unmanaged<>` requires the runtime to marshal the array element-by-element. `ManagedPeer..cctor`, `AndroidTypeManager`, `ManagedTypeManager`, and every `JniType.RegisterNativeMethods` caller funnel through this single method (`_RegisterNatives` has exactly one caller), so fixing it here fixes all of them. ## Root cause (a crossgen2 / runtime regression) dotnet/runtime **#126911** ("Move built-in array marshalling to managed", 2026-05-01) moved array-of-struct marshalling from native C++ into the managed generic `System.StubHelpers.StructureMarshaler<T> : IArrayElementMarshaler<T, StructureMarshaler<T>>`. Its element converter is an intrinsic with a **blittable-only fallback body**: ```csharp // src/coreclr/System.Private.CoreLib/src/System/StubHelpers.cs // "Non-blittable structs should have a custom IL body generated with the marshaling logic." [Intrinsic] private static void ConvertToUnmanagedCore (ref T managed, byte* unmanaged, ref CleanupWorkListElement? cleanupWorkList) => SpanHelpers.Memmove (ref *unmanaged, ref Unsafe.As<T,byte>(ref managed), (nuint)sizeof(T)); ``` For non-blittable `T`, the VM generates a real per-field marshalling body at JIT time (`StructMarshalStubs::TryGenerateStructMarshallingMethod`, `dllimport.cpp`). **crossgen2 has no equivalent**, so when PGO/MIBC marks the marshaller hot and crossgen2 precompiles the shared canonical (`__Canon`) instantiation, it emits the literal `Memmove` fallback — a raw blit of the managed object references into the native struct. Disassembly of the precompiled canonical converter from a composite-R2R + MIBC image: ```asm StructureMarshaler`1<__Canon>.ConvertToUnmanagedCore: ldr x0, [x1] ; first 8 bytes of the managed struct = the Name string REFERENCE str x0, [x2] ; stored straight into the native JNINativeMethod.name ← no string→char* marshalling ret ``` So JNI receives a managed `string` object pointer where it expects a UTF-8 `char*`, the method name is garbage, and registration fails with `NoSuchMethodError` during startup (e.g. `MauiApplication`, `net.dot.jni.ManagedPeer`). This only manifests when crossgen2 precompiles the marshaller (MIBC marks it hot). Without a profile the JIT compiles it and registration is correct — which is why the same app works without a startup MIBC. ## Why this is the right fix - It **eliminates the non-blittable array marshalling** at this call site entirely. The blittable `RegisterNatives(ReadOnlySpan<JniNativeMethod>)` path marshals names/signatures to UTF-8 ourselves (`Marshal.StringToCoTaskMemUTF8`) and passes a blittable `JniNativeMethod*` — there is no `StructureMarshaler<T>` involved, so it is correct regardless of the crossgen2 bug. - It matches the **trimmable type-map path**, which already used the blittable overload and was therefore never affected. - It is the single registration chokepoint, so it fixes `ManagedPeer`, `AndroidTypeManager`, and `ManagedTypeManager` together. We do **not** want to marshal arrays of non-blittable types across `delegate* unmanaged<>` / P/Invoke boundaries given this runtime limitation. A scan of the shipped runtime path (Java.Interop + Mono.Android) shows `RegisterNatives` was the only such site: it is the only invoker function pointer with an array parameter, the only non-blittable-array native call; all `JValue[]` call sites already pin (`fixed (JValue* …)`) and pass a blittable pointer. ## Changes - Rework `JniEnvironment.Types.RegisterNatives(JniObjectReference, JniNativeMethodRegistration[], int)` to marshal `Name`/`Signature` to unmanaged UTF-8 and a function pointer, then dispatch to the blittable `RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>)` overload (no more `_RegisterNatives` / non-blittable `delegate* unmanaged<>` call). - Preserve JNI error behavior in the blittable overload: it now observes and rethrows (clearing) any pending Java exception from `JNIEnv::RegisterNatives()` — e.g. `NoSuchMethodError` — and guards `type.IsValid`, matching the generated `_RegisterNatives` wrapper it replaces. This benefits both the array-based path and the trimmable type-map path (which previously could leave a pending exception in the JNIEnv). - Add regression tests for the `JniNativeMethodRegistration[]` path to validate correct UTF-8 marshaling and function pointer conversion, covering both stack-allocated (≤32 methods) and heap-allocated (>32 methods) code paths. ## Implementation notes - UTF-8 name/signature buffers are allocated with `Marshal.StringToCoTaskMemUTF8` and freed in a `finally` block with `Marshal.ZeroFreeCoTaskMemUTF8`, guarding against `IntPtr.Zero` to prevent crashes with uninitialized entries. `GC.KeepAlive (methods)` keeps the marshaler delegates alive across the native call. - Null `Marshaler` delegates are explicitly checked with an actionable error message that includes the array index and method signature, rather than relying on the generic `ArgumentNullException` from `Marshal.GetFunctionPointerForDelegate`. - `Marshal.GetFunctionPointerForDelegate` is called inline within the already-`RequiresDynamicCode`-annotated method. This `JniNativeMethodRegistration[]` path runs only on JIT-capable runtimes (MonoVM/CoreCLR); NativeAOT registers through the trimmable type map with statically-compiled function pointers and never reaches it. ## Verification Reproduced and fixed in an isolated `net11.0` console app (no Java.Interop/Android types): a non-blittable `struct[]` passed through a `delegate* unmanaged<>` to a real native function corrupts only under composite R2R + MIBC (when the call site is hot); the blittable equivalent is correct under all configurations (JIT, plain R2R, composite R2R, composite R2R + MIBC). ## Tracking - Underlying runtime regression introduced by dotnet/runtime#126911; durable fix belongs in crossgen2 (expand the marshalling intrinsic for non-blittable `__Canon`, or defer it to the JIT). This PR is the Java.Interop-side fix and is correct independent of the runtime change.
1 parent 7049364 commit c24a8e9

4 files changed

Lines changed: 131 additions & 9 deletions

File tree

src/Java.Interop/Java.Interop/JniEnvironment.Types.cs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
using System;
44
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
56
using System.Collections.Generic;
67
using System.Runtime.ExceptionServices;
78
using System.Runtime.InteropServices;
@@ -251,12 +252,14 @@ static string JavaClassToJniType (string value)
251252
return value.Replace ('.', '/');
252253
}
253254

255+
[RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>) overload with statically-compiled function pointers for Native AOT compatibility.")]
254256
public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods)
255257
{
256258
RegisterNatives (type, methods, methods == null ? 0 : methods.Length);
257259
}
258260

259-
public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods)
261+
[RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>) overload with statically-compiled function pointers for Native AOT compatibility.")]
262+
public static unsafe void RegisterNatives (JniObjectReference type, JniNativeMethodRegistration [] methods, int numMethods)
260263
{
261264
if ((numMethods < 0) ||
262265
(numMethods > (methods?.Length ?? 0))) {
@@ -275,11 +278,45 @@ public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegi
275278
}
276279
#endif // DEBUG
277280

278-
int r = _RegisterNatives (type, methods ?? Array.Empty<JniNativeMethodRegistration>(), numMethods);
281+
if (numMethods == 0 || methods == null) {
282+
return;
283+
}
279284

280-
if (r != 0) {
281-
throw new InvalidOperationException (
282-
string.Format ("Could not register native methods for class '{0}'; JNIEnv::RegisterNatives() returned {1}.", GetJniTypeNameFromClass (type), r));
285+
// Marshal the non-blittable JniNativeMethodRegistration[] into blittable JniNativeMethod
286+
// values and dispatch to the blittable overload, instead of invoking the JNI
287+
// `RegisterNatives` function pointer with a non-blittable managed-array parameter.
288+
// The runtime marshalling stub synthesized for such a `delegate* unmanaged<>` call is
289+
// miscompiled by crossgen2 under composite ReadyToRun + PGO: the JniNativeMethod `name`
290+
// pointers end up referencing the managed `string` objects instead of marshalled UTF-8
291+
// data, which corrupts the registered method names. See https://github.com/dotnet/android/issues/11633.
292+
const int MaxStackAllocatedNativeMethods = 32;
293+
bool useStackAllocatedBuffers = numMethods <= MaxStackAllocatedNativeMethods;
294+
Span<JniNativeMethod> natives = useStackAllocatedBuffers
295+
? stackalloc JniNativeMethod [numMethods]
296+
: new JniNativeMethod [numMethods];
297+
Span<IntPtr> unmanagedStrings = useStackAllocatedBuffers
298+
? stackalloc IntPtr [numMethods * 2]
299+
: new IntPtr [numMethods * 2];
300+
unmanagedStrings.Clear ();
301+
try {
302+
for (int i = 0; i < numMethods; ++i) {
303+
var m = methods [i];
304+
if (m.Marshaler == null)
305+
throw new ArgumentException ($"JniNativeMethodRegistration[{i}] ({m.Name}{m.Signature}) has a null Marshaler delegate.", nameof (methods));
306+
IntPtr name = Marshal.StringToCoTaskMemUTF8 (m.Name);
307+
unmanagedStrings [i * 2] = name;
308+
IntPtr sig = Marshal.StringToCoTaskMemUTF8 (m.Signature);
309+
unmanagedStrings [i * 2 + 1] = sig;
310+
natives [i] = new JniNativeMethod ((byte*) name, (byte*) sig, Marshal.GetFunctionPointerForDelegate (m.Marshaler));
311+
}
312+
RegisterNatives (type, natives);
313+
// Keep the Marshaler delegates alive at least until JNI has consumed the function pointers.
314+
GC.KeepAlive (methods);
315+
} finally {
316+
for (int i = 0; i < unmanagedStrings.Length; ++i) {
317+
if (unmanagedStrings [i] != IntPtr.Zero)
318+
Marshal.ZeroFreeCoTaskMemUTF8 (unmanagedStrings [i]);
319+
}
283320
}
284321
}
285322

@@ -290,7 +327,11 @@ public static void RegisterNatives (JniObjectReference type, JniNativeMethodRegi
290327
/// </summary>
291328
public static unsafe void RegisterNatives (JniObjectReference type, ReadOnlySpan<JniNativeMethod> methods)
292329
{
330+
if (!type.IsValid)
331+
throw new ArgumentException ("Handle must be valid.", nameof (type));
332+
293333
IntPtr env = JniEnvironment.EnvironmentPointer;
334+
int r;
294335
fixed (JniNativeMethod* methodsPtr = methods) {
295336
#if FEATURE_JNIENVIRONMENT_JI_FUNCTION_POINTERS
296337
var registerNatives = (delegate* unmanaged<IntPtr, IntPtr, JniNativeMethod*, int, int>)
@@ -299,10 +340,19 @@ public static unsafe void RegisterNatives (JniObjectReference type, ReadOnlySpan
299340
var registerNatives = (delegate* unmanaged<IntPtr, IntPtr, JniNativeMethod*, int, int>)
300341
JniEnvironment.CurrentInfo.Invoker.env.RegisterNatives;
301342
#endif
302-
int r = registerNatives (env, type.Handle, methodsPtr, methods.Length);
303-
if (r != 0) {
304-
throw new InvalidOperationException ($"Could not register native methods for class '{GetJniTypeNameFromClass (type)}'; JNIEnv::RegisterNatives() returned {r}.");
305-
}
343+
r = registerNatives (env, type.Handle, methodsPtr, methods.Length);
344+
}
345+
346+
// Surface (and clear) any pending Java exception raised by JNI::RegisterNatives()
347+
// — e.g. NoSuchMethodError — before falling back to the return-code check, matching
348+
// the behavior of the prior JniNativeMethodRegistration[] registration path. Leaving a pending
349+
// exception in the JNIEnv would make subsequent JNI calls fail or abort.
350+
var thrown = JniEnvironment.GetExceptionForLastThrowable ();
351+
if (thrown != null)
352+
ExceptionDispatchInfo.Capture (thrown).Throw ();
353+
354+
if (r != 0) {
355+
throw new InvalidOperationException ($"Could not register native methods for class '{GetJniTypeNameFromClass (type)}'; JNIEnv::RegisterNatives() returned {r}.");
306356
}
307357
}
308358

src/Java.Interop/Java.Interop/JniType.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public bool IsInstanceOfType (JniObjectReference value)
153153
JniNativeMethodRegistration[]? methods;
154154
#pragma warning restore 0414
155155

156+
[RequiresDynamicCode ("Native method registration via JniNativeMethodRegistration[] requires dynamic code generation. Use the blittable RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>) overload with statically-compiled function pointers for Native AOT compatibility.")]
156157
public void RegisterNativeMethods (params JniNativeMethodRegistration[] methods)
157158
{
158159
AssertValid ();

src/Java.Interop/Java.Interop/ManagedPeer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ namespace Java.Interop {
2323

2424
static readonly JniPeerMembers _members = new JniPeerMembers (JniTypeName, typeof (ManagedPeer));
2525

26+
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "ManagedPeer is not compatible with Native AOT; it's only used by reflection-based JniRuntime.JniValueManager implementations.")]
2627
static ManagedPeer ()
2728
{
2829
_members.JniPeerType.RegisterNativeMethods (

tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Diagnostics.CodeAnalysis;
23
using System.Runtime.InteropServices;
34

45
using Java.Interop;
@@ -42,6 +43,7 @@ public void Ctor_ThrowsIfTypeNotFound ()
4243
}
4344

4445
[Test]
46+
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniType.RegisterNativeMethods API.")]
4547
public unsafe void Dispose_Exceptions ()
4648
{
4749
var t = new JniType ("java/lang/Object");
@@ -175,6 +177,7 @@ public void RegisterWithRuntime ()
175177
}
176178

177179
[Test]
180+
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniType.RegisterNativeMethods API.")]
178181
public void RegisterNativeMethods ()
179182
{
180183
using (var TestType_class = new JniType ("net/dot/jni/test/CallNonvirtualBase")) {
@@ -214,6 +217,73 @@ public unsafe void RegisterNativeMethods_JniNativeMethod ()
214217

215218
[UnmanagedCallersOnly]
216219
static int NativeAdd (IntPtr jnienv, IntPtr self, int a, int b) => a + b;
220+
221+
[Test]
222+
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniNativeMethodRegistration[] registration path.")]
223+
public unsafe void RegisterNativeMethods_JniNativeMethodRegistration ()
224+
{
225+
using (var nativeClass = new JniType ("net/dot/jni/test/RegisterNativesTestType")) {
226+
// Test the JniNativeMethodRegistration[] registration path (the one that marshals to blittable)
227+
var methods = new JniNativeMethodRegistration [1];
228+
methods [0] = new JniNativeMethodRegistration (
229+
"add",
230+
"(II)I",
231+
new AddDelegate (ManagedAdd));
232+
233+
JniEnvironment.Types.RegisterNatives (nativeClass.PeerReference, methods, methods.Length);
234+
235+
// Call the native method from Java to verify registration worked
236+
var ctor = JniEnvironment.InstanceMethods.GetMethodID (nativeClass.PeerReference, "<init>", "()V");
237+
var obj = JniEnvironment.Object.NewObject (nativeClass.PeerReference, ctor);
238+
try {
239+
var addMethod = JniEnvironment.InstanceMethods.GetMethodID (nativeClass.PeerReference, "add", "(II)I");
240+
var args = stackalloc JniArgumentValue [2];
241+
args [0] = new JniArgumentValue (5);
242+
args [1] = new JniArgumentValue (6);
243+
int result = JniEnvironment.InstanceMethods.CallIntMethod (obj, addMethod, args);
244+
Assert.AreEqual (11, result);
245+
} finally {
246+
JniObjectReference.Dispose (ref obj);
247+
}
248+
}
249+
}
250+
251+
delegate int AddDelegate (IntPtr jnienv, IntPtr self, int a, int b);
252+
253+
static int ManagedAdd (IntPtr jnienv, IntPtr self, int a, int b) => a + b;
254+
255+
[Test]
256+
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniNativeMethodRegistration[] registration path with many methods.")]
257+
public unsafe void RegisterNativeMethods_JniNativeMethodRegistration_ManyMethods ()
258+
{
259+
using (var nativeClass = new JniType ("net/dot/jni/test/RegisterNativesTestType")) {
260+
// Test the heap allocation path (> 32 methods) to ensure the marshalling loop handles it
261+
var methods = new JniNativeMethodRegistration [40];
262+
for (int i = 0; i < methods.Length; ++i) {
263+
methods [i] = new JniNativeMethodRegistration (
264+
"add",
265+
"(II)I",
266+
new AddDelegate (ManagedAdd));
267+
}
268+
269+
// This should not crash even with > 32 methods
270+
JniEnvironment.Types.RegisterNatives (nativeClass.PeerReference, methods, methods.Length);
271+
272+
// Verify the registration worked by calling the method
273+
var ctor = JniEnvironment.InstanceMethods.GetMethodID (nativeClass.PeerReference, "<init>", "()V");
274+
var obj = JniEnvironment.Object.NewObject (nativeClass.PeerReference, ctor);
275+
try {
276+
var addMethod = JniEnvironment.InstanceMethods.GetMethodID (nativeClass.PeerReference, "add", "(II)I");
277+
var args = stackalloc JniArgumentValue [2];
278+
args [0] = new JniArgumentValue (10);
279+
args [1] = new JniArgumentValue (20);
280+
int result = JniEnvironment.InstanceMethods.CallIntMethod (obj, addMethod, args);
281+
Assert.AreEqual (30, result);
282+
} finally {
283+
JniObjectReference.Dispose (ref obj);
284+
}
285+
}
286+
}
217287
}
218288
}
219289

0 commit comments

Comments
 (0)