From 15fa01d4512805ae3dcdb60e0ef3fd3125a6357c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Apr 2026 18:06:29 +0200 Subject: [PATCH 1/5] Add diagnostic logging for peer lifecycle tracing 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> --- .../Android.Runtime/AndroidRuntime.cs | 19 ++++ src/Mono.Android/Java.Interop/TypeManager.cs | 6 ++ src/Mono.Android/Java.Lang/Object.cs | 8 +- .../Android.Views/InflatedCustomViewTests.cs | 95 +++++++++++++++++++ .../layout/inflated_custom_view.axml | 11 +++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Resources/layout/inflated_custom_view.axml diff --git a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs index 883e3c6efe1..a0657bf131e 100644 --- a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs +++ b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs @@ -685,23 +685,33 @@ public override void AddPeer (IJavaPeerable value) internal void AddPeer (IJavaPeerable value, JniObjectReference reference, IntPtr hash) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: hash=0x{hash:x} PeerRef={reference} PeerRef.Type={reference.Type} State={value.JniManagedPeerState} type={value.GetType ().FullName}")); lock (instances) { if (!instances.TryGetValue (hash, out var targets)) { targets = new IdentityHashTargets (value); instances.Add (hash, targets); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: new entry, instances.Count={instances.Count}")); return; } bool found = false; + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: existing hash with {targets.Count} entries")); for (int i = 0; i < targets.Count; ++i) { IJavaPeerable? target; var wref = targets [i]; if (ShouldReplaceMapping (wref!, reference, value, out target)) { found = true; + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: REPLACING entry[{i}] (target.State={target?.JniManagedPeerState} target.Type={target?.GetType ().FullName})")); targets [i] = IdentityHashTargets.CreateWeakReference (value); break; } if (JniEnvironment.Types.IsSameObject (value.PeerReference, target!.PeerReference)) { found = true; + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: SAME object already registered at [{i}] (target.State={target.JniManagedPeerState} target.Type={target.GetType ().FullName})")); if (Logger.LogGlobalRef) { Logger.Log (LogLevel.Info, "monodroid-gref", FormattableString.Invariant ( $"warning: not replacing previous registered handle {target.PeerReference} with handle {reference} for key_handle 0x{hash:x}")); @@ -710,6 +720,8 @@ internal void AddPeer (IJavaPeerable value, JniObjectReference reference, IntPtr } if (!found) { targets.Add (value); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"AddPeer: APPENDED as entry[{targets.Count - 1}]")); } } } @@ -879,6 +891,9 @@ public override void FinalizePeer (IJavaPeerable value) if (value == null) throw new ArgumentNullException (nameof (value)); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"FinalizePeer: PeerRef={value.PeerReference} PeerRef.Type={value.PeerReference.Type} PeerRef.IsValid={value.PeerReference.IsValid} State={value.JniManagedPeerState} IdentityHashCode=0x{value.JniIdentityHashCode:x} type={value.GetType ().FullName}")); + if (Logger.LogGlobalRef) { RuntimeNativeMethods._monodroid_gref_log ( string.Format (CultureInfo.InvariantCulture, @@ -894,8 +909,12 @@ public override void FinalizePeer (IJavaPeerable value) // handle still contains a java reference, we can't finalize the // object and should "resurrect" it. if (value.PeerReference.IsValid) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"FinalizePeer: RESURRECTING (PeerReference still valid) type={value.GetType ().FullName}")); GC.ReRegisterForFinalize (value); } else { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"FinalizePeer: DISPOSING type={value.GetType ().FullName}")); RemovePeer (value, (IntPtr) value.JniIdentityHashCode); value.SetPeerReference (new JniObjectReference ()); value.Finalized (); diff --git a/src/Mono.Android/Java.Interop/TypeManager.cs b/src/Mono.Android/Java.Interop/TypeManager.cs index cc0b22936bd..c2fb1b20fae 100644 --- a/src/Mono.Android/Java.Interop/TypeManager.cs +++ b/src/Mono.Android/Java.Interop/TypeManager.cs @@ -183,11 +183,17 @@ internal static void Activate (IntPtr jobject, ConstructorInfo cinfo, object? [] try { var newobj = RuntimeHelpers.GetUninitializedObject (cinfo.DeclaringType!); if (newobj is IJavaPeerable peer) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"Activate: SetPeerReference handle=0x{jobject:x} type={cinfo.DeclaringType?.FullName}")); peer.SetPeerReference (new JniObjectReference (jobject)); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"Activate: after SetPeerReference PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState}")); } else { throw new InvalidOperationException ($"Unsupported type: '{newobj}'"); } cinfo.Invoke (newobj, parms); + 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}")); } catch (Exception e) { var m = FormattableString.Invariant ( $"Could not activate JNI Handle 0x{jobject:x} (key_handle 0x{JNIEnv.IdentityHash (jobject):x}) of Java type '{JNIEnv.GetClassNameFromInstance (jobject)}' as managed type '{cinfo?.DeclaringType?.FullName}'."); diff --git a/src/Mono.Android/Java.Lang/Object.cs b/src/Mono.Android/Java.Lang/Object.cs index 814d4c3b277..035f4025484 100644 --- a/src/Mono.Android/Java.Lang/Object.cs +++ b/src/Mono.Android/Java.Lang/Object.cs @@ -109,12 +109,18 @@ protected override void Dispose (bool disposing) [EditorBrowsable (EditorBrowsableState.Never)] protected void SetHandle (IntPtr value, JniHandleOwnership transfer) { + var existingRef = PeerReference; + var effectiveOptions = value == IntPtr.Zero ? JniObjectReferenceOptions.None : FromJniHandleOwnership (transfer); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"SetHandle: value=0x{value:x} transfer={transfer} existingPeerRef={existingRef} existingPeerRef.Type={existingRef.Type} effectiveOptions={effectiveOptions} State={((IJavaPeerable)this).JniManagedPeerState} type={GetType ().FullName}")); var reference = new JniObjectReference (value); var options = FromJniHandleOwnership (transfer); JniEnvironment.Runtime.ValueManager.ConstructPeer ( this, ref reference, - value == IntPtr.Zero ? JniObjectReferenceOptions.None : options); + effectiveOptions); + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"SetHandle: after ConstructPeer PeerRef={PeerReference} PeerRef.Type={PeerReference.Type} State={((IJavaPeerable)this).JniManagedPeerState} IdentityHashCode=0x{JniIdentityHashCode:x}")); JNIEnv.DeleteRef (value, transfer); } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs new file mode 100644 index 00000000000..6ab63319df1 --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs @@ -0,0 +1,95 @@ +using System; +using Android.App; +using Android.Content; +using Android.Runtime; +using Android.Util; +using Android.Views; +using Java.Interop; +using NUnit.Framework; + +namespace Xamarin.Android.RuntimeTests; + +// https://github.com/dotnet/android/issues/11101 +[TestFixture] +public class InflatedCustomViewTests +{ + [Test] + public void InflatedCustomView_HasValidPeerReference () + { + var inflater = LayoutInflater.From (Application.Context)!; + var layout = inflater.Inflate (Resource.Layout.inflated_custom_view, null, false)!; + + // Find our custom view in the inflated layout + var customView = FindCustomView (layout); + + Assert.IsNotNull (customView, "Custom view should be found in inflated layout"); + + // After inflation via Java-initiated activation, the peer should have a + // properly managed global JNI reference, not a raw local ref with Invalid type. + var peerRef = customView!.PeerReference; + Assert.IsTrue (peerRef.IsValid, "PeerReference should be valid"); + Assert.AreNotEqual ( + JniObjectReferenceType.Invalid, + peerRef.Type, + "PeerReference.Type should not be Invalid — it should be a Global ref"); + + // The peer should be registered so PeekObject can find it + var peeked = Java.Lang.Object.PeekObject (customView.Handle); + Assert.IsNotNull (peeked, "PeekObject should find the registered peer"); + Assert.AreSame (customView, peeked, "PeekObject should return the same instance"); + } + + [Test] + public void InflatedCustomView_CanBeCollected () + { + WeakReference? weakRef = null; + + // Create and discard the inflated view on a separate thread + // to avoid any local variable keeping it alive + var t = new System.Threading.Thread (() => { + var inflater = LayoutInflater.From (Application.Context)!; + var layout = inflater.Inflate (Resource.Layout.inflated_custom_view, null, false)!; + var customView = FindCustomView (layout); + Assert.IsNotNull (customView, "Custom view should be found in inflated layout"); + weakRef = new WeakReference (customView); + }); + t.Start (); + t.Join (); + + // Force GC + bridge processing + GC.Collect (); + GC.WaitForPendingFinalizers (); + GC.Collect (); + GC.WaitForPendingFinalizers (); + + 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)"); + } + + static InflatedCustomView? FindCustomView (View root) + { + if (root is InflatedCustomView customView) + return customView; + + if (root is ViewGroup viewGroup) { + for (int i = 0; i < viewGroup.ChildCount; i++) { + var child = viewGroup.GetChildAt (i); + if (child is InflatedCustomView found) + return found; + } + } + + return null; + } +} + +// A simple custom view that can be inflated from XML +public sealed class InflatedCustomView : View +{ + public InflatedCustomView (Context? context) : base (context) { } + public InflatedCustomView (nint javaReference, JniHandleOwnership transfer) : base (javaReference, transfer) { } + public InflatedCustomView (Context? context, IAttributeSet? attrs) : base (context, attrs) { } + public InflatedCustomView (Context? context, IAttributeSet? attrs, int defStyleAttr) : base (context, attrs, defStyleAttr) { } + public InflatedCustomView (Context? context, IAttributeSet? attrs, int defStyleAttr, int defStyleRes) : base (context, attrs, defStyleAttr, defStyleRes) { } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Resources/layout/inflated_custom_view.axml b/tests/Mono.Android-Tests/Mono.Android-Tests/Resources/layout/inflated_custom_view.axml new file mode 100644 index 00000000000..987673103ae --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Resources/layout/inflated_custom_view.axml @@ -0,0 +1,11 @@ + + + + + + From 65c7a531b7eab129a2e1e113682c0f1d5580e35c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Apr 2026 18:15:52 +0200 Subject: [PATCH 2/5] Add RemovePeer logging for peer cleanup tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Android.Runtime/AndroidRuntime.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs index a0657bf131e..8e64612a6c1 100644 --- a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs +++ b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs @@ -821,8 +821,12 @@ public override void RemovePeer (IJavaPeerable value) internal void RemovePeer (IJavaPeerable value, IntPtr hash) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"RemovePeer: hash=0x{hash:x} type={value.GetType ().FullName}")); lock (instances) { if (!instances.TryGetValue (hash, out var targets)) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"RemovePeer: no entry for hash=0x{hash:x}")); return; } for (int i = targets.Count - 1; i >= 0; i--) { @@ -840,6 +844,8 @@ internal void RemovePeer (IJavaPeerable value, IntPtr hash) if (targets.Count == 0) { instances.Remove (hash); } + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"RemovePeer: done, instances.Count={instances.Count}")); } } From 7debed6098646d439b02172c2713fec03cd9a7ef Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Apr 2026 18:32:10 +0200 Subject: [PATCH 3/5] Fix race condition: use ConstructPeer before constructor in activation 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: https://github.com/dotnet/android/issues/11101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Java.Interop/TypeManager.cs | 21 ++++++++++++++++--- .../JavaMarshalValueManager.cs | 8 ++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Mono.Android/Java.Interop/TypeManager.cs b/src/Mono.Android/Java.Interop/TypeManager.cs index c2fb1b20fae..dc3f1256e5c 100644 --- a/src/Mono.Android/Java.Interop/TypeManager.cs +++ b/src/Mono.Android/Java.Interop/TypeManager.cs @@ -183,11 +183,26 @@ internal static void Activate (IntPtr jobject, ConstructorInfo cinfo, object? [] try { var newobj = RuntimeHelpers.GetUninitializedObject (cinfo.DeclaringType!); if (newobj is IJavaPeerable peer) { + // Set Activatable BEFORE ConstructPeer so that the + // constructor chain's ConstructPeer (via SetHandle) will + // see the existing PeerReference and return early, avoiding + // a duplicate global ref. + peer.SetJniManagedPeerState (JniManagedPeerStates.Activatable); + // Create a proper JNI global ref and register the peer + // BEFORE invoking the constructor. This eliminates a race + // window: if SetPeerReference stored a raw local ref and a + // GC triggered bridge processing before ConstructPeer ran, + // the bridge would call DeleteGlobalRef on a local ref + // (JNI error) and create an orphaned global ref that keeps + // the Java object alive forever. + // See: https://github.com/dotnet/android/issues/11101 + var reference = new JniObjectReference (jobject); Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"Activate: SetPeerReference handle=0x{jobject:x} type={cinfo.DeclaringType?.FullName}")); - peer.SetPeerReference (new JniObjectReference (jobject)); + FormattableString.Invariant ($"Activate: ConstructPeer handle=0x{jobject:x} type={cinfo.DeclaringType?.FullName}")); + JniEnvironment.Runtime.ValueManager.ConstructPeer ( + peer, ref reference, JniObjectReferenceOptions.Copy); Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"Activate: after SetPeerReference PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState}")); + FormattableString.Invariant ($"Activate: after ConstructPeer PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState}")); } else { throw new InvalidOperationException ($"Unsupported type: '{newobj}'"); } diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs index 2278e272461..865a6247efc 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs @@ -272,7 +272,13 @@ void ActivateViaReflection (JniObjectReference reference, ConstructorInfo cinfo, #pragma warning disable IL2072 var self = (IJavaPeerable) System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject (declType); #pragma warning restore IL2072 - self.SetPeerReference (reference); + // Set Activatable + ConstructPeer BEFORE the constructor to + // create a proper global ref and eliminate the race window + // where bridge processing could see a raw local ref. + // See: https://github.com/dotnet/android/issues/11101 + self.SetJniManagedPeerState (JniManagedPeerStates.Activatable); + JniEnvironment.Runtime.ValueManager.ConstructPeer ( + self, ref reference, JniObjectReferenceOptions.Copy); cinfo.Invoke (self, argumentValues); From 527cb22d985954723f5e6faec43ea1d9ff48dcc3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Apr 2026 18:42:19 +0200 Subject: [PATCH 4/5] Fix SimpleValueManager + guard diagnostic logging 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> --- .../Android.Runtime/AndroidRuntime.cs | 24 +++++++++---------- src/Mono.Android/Java.Interop/TypeManager.cs | 18 +++++++++----- src/Mono.Android/Java.Lang/Object.cs | 15 +++++++----- .../SimpleValueManager.cs | 8 ++++++- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs index 8e64612a6c1..0714fe93476 100644 --- a/src/Mono.Android/Android.Runtime/AndroidRuntime.cs +++ b/src/Mono.Android/Android.Runtime/AndroidRuntime.cs @@ -685,32 +685,32 @@ public override void AddPeer (IJavaPeerable value) internal void AddPeer (IJavaPeerable value, JniObjectReference reference, IntPtr hash) { - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: hash=0x{hash:x} PeerRef={reference} PeerRef.Type={reference.Type} State={value.JniManagedPeerState} type={value.GetType ().FullName}")); lock (instances) { if (!instances.TryGetValue (hash, out var targets)) { targets = new IdentityHashTargets (value); instances.Add (hash, targets); - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: new entry, instances.Count={instances.Count}")); return; } bool found = false; - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: existing hash with {targets.Count} entries")); for (int i = 0; i < targets.Count; ++i) { IJavaPeerable? target; var wref = targets [i]; if (ShouldReplaceMapping (wref!, reference, value, out target)) { found = true; - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: REPLACING entry[{i}] (target.State={target?.JniManagedPeerState} target.Type={target?.GetType ().FullName})")); targets [i] = IdentityHashTargets.CreateWeakReference (value); break; } if (JniEnvironment.Types.IsSameObject (value.PeerReference, target!.PeerReference)) { found = true; - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: SAME object already registered at [{i}] (target.State={target.JniManagedPeerState} target.Type={target.GetType ().FullName})")); if (Logger.LogGlobalRef) { Logger.Log (LogLevel.Info, "monodroid-gref", FormattableString.Invariant ( @@ -720,7 +720,7 @@ internal void AddPeer (IJavaPeerable value, JniObjectReference reference, IntPtr } if (!found) { targets.Add (value); - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"AddPeer: APPENDED as entry[{targets.Count - 1}]")); } } @@ -821,11 +821,11 @@ public override void RemovePeer (IJavaPeerable value) internal void RemovePeer (IJavaPeerable value, IntPtr hash) { - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"RemovePeer: hash=0x{hash:x} type={value.GetType ().FullName}")); lock (instances) { if (!instances.TryGetValue (hash, out var targets)) { - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"RemovePeer: no entry for hash=0x{hash:x}")); return; } @@ -844,7 +844,7 @@ internal void RemovePeer (IJavaPeerable value, IntPtr hash) if (targets.Count == 0) { instances.Remove (hash); } - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"RemovePeer: done, instances.Count={instances.Count}")); } } @@ -897,7 +897,7 @@ public override void FinalizePeer (IJavaPeerable value) if (value == null) throw new ArgumentNullException (nameof (value)); - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"FinalizePeer: PeerRef={value.PeerReference} PeerRef.Type={value.PeerReference.Type} PeerRef.IsValid={value.PeerReference.IsValid} State={value.JniManagedPeerState} IdentityHashCode=0x{value.JniIdentityHashCode:x} type={value.GetType ().FullName}")); if (Logger.LogGlobalRef) { @@ -915,11 +915,11 @@ public override void FinalizePeer (IJavaPeerable value) // handle still contains a java reference, we can't finalize the // object and should "resurrect" it. if (value.PeerReference.IsValid) { - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"FinalizePeer: RESURRECTING (PeerReference still valid) type={value.GetType ().FullName}")); GC.ReRegisterForFinalize (value); } else { - Logger.Log (LogLevel.Info, "monodroid-peer", + if (Logger.LogGlobalRef) Logger.Log (LogLevel.Info, "monodroid-peer", FormattableString.Invariant ($"FinalizePeer: DISPOSING type={value.GetType ().FullName}")); RemovePeer (value, (IntPtr) value.JniIdentityHashCode); value.SetPeerReference (new JniObjectReference ()); diff --git a/src/Mono.Android/Java.Interop/TypeManager.cs b/src/Mono.Android/Java.Interop/TypeManager.cs index dc3f1256e5c..7b4c63189ae 100644 --- a/src/Mono.Android/Java.Interop/TypeManager.cs +++ b/src/Mono.Android/Java.Interop/TypeManager.cs @@ -197,18 +197,24 @@ internal static void Activate (IntPtr jobject, ConstructorInfo cinfo, object? [] // the Java object alive forever. // See: https://github.com/dotnet/android/issues/11101 var reference = new JniObjectReference (jobject); - Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"Activate: ConstructPeer handle=0x{jobject:x} type={cinfo.DeclaringType?.FullName}")); + if (Logger.LogGlobalRef) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"Activate: ConstructPeer handle=0x{jobject:x} type={cinfo.DeclaringType?.FullName}")); + } JniEnvironment.Runtime.ValueManager.ConstructPeer ( peer, ref reference, JniObjectReferenceOptions.Copy); - Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"Activate: after ConstructPeer PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState}")); + if (Logger.LogGlobalRef) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"Activate: after ConstructPeer PeerRef={peer.PeerReference} PeerRef.Type={peer.PeerReference.Type} State={peer.JniManagedPeerState}")); + } } else { throw new InvalidOperationException ($"Unsupported type: '{newobj}'"); } cinfo.Invoke (newobj, parms); - 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}")); + 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}")); + } } catch (Exception e) { var m = FormattableString.Invariant ( $"Could not activate JNI Handle 0x{jobject:x} (key_handle 0x{JNIEnv.IdentityHash (jobject):x}) of Java type '{JNIEnv.GetClassNameFromInstance (jobject)}' as managed type '{cinfo?.DeclaringType?.FullName}'."); diff --git a/src/Mono.Android/Java.Lang/Object.cs b/src/Mono.Android/Java.Lang/Object.cs index 035f4025484..012aa76ec7d 100644 --- a/src/Mono.Android/Java.Lang/Object.cs +++ b/src/Mono.Android/Java.Lang/Object.cs @@ -109,18 +109,21 @@ protected override void Dispose (bool disposing) [EditorBrowsable (EditorBrowsableState.Never)] protected void SetHandle (IntPtr value, JniHandleOwnership transfer) { - var existingRef = PeerReference; var effectiveOptions = value == IntPtr.Zero ? JniObjectReferenceOptions.None : FromJniHandleOwnership (transfer); - Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"SetHandle: value=0x{value:x} transfer={transfer} existingPeerRef={existingRef} existingPeerRef.Type={existingRef.Type} effectiveOptions={effectiveOptions} State={((IJavaPeerable)this).JniManagedPeerState} type={GetType ().FullName}")); + if (Logger.LogGlobalRef) { + var existingRef = PeerReference; + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"SetHandle: value=0x{value:x} transfer={transfer} existingPeerRef={existingRef} existingPeerRef.Type={existingRef.Type} effectiveOptions={effectiveOptions} State={((IJavaPeerable)this).JniManagedPeerState} type={GetType ().FullName}")); + } var reference = new JniObjectReference (value); - var options = FromJniHandleOwnership (transfer); JniEnvironment.Runtime.ValueManager.ConstructPeer ( this, ref reference, effectiveOptions); - Logger.Log (LogLevel.Info, "monodroid-peer", - FormattableString.Invariant ($"SetHandle: after ConstructPeer PeerRef={PeerReference} PeerRef.Type={PeerReference.Type} State={((IJavaPeerable)this).JniManagedPeerState} IdentityHashCode=0x{JniIdentityHashCode:x}")); + if (Logger.LogGlobalRef) { + Logger.Log (LogLevel.Info, "monodroid-peer", + FormattableString.Invariant ($"SetHandle: after ConstructPeer PeerRef={PeerReference} PeerRef.Type={PeerReference.Type} State={((IJavaPeerable)this).JniManagedPeerState} IdentityHashCode=0x{JniIdentityHashCode:x}")); + } JNIEnv.DeleteRef (value, transfer); } diff --git a/src/Mono.Android/Microsoft.Android.Runtime/SimpleValueManager.cs b/src/Mono.Android/Microsoft.Android.Runtime/SimpleValueManager.cs index 57d8ef5f84d..6c98adade73 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/SimpleValueManager.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/SimpleValueManager.cs @@ -232,7 +232,13 @@ void ActivateViaReflection (JniObjectReference reference, ConstructorInfo cinfo, #pragma warning disable IL2072 var self = (IJavaPeerable) System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject (declType); #pragma warning restore IL2072 - self.SetPeerReference (reference); + // Set Activatable + ConstructPeer BEFORE the constructor to + // create a proper global ref and eliminate the race window + // where bridge processing could see a raw local ref. + // See: https://github.com/dotnet/android/issues/11101 + self.SetJniManagedPeerState (JniManagedPeerStates.Activatable); + JniEnvironment.Runtime.ValueManager.ConstructPeer ( + self, ref reference, JniObjectReferenceOptions.Copy); cinfo.Invoke (self, argumentValues); From 00e04ed4c9087f2d56ab0c094d9fc6746dce5de2 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Apr 2026 18:44:46 +0200 Subject: [PATCH 5/5] Add stress test for global ref leak during repeated inflation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../Android.Views/InflatedCustomViewTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs index 6ab63319df1..6ebb4a4cb7a 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Views/InflatedCustomViewTests.cs @@ -67,6 +67,40 @@ public void InflatedCustomView_CanBeCollected () "Custom view should be collected after GC — if it's still alive, there is a memory leak (https://github.com/dotnet/android/issues/11101)"); } + // Stress test: repeated inflation + GC to trigger the race condition + // between Activate.SetPeerReference and ConstructPeer. Under the bug, + // each race hit leaks a JNI global ref, so gref count grows unboundedly. + [Test] + public void InflatedCustomView_RepeatedInflation_DoesNotLeakGlobalRefs () + { + int initialGrefCount = Java.Interop.JniEnvironment.Runtime.GlobalReferenceCount; + + for (int i = 0; i < 50; i++) { + var t = new System.Threading.Thread (() => { + var inflater = LayoutInflater.From (Application.Context)!; + inflater.Inflate (Resource.Layout.inflated_custom_view, null, false); + }); + t.Start (); + t.Join (); + } + + GC.Collect (); + GC.WaitForPendingFinalizers (); + GC.Collect (); + GC.WaitForPendingFinalizers (); + + int finalGrefCount = Java.Interop.JniEnvironment.Runtime.GlobalReferenceCount; + + // Allow some tolerance — other code may allocate/release grefs. + // The key assertion is that gref count doesn't grow proportionally + // to the number of inflations (under the bug, each inflation + // leaks ~1 gref, so 50 inflations would leak ~50 grefs). + int leaked = finalGrefCount - initialGrefCount; + Assert.Less (leaked, 10, + $"Global reference count grew by {leaked} after 50 inflations — " + + $"expected near-zero growth after GC (initial={initialGrefCount}, final={finalGrefCount})"); + } + static InflatedCustomView? FindCustomView (View root) { if (root is InflatedCustomView customView)