Skip to content

Register JNI natives via blittable JniNativeMethod#1455

Open
simonrozsival wants to merge 2 commits into
mainfrom
dev/srozsival/fix-registernatives-blittable
Open

Register JNI natives via blittable JniNativeMethod#1455
simonrozsival wants to merge 2 commits into
mainfrom
dev/srozsival/fix-registernatives-blittable

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Jun 12, 2026

Copy link
Copy Markdown
Member

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:

// 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:

// 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:

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).

Implementation notes

  • UTF-8 name/signature buffers are freed in a finally (Marshal.ZeroFreeCoTaskMemUTF8); GC.KeepAlive (methods) keeps the marshaler delegates alive across the native call.
  • Marshal.GetFunctionPointerForDelegate(Delegate) is wrapped in a helper with an IL3050 suppression: 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

The default native-method registration funnel invoked the JNI RegisterNatives
function pointer typed as
`delegate* unmanaged<nint, nint, JniNativeMethodRegistration[], int, int>`,
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<JniNativeMethod>) overload, eliminating the non-blittable
`delegate* unmanaged<>` call site. This matches the trimmable type-map path,
which was already immune.

Fixes dotnet/android#11633

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival marked this pull request as ready for review June 12, 2026 15:31
Copilot AI review requested due to automatic review settings June 12, 2026 15:31
@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
No pipelines are associated with this pull request.

@simonrozsival simonrozsival changed the title [Java.Interop] Register JNI natives via blittable JniNativeMethod Register JNI natives via blittable JniNativeMethod Jun 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Java.Interop’s JNI native method registration to avoid passing a non-blittable JniNativeMethodRegistration[] across a delegate* unmanaged<> boundary, instead marshalling into a blittable JniNativeMethod array and calling the existing RegisterNatives (JniObjectReference, ReadOnlySpan<JniNativeMethod>) overload. This targets a crossgen2 composite R2R + PGO miscompilation that can corrupt method names during registration.

Changes:

  • Rework JniEnvironment.Types.RegisterNatives (JniObjectReference, JniNativeMethodRegistration[], int) to marshal Name/Signature to unmanaged UTF-8 and call the blittable span-based overload.
  • Add an IL3050-suppressed helper for Marshal.GetFunctionPointerForDelegate used by the registration path.
  • Add a fast-path return for numMethods == 0 / methods == null.
Show a summary per file
File Description
src/Java.Interop/Java.Interop/JniEnvironment.Types.cs Routes native registration through blittable JniNativeMethod + ReadOnlySpan to avoid non-blittable array marshalling across unmanaged function pointers.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 1

Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs
@simonrozsival simonrozsival added the ready-to-review This PR is ready to review/merge, thanks! label Jun 12, 2026
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<JniNativeMethod>)`
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-review This PR is ready to review/merge, thanks!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants