Fix JNI global ref leak in TypeManager.Activate and ValueManager.ActivateViaReflection#11111
Conversation
Add 'monodroid-peer' tagged logging to trace: - TypeManager.Activate: PeerReference state before/after ctor - Object.SetHandle: ConstructPeer entry with existing PeerRef state - AndroidValueManager.AddPeer: registration, hash collisions, replacements - AndroidValueManager.FinalizePeer: resurrection vs disposal decisions Also add a device test for the #11101 inflation scenario. Filter with: adb logcat -s monodroid-peer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace SetPeerReference with Activatable + ConstructPeer in both TypeManager.Activate() and JavaMarshalValueManager.ActivateViaReflection(). The race condition: Between SetPeerReference (which stores a raw JNI local ref with Invalid type in the control block) and ConstructPeer (which promotes it to a global ref), if a GC triggers bridge processing, the bridge's take_weak_global_ref_jni calls DeleteGlobalRef on what is actually a local ref (JNI error), then take_global_ref_jni creates a global ref that gets orphaned when ConstructPeer later creates its own global ref and overwrites the control block. The orphaned global ref keeps the Java object alive forever. The fix calls ConstructPeer BEFORE the constructor, ensuring the control block always contains a proper global ref. The Activatable flag prevents the constructor chain's ConstructPeer from creating a duplicate ref. Fixes: #11101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix the same SetPeerReference race in SimpleValueManager. ActivateViaReflection() for completeness (same pattern as the TypeManager and JavaMarshalValueManager fixes). Guard all monodroid-peer diagnostic logging behind Logger.LogGlobalRef to avoid string allocation overhead in production. Enable with: debug.mono.log=gref Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests that 50 consecutive inflate+discard cycles don't cause unbounded global ref growth. Under the bug, each race hit during Activate leaks ~1 gref, so this test would see ~50 leaked grefs. With the fix, gref count returns to near-baseline after GC. This also validates the fix for issue #10989 (same root cause with Activity recreation — Activities also go through n_Activate). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes a JNI global reference leak during Java-initiated peer activation by ensuring peers are marked Activatable and ConstructPeer is performed before invoking managed constructors, preventing duplicate/orphaned global refs. Adds regression tests around LayoutInflater.Inflate() and extra diagnostic logging to help validate peer/ref lifecycle behavior.
Changes:
- Update activation paths (
TypeManager.Activate()and*ValueManager.ActivateViaReflection()) to setActivatableand callConstructPeerprior to constructor invocation. - Add targeted logging (behind
Logger.LogGlobalRef) around handle/peer registration and finalization to diagnose gref/peer behavior. - Add an inflation-based device regression test and corresponding AXML layout to validate peer reference correctness and non-leaking behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Mono.Android-Tests/Mono.Android-Tests/Resources/layout/inflated_custom_view.axml | Adds layout resource used to inflate a custom managed View from XML. |
| tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs | Adds regression tests for activation/collection/gref stability when inflating custom views. |
| src/Mono.Android/Microsoft.Android.Runtime/SimpleValueManager.cs | Switches reflection activation path to Activatable + ConstructPeer pre-ctor. |
| src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs | Same as above for JavaMarshal/CoreCLR activation path. |
| src/Mono.Android/Java.Lang/Object.cs | Adds gref diagnostics around SetHandle() and refines options passed to ConstructPeer. |
| src/Mono.Android/Java.Interop/TypeManager.cs | Updates Java->managed activation to Activatable + ConstructPeer pre-ctor and adds diagnostics. |
| src/Mono.Android/Android.Runtime/AndroidRuntime.cs | Adds gref/peer-table diagnostics for AddPeer/RemovePeer/FinalizePeer paths. |
| cinfo.Invoke (newobj, parms); | ||
| if (Logger.LogGlobalRef) { | ||
| Logger.Log (LogLevel.Info, "monodroid-peer", | ||
| FormattableString.Invariant ($"Activate: after ctor PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState} IdentityHashCode=0x{peer.JniIdentityHashCode:x}")); | ||
| } |
There was a problem hiding this comment.
peer is declared in the pattern match (if (newobj is IJavaPeerable peer)) and is out of scope after the if block. The logging after cinfo.Invoke() references peer, which will not compile. Move the post-ctor log inside the if block, or hoist a separate IJavaPeerable peer variable outside the if and assign it before invoking the constructor.
| var inflater = LayoutInflater.From (Application.Context)!; | ||
| var layout = inflater.Inflate (Resource.Layout.inflated_custom_view, null, false)!; | ||
|
|
There was a problem hiding this comment.
Avoid the null-forgiving operator (!) in tests. LayoutInflater.From(Application.Context) and Inflate(...) are non-null; remove ! and, if you need to enforce non-nullability, use explicit checks/assertions rather than !.
| var peerRef = customView!.PeerReference; | ||
| Assert.IsTrue (peerRef.IsValid, "PeerReference should be valid"); |
There was a problem hiding this comment.
Avoid using the null-forgiving operator (customView!). Since FindCustomView() can return null, replace customView! with an explicit null check (e.g., fail the test if null) and then use a non-null local variable for subsequent assertions.
| Assert.IsNotNull (weakRef, "WeakReference should have been created"); | ||
| Assert.IsFalse (weakRef!.IsAlive, | ||
| "Custom view should be collected after GC — if it's still alive, there is a memory leak (https://github.com/dotnet/android/issues/11101)"); |
There was a problem hiding this comment.
Avoid the null-forgiving operator on weakRef (weakRef!). Prefer an explicit null-check (e.g., if (weakRef == null) Assert.Fail(...)) and then use a non-null local variable for the IsAlive assertion.
Summary
Fix a JNI global reference leak in
TypeManager.Activate()andValueManager.ActivateViaReflection()that causes memory leaks when Java-initiated peer activation (e.g.,LayoutInflater.Inflatewith custom .NET views, Activity recreation) triggers GC during the constructor chain.Fixes: #11101
Related: #10989
Root Cause
The regression was introduced in the "Java.Interop Unification" (#9640, commit
5c23bcda8) whenJava.Lang.Objectwas rewritten to extendJava.Interop.JavaObject.Net9 (safe):
Activate()seto.handle = jobjectdirectly. TheObject(IntPtr, JniHandleOwnership)constructor had a guard:Net10 (leaks):
Activate()callsSetPeerReference(new JniObjectReference(jobject)), which allocates aJniObjectReferenceControlBlockwith a raw local ref (handle_type = Invalid). Thehandle_typeguard was removed during the unification. When the constructor chain'sConstructPeerruns:PeerReference(which may have been modified by bridge processing during a GC)NewGlobalRef()— creating a second global refThe orphaned global ref is never deleted, keeping the Java object alive forever.
Fix
Replace
SetPeerReferencewithActivatable+ConstructPeerbefore the constructor invocation in all three activation paths:TypeManager.Activate()— ACW activation path (Mono VM viaAndroidValueManager.ActivatePeer)JavaMarshalValueManager.ActivateViaReflection()— CoreCLR path viaManagedPeer.ConstructSimpleValueManager.ActivateViaReflection()— test/desktop pathThis ensures:
Invalid-type handle)Activatableflag prevents the constructor chain'sConstructPeerfrom creating a duplicate (equivalent of net9'shandle_type != 0guard)Testing
Added device tests in
InflatedCustomViewTests:HasValidPeerReference— verifies activation producesGlobalref type +PeekObjectregistrationCanBeCollected— verifies inflated custom views are GC'd after discardingRepeatedInflation_DoesNotLeakGlobalRefs— stress test: 50 inflate cycles, asserts gref count stays stableAlso added diagnostic logging (guarded by
Logger.LogGlobalRef) atActivate,SetHandle,AddPeer,RemovePeer, andFinalizePeer. Enable withdebug.mono.log=gref, filter withadb logcat -s monodroid-peer.