Skip to content

Register JNI natives via blittable JniNativeMethod#1474

Open
simonrozsival wants to merge 7 commits into
mainfrom
dev/simonrozsival/pr11477-registernatives-blittable
Open

Register JNI natives via blittable JniNativeMethod#1474
simonrozsival wants to merge 7 commits into
mainfrom
dev/simonrozsival/pr11477-registernatives-blittable

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Jun 17, 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

simonrozsival and others added 4 commits June 17, 2026 16:46
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>
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>
Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 14:52

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[] through a delegate* unmanaged<> call path, instead marshalling into blittable JniNativeMethod entries and using the existing RegisterNatives(JniObjectReference, ReadOnlySpan<JniNativeMethod>) overload. It also improves AOT analyzer signaling for APIs that rely on dynamic code and clarifies test suppressions.

Changes:

  • Reworked JniEnvironment.Types.RegisterNatives(JniObjectReference, JniNativeMethodRegistration[], int) to marshal names/signatures to unmanaged UTF-8 and dispatch via the blittable overload.
  • Added pending-Java-exception surfacing/clearing to the blittable RegisterNatives overload and added RequiresDynamicCode/IL3050 suppressions where appropriate.
  • Updated tests and ManagedPeer to suppress IL3050 where they intentionally exercise non-AOT-compatible registration APIs.
Show a summary per file
File Description
tests/Java.Interop-Tests/Java.Interop/JniTypeTest.cs Adds IL3050 suppressions for tests that intentionally call non-AOT-compatible native registration APIs.
src/Java.Interop/Java.Interop/ManagedPeer.cs Suppresses IL3050 on ManagedPeer static ctor due to non-AOT-compatible native registration usage.
src/Java.Interop/Java.Interop/JniType.cs Marks RegisterNativeMethods(JniNativeMethodRegistration[]) as RequiresDynamicCode for clearer AOT analyzer behavior.
src/Java.Interop/Java.Interop/JniEnvironment.Types.cs Implements marshalling to blittable JniNativeMethod and improves exception handling for RegisterNatives.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs
Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs Outdated
Comment thread src/Java.Interop/Java.Interop/JniEnvironment.Types.cs Outdated
…nally block

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
… helper

Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com>
@simonrozsival simonrozsival added the ready-to-review This PR is ready to review/merge, thanks! label Jun 22, 2026
@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown

Java.Interop PR Reviewer completed successfully!

@github-actions github-actions Bot 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.

🤖 ⚠️ Needs Changes

Thorough, well-documented fix. The root-cause analysis (crossgen2 emitting the blittable Memmove fallback of the StructureMarshaler<__Canon> intrinsic when it precompiles the hot, non-blittable array marshaller for a delegate* unmanaged<> call) is convincing, and rerouting through the blittable RegisterNatives (ReadOnlySpan<JniNativeMethod>) overload is the right call. I independently verified:

  • ✅ The hand-rolled marshalling is correct: StringToCoTaskMemUTF8ZeroFreeCoTaskMemUTF8 are correctly paired, unmanagedStrings.Clear () + the finally correctly free partially-allocated buffers if an allocation fails mid-loop, and GC.KeepAlive (methods) is correctly placed after the native call to keep the marshaler delegates rooted across it (long-term rooting stays with JniType.methods / ManagedPeer._members, unchanged).
  • [RequiresDynamicCode] coverage is complete — ManagedPeer, both tests, and ReflectionJniTypeManager (class-level annotation) are all covered, so no new IL3050 escapes the build.
  • GetExceptionForLastThrowable () does call ExceptionClear (), so the new "surface and clear" behavior matches the comment and is a genuine improvement for the trimmable type-map path too.
  • ✅ CI (dotnet.java-interop) is green; mergeable_state: blocked reflects review requirements, not a failing check.

Main ask: this fixes a method-name corruption bug (refs dotnet/android#11633) but adds no test that exercises the reworked JniNativeMethodRegistration[] marshalling loop directly — the existing tests either pass zero methods or hit the already-correct blittable overload. A targeted regression test would lock in the fix (details inline).

Findings: 1 ⚠️ warning (missing regression test), 1 💡 suggestion (null-Marshaler behavioral change). No correctness or safety defects found in the new code.

Generated by Java.Interop PR Reviewer for issue #1474 · 585.1 AIC · ⌖ 47.3 AIC · ⊞ 34.6K
Comment /review to run again

}

[Test]
[UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Test exercises non-AOT-compatible JniType.RegisterNativeMethods API.")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 ⚠️ Testing — This PR fixes a method-name corruption bug in the JniNativeMethodRegistration[] registration path, but nothing exercises the reworked marshalling loop directly. RegisterNativeMethods () here calls the params overload with zero methods, so it returns at the new numMethods == 0 guard before any marshalling runs; and RegisterNativeMethods_JniNativeMethod tests the already-correct blittable ReadOnlySpan<JniNativeMethod> overload, not the array path. Please add a regression test that registers a method through JniType.RegisterNativeMethods (new JniNativeMethodRegistration ("add", "(II)I", ...)) and then invokes it from Java (mirroring RegisterNativeMethods_JniNativeMethod), so a future regression in the UTF-8 name marshalling is caught. Covering the > 32-method heap branch would be a bonus.

Rule: Bug fixes need regression tests

unmanagedStrings [i * 2] = name;
IntPtr sig = Marshal.StringToCoTaskMemUTF8 (m.Signature);
unmanagedStrings [i * 2 + 1] = sig;
natives [i] = new JniNativeMethod ((byte*) name, (byte*) sig, Marshal.GetFunctionPointerForDelegate (m.Marshaler));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 💡 Error handlingMarshal.GetFunctionPointerForDelegate (m.Marshaler) throws ArgumentNullException when Marshaler is null, whereas the old _RegisterNatives path marshalled a null Delegate field to a null function pointer without throwing. The #if DEBUG block just above guards against m.Marshaler == null, so null is part of this method's existing input contract — this is a behavioral change. Either reject it explicitly with an actionable message that names the offending entry/index, or pass IntPtr.Zero when m.Marshaler is null. As written, the framework exception's parameter name (d) gives the caller no clue which registration was malformed.

Rule: Include actionable details in exceptions

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.

4 participants