From c3dcf18ed57dabcfed65a63a102d77f5ca98a019 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 06:10:57 +0200 Subject: [PATCH 1/7] [Tests] Define managed object proxy contract Cover plain managed object round trips through Java arrays and collections, including identity, nulls, nested containers, Java-visible object semantics, and GC lifetime across runtime configurations.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 322 ++++++++++++++++++ .../Mono.Android.NET-Tests.csproj | 1 + 2 files changed, 323 insertions(+) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs new file mode 100644 index 00000000000..ef600fd4c81 --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -0,0 +1,322 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +using Android.Runtime; +using Java.Interop; + +using NUnit.Framework; + +namespace Java.InteropTests +{ + [TestFixture] + [Category ("ManagedObjectProxy")] + public class ManagedObjectProxyTests + { + const int MaxGcAttempts = 20; + + public ManagedObjectProxyTests () + { + } + + [Test] + public void JavaObjectArray_RoundTripPreservesManagedReferences () + { + var value = new ManagedValue (42); + var equalValue = new ManagedValue (42); + using var values = new JavaObjectArray (4); + + values [0] = value; + values [1] = value; + values [2] = equalValue; + values [3] = null; + + Assert.AreSame (value, values [0], "The original managed instance should be returned."); + Assert.AreSame (value, values [1], "Duplicate array entries should return the same managed instance."); + Assert.AreSame (equalValue, values [2], "A distinct but managed-equal instance should retain its identity."); + Assert.AreNotSame (values [0], values [2], "Managed equality must not collapse distinct round-trip values."); + Assert.IsNull (values [3], "A null array entry should round-trip as null."); + Assert.AreEqual (typeof (ManagedValue), values [0].GetType ()); + Assert.AreEqual (42, ((ManagedValue) values [0]).Value); + } + + [Test] + public void JavaObjectArray_RepeatedLookupUsesSameJavaAndManagedIdentity () + { + var value = new ManagedValue (42); + using var values = new JavaObjectArray (1); + values [0] = value; + + var firstReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); + var secondReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); + try { + Assert.IsTrue (JniEnvironment.Types.IsSameObject (firstReference, secondReference), + "Repeated Java array lookups should refer to the same Java proxy."); + + var first = JniEnvironment.Runtime.ValueManager.GetValue ( + ref firstReference, JniObjectReferenceOptions.CopyAndDispose); + var second = JniEnvironment.Runtime.ValueManager.GetValue ( + ref secondReference, JniObjectReferenceOptions.CopyAndDispose); + + Assert.AreSame (value, first); + Assert.AreSame (value, second); + } finally { + JniObjectReference.Dispose (ref secondReference); + JniObjectReference.Dispose (ref firstReference); + } + } + + [Test] + public void JavaLangObjectView_RoundTripUsesManagedProxyAssociation () + { + var value = new ManagedValue (42); + var reference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), value); + try { + var viewReference = reference.NewLocalRef (); + using var view = new Java.Lang.Object ( + viewReference.Handle, + JniHandleOwnership.TransferLocalRef | JniHandleOwnership.DoNotRegister); + viewReference = default; + + Assert.IsTrue (JniEnvironment.Types.IsSameObject (reference, view.PeerReference)); + + var roundTripReference = view.PeerReference.NewLocalRef (); + var roundTrip = JniEnvironment.Runtime.ValueManager.GetValue ( + ref roundTripReference, JniObjectReferenceOptions.CopyAndDispose); + + Assert.AreSame (value, roundTrip); + } finally { + JniObjectReference.Dispose (ref reference); + } + } + + [Test] + public void JavaList_RoundTripPreservesDuplicateManagedReferencesAndNull () + { + var value = new ManagedValue (42); + var equalValue = new ManagedValue (42); + using var values = new JavaList (); + + values.Add (value); + values.Add (value); + values.Add (equalValue); + values.Add (null); + + Assert.AreSame (value, values [0]); + Assert.AreSame (value, values [1]); + Assert.AreSame (equalValue, values [2]); + Assert.AreNotSame (values [0], values [2]); + Assert.IsNull (values [3]); + + Assert.AreEqual (0, values.IndexOf (value), + "Java collections should match repeated wrappers for the same managed reference."); + Assert.AreEqual (2, values.IndexOf (equalValue), + "Java collection equality should not collapse distinct managed-equal references."); + Assert.AreEqual (3, values.IndexOf (null)); + } + + [Test] + public void JavaDictionary_RoundTripPreservesKeysDuplicateValuesAndNull () + { + var firstKey = new ManagedValue (1); + var secondKey = new ManagedValue (2); + var value = new ManagedValue (42); + using var values = new JavaDictionary (); + + values.Add (firstKey, value); + values.Add (secondKey, value); + values.Add (null, null); + + Assert.AreEqual (3, values.Count); + Assert.AreSame (value, values [firstKey]); + Assert.AreSame (value, values [secondKey]); + Assert.IsNull (values [null]); + } + + [Test] + public void JavaDictionary_DistinctManagedEqualKeysRemainDistinct () + { + var firstKey = new ManagedValue (42); + var equalKey = new ManagedValue (42); + var firstValue = new ManagedValue (1); + var secondValue = new ManagedValue (2); + using var values = new JavaDictionary (); + + values.Add (firstKey, firstValue); + values.Add (equalKey, secondValue); + + Assert.AreEqual (2, values.Count, + "Java collection wrappers compare the identity of the wrapped managed references."); + Assert.AreSame (firstValue, values [firstKey]); + Assert.AreSame (secondValue, values [equalKey]); + } + + [Test] + public void NestedJavaCollections_RoundTripPreservesManagedReference () + { + var value = new ManagedValue (42); + using var inner = new JavaList (); + using var outer = new JavaList (); + inner.Add (value); + outer.Add (inner); + + var roundTripInner = outer [0]; + + Assert.AreSame (inner, roundTripInner); + Assert.AreSame (value, ((JavaList) roundTripInner) [0]); + } + + // Managed round-trip identity is common to both typemap implementations, but the Java-visible + // object methods intentionally differ. See https://github.com/dotnet/android/issues/11703. + [Test] + public void ProxyObjectMethodsFollowConfiguredJavaSemantics () + { + var value = new ManagedValue (42); + var equalValue = new ManagedValue (42); + var reference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), value); + var equalReference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), equalValue); + + try { + IntPtr proxyClass = JNIEnv.GetObjectClass (reference.Handle); + try { + IntPtr equals = JNIEnv.GetMethodID (proxyClass, "equals", "(Ljava/lang/Object;)Z"); + IntPtr hashCode = JNIEnv.GetMethodID (proxyClass, "hashCode", "()I"); + IntPtr toString = JNIEnv.GetMethodID (proxyClass, "toString", "()Ljava/lang/String;"); + + Assert.IsFalse (JNIEnv.IsSameObject (reference.Handle, equalReference.Handle), + "Distinct managed instances should use distinct Java proxies."); + Assert.IsTrue (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (reference.Handle))); + + int actualHashCode = JNIEnv.CallIntMethod (reference.Handle, hashCode); + string actualString = JNIEnv.GetString ( + JNIEnv.CallObjectMethod (reference.Handle, toString), + JniHandleOwnership.TransferLocalRef); + + if (Microsoft.Android.Runtime.RuntimeFeature.TrimmableTypeMap) { + Assert.IsFalse (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (equalReference.Handle)), + "Trimmable proxies intentionally use Java reference identity instead of managed Equals."); + Assert.AreEqual (GetJavaIdentityHashCode (reference), actualHashCode, + "Trimmable proxies intentionally use Java identity hash codes."); + Assert.IsTrue ( + actualString.StartsWith ("net.dot.jni.internal.TrimmableJavaProxyObject@", StringComparison.Ordinal), + actualString); + } else { + Assert.IsTrue (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (equalReference.Handle)), + "The llvm-ir proxy forwards Java equals to the managed override."); + Assert.AreEqual (value.GetHashCode (), actualHashCode); + Assert.AreEqual (value.ToString (), actualString); + } + } finally { + JNIEnv.DeleteLocalRef (proxyClass); + } + } finally { + JniObjectReference.Dispose (ref equalReference); + JniObjectReference.Dispose (ref reference); + } + } + + [Test] + public void JavaObjectArrayRetainsManagedValueUntilReleased () + { + WeakReference weakValue; + var values = CreateJavaRootedValue (out weakValue); + + try { + ForceGc (3); + AssertJavaRootRetainsValue (values, weakValue); + } finally { + values.Clear (); + values.Dispose (); + values = null; + } + + AssertEventuallyCollected (weakValue, + "The managed value should be collectible after its Java collection reference is cleared and disposed."); + } + + static int GetJavaIdentityHashCode (JniObjectReference reference) + { + var systemClass = JniEnvironment.Types.FindClass ("java/lang/System"); + try { + IntPtr identityHashCode = JNIEnv.GetStaticMethodID ( + systemClass.Handle, "identityHashCode", "(Ljava/lang/Object;)I"); + return JNIEnv.CallStaticIntMethod (systemClass.Handle, identityHashCode, new JValue (reference.Handle)); + } finally { + JniObjectReference.Dispose (ref systemClass); + } + } + + [MethodImpl (MethodImplOptions.NoInlining)] + static JavaObjectArray CreateJavaRootedValue (out WeakReference weakValue) + { + var value = new ManagedValue (42); + var values = new JavaObjectArray (1); + values [0] = value; + weakValue = new WeakReference (value); + return values; + } + + [MethodImpl (MethodImplOptions.NoInlining)] + static void AssertJavaRootRetainsValue ( + JavaObjectArray values, + WeakReference weakValue) + { + Assert.IsTrue (weakValue.TryGetTarget (out var value), + "A Java collection reference should retain its managed value."); + Assert.AreSame (value, values [0]); + GC.KeepAlive (value); + } + + static void ForceGc (int attempts) + { + for (int i = 0; i < attempts; i++) { + GC.Collect (generation: 2, mode: GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers (); + JniEnvironment.Runtime.ValueManager.CollectPeers (); + } + } + + static void AssertEventuallyCollected (WeakReference weakValue, string message) + { + for (int i = 0; i < MaxGcAttempts; i++) { + ForceGc (1); + if (!IsAlive (weakValue)) { + return; + } + Thread.Yield (); + } + Assert.Fail (message); + } + + [MethodImpl (MethodImplOptions.NoInlining)] + static bool IsAlive (WeakReference weakValue) + { + return weakValue.TryGetTarget (out _); + } + + sealed class ManagedValue + { + public ManagedValue (int value) + { + Value = value; + } + + public int Value { get; } + + public override bool Equals (object obj) + { + return obj is ManagedValue other && Value == other.Value; + } + + public override int GetHashCode () + { + return Value; + } + + public override string ToString () + { + return $"ManagedValue({Value})"; + } + } + } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index 6405287d880..b79ab61d747 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -141,6 +141,7 @@ + From 81305091b7ad9e8e3186169a58899f847bb751f3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 06:12:22 +0200 Subject: [PATCH 2/7] [Tests] Clarify managed proxy semantics State the shared round-trip contract separately from typemap-specific Java object methods. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index ef600fd4c81..0e59da2a83c 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -13,6 +13,8 @@ namespace Java.InteropTests [Category ("ManagedObjectProxy")] public class ManagedObjectProxyTests { + // Managed reference identity is the common round-trip contract. Java-visible equality, + // hashing, and string conversion are asserted separately because they vary by typemap. const int MaxGcAttempts = 20; public ManagedObjectProxyTests () From 3c52a751af0cd0861b52ad3654cbaf89f7a8a1f5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 06:28:11 +0200 Subject: [PATCH 3/7] [Tests] Harden managed proxy contracts Wait for an observed JNI bridge-processing generation when testing proxy lifetime, and derive the exact default Java object string from the runtime proxy class and unsigned identity hash. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index 0e59da2a83c..da17df7a896 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -1,6 +1,7 @@ using System; +using System.Globalization; using System.Runtime.CompilerServices; -using System.Threading; +using System.Threading.Tasks; using Android.Runtime; using Java.Interop; @@ -15,8 +16,6 @@ public class ManagedObjectProxyTests { // Managed reference identity is the common round-trip contract. Java-visible equality, // hashing, and string conversion are asserted separately because they vary by typemap. - const int MaxGcAttempts = 20; - public ManagedObjectProxyTests () { } @@ -199,9 +198,10 @@ public void ProxyObjectMethodsFollowConfiguredJavaSemantics () "Trimmable proxies intentionally use Java reference identity instead of managed Equals."); Assert.AreEqual (GetJavaIdentityHashCode (reference), actualHashCode, "Trimmable proxies intentionally use Java identity hash codes."); - Assert.IsTrue ( - actualString.StartsWith ("net.dot.jni.internal.TrimmableJavaProxyObject@", StringComparison.Ordinal), - actualString); + string runtimeClassName = JNIEnv.GetClassNameFromInstance (reference.Handle).Replace ('/', '.'); + string expectedString = $"{runtimeClassName}@{unchecked ((uint) actualHashCode).ToString ("x", CultureInfo.InvariantCulture)}"; + Assert.AreEqual (expectedString, actualString, + "Trimmable proxies should use the exact default java.lang.Object.toString format."); } else { Assert.IsTrue (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (equalReference.Handle)), "The llvm-ir proxy forwards Java equals to the managed override."); @@ -218,13 +218,14 @@ public void ProxyObjectMethodsFollowConfiguredJavaSemantics () } [Test] - public void JavaObjectArrayRetainsManagedValueUntilReleased () + public async Task JavaObjectArrayRetainsManagedValueUntilReleased () { WeakReference weakValue; var values = CreateJavaRootedValue (out weakValue); try { - ForceGc (3); + await WaitForGC (() => IsAlive (weakValue), + "A Java array reference should retain its managed value."); AssertJavaRootRetainsValue (values, weakValue); } finally { values.Clear (); @@ -232,7 +233,7 @@ public void JavaObjectArrayRetainsManagedValueUntilReleased () values = null; } - AssertEventuallyCollected (weakValue, + await WaitForGC (() => !IsAlive (weakValue), "The managed value should be collectible after its Java collection reference is cleared and disposed."); } @@ -269,25 +270,25 @@ static void AssertJavaRootRetainsValue ( GC.KeepAlive (value); } - static void ForceGc (int attempts) + static async Task WaitForGC (Func predicate, string message, int timeoutMilliseconds = 5000) { - for (int i = 0; i < attempts; i++) { + int initialBridgeGeneration = JNIEnv.BridgeProcessingGeneration; + var timeout = TimeSpan.FromMilliseconds (timeoutMilliseconds); + var start = DateTime.UtcNow; + while ((JNIEnv.BridgeProcessingGeneration == initialBridgeGeneration || !predicate ()) && + DateTime.UtcNow - start < timeout) { GC.Collect (generation: 2, mode: GCCollectionMode.Forced, blocking: true); GC.WaitForPendingFinalizers (); + JNIEnv.WaitForBridgeProcessing (); JniEnvironment.Runtime.ValueManager.CollectPeers (); + await Task.Yield (); } - } - static void AssertEventuallyCollected (WeakReference weakValue, string message) - { - for (int i = 0; i < MaxGcAttempts; i++) { - ForceGc (1); - if (!IsAlive (weakValue)) { - return; - } - Thread.Yield (); - } - Assert.Fail (message); + int finalBridgeGeneration = JNIEnv.BridgeProcessingGeneration; + Assert.Greater (finalBridgeGeneration, initialBridgeGeneration, + $"A JNI bridge-processing cycle did not complete within {timeoutMilliseconds}ms. " + + $"Initial generation: {initialBridgeGeneration}; final generation: {finalBridgeGeneration}."); + Assert.IsTrue (predicate (), message); } [MethodImpl (MethodImplOptions.NoInlining)] From ee8ef44373f5ebbcda130e28a34e86ede1980eb1 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 06:42:17 +0200 Subject: [PATCH 4/7] [Tests] Support Mono proxy GC waits Always perform a full collection and bridge synchronization cycle, while requiring bridge-generation advancement only on CoreCLR and NativeAOT. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index da17df7a896..66d8547f81f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -272,22 +272,27 @@ static void AssertJavaRootRetainsValue ( static async Task WaitForGC (Func predicate, string message, int timeoutMilliseconds = 5000) { + bool requireBridgeGeneration = !Microsoft.Android.Runtime.RuntimeFeature.IsMonoRuntime; int initialBridgeGeneration = JNIEnv.BridgeProcessingGeneration; var timeout = TimeSpan.FromMilliseconds (timeoutMilliseconds); var start = DateTime.UtcNow; - while ((JNIEnv.BridgeProcessingGeneration == initialBridgeGeneration || !predicate ()) && - DateTime.UtcNow - start < timeout) { + do { GC.Collect (generation: 2, mode: GCCollectionMode.Forced, blocking: true); GC.WaitForPendingFinalizers (); JNIEnv.WaitForBridgeProcessing (); JniEnvironment.Runtime.ValueManager.CollectPeers (); + JNIEnv.WaitForBridgeProcessing (); await Task.Yield (); - } + } while ((!predicate () || + (requireBridgeGeneration && JNIEnv.BridgeProcessingGeneration == initialBridgeGeneration)) && + DateTime.UtcNow - start < timeout); int finalBridgeGeneration = JNIEnv.BridgeProcessingGeneration; - Assert.Greater (finalBridgeGeneration, initialBridgeGeneration, - $"A JNI bridge-processing cycle did not complete within {timeoutMilliseconds}ms. " + - $"Initial generation: {initialBridgeGeneration}; final generation: {finalBridgeGeneration}."); + if (requireBridgeGeneration) { + Assert.Greater (finalBridgeGeneration, initialBridgeGeneration, + $"A JNI bridge-processing cycle did not complete within {timeoutMilliseconds}ms. " + + $"Initial generation: {initialBridgeGeneration}; final generation: {finalBridgeGeneration}."); + } Assert.IsTrue (predicate (), message); } From eff7a15bea92586028cc63643ba94deb1bbb9ce3 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 12:54:11 +0200 Subject: [PATCH 5/7] [Tests] Harden managed proxy test cleanup Use exception-safe JNI reference acquisition and collection disposal, and measure the bounded GC wait with a monotonic stopwatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index 66d8547f81f..866e5a23ec9 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -16,10 +17,6 @@ public class ManagedObjectProxyTests { // Managed reference identity is the common round-trip contract. Java-visible equality, // hashing, and string conversion are asserted separately because they vary by typemap. - public ManagedObjectProxyTests () - { - } - [Test] public void JavaObjectArray_RoundTripPreservesManagedReferences () { @@ -48,9 +45,12 @@ public void JavaObjectArray_RepeatedLookupUsesSameJavaAndManagedIdentity () using var values = new JavaObjectArray (1); values [0] = value; - var firstReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); - var secondReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); + JniObjectReference firstReference = default; + JniObjectReference secondReference = default; try { + firstReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); + secondReference = JniEnvironment.Arrays.GetObjectArrayElement (values.PeerReference, 0); + Assert.IsTrue (JniEnvironment.Types.IsSameObject (firstReference, secondReference), "Repeated Java array lookups should refer to the same Java proxy."); @@ -174,10 +174,13 @@ public void ProxyObjectMethodsFollowConfiguredJavaSemantics () { var value = new ManagedValue (42); var equalValue = new ManagedValue (42); - var reference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), value); - var equalReference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), equalValue); + JniObjectReference reference = default; + JniObjectReference equalReference = default; try { + reference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), value); + equalReference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), equalValue); + IntPtr proxyClass = JNIEnv.GetObjectClass (reference.Handle); try { IntPtr equals = JNIEnv.GetMethodID (proxyClass, "equals", "(Ljava/lang/Object;)Z"); @@ -228,8 +231,11 @@ await WaitForGC (() => IsAlive (weakValue), "A Java array reference should retain its managed value."); AssertJavaRootRetainsValue (values, weakValue); } finally { - values.Clear (); - values.Dispose (); + try { + values.Clear (); + } finally { + values.Dispose (); + } values = null; } @@ -275,7 +281,7 @@ static async Task WaitForGC (Func predicate, string message, int timeoutMi bool requireBridgeGeneration = !Microsoft.Android.Runtime.RuntimeFeature.IsMonoRuntime; int initialBridgeGeneration = JNIEnv.BridgeProcessingGeneration; var timeout = TimeSpan.FromMilliseconds (timeoutMilliseconds); - var start = DateTime.UtcNow; + var stopwatch = Stopwatch.StartNew (); do { GC.Collect (generation: 2, mode: GCCollectionMode.Forced, blocking: true); GC.WaitForPendingFinalizers (); @@ -285,7 +291,7 @@ static async Task WaitForGC (Func predicate, string message, int timeoutMi await Task.Yield (); } while ((!predicate () || (requireBridgeGeneration && JNIEnv.BridgeProcessingGeneration == initialBridgeGeneration)) && - DateTime.UtcNow - start < timeout); + stopwatch.Elapsed < timeout); int finalBridgeGeneration = JNIEnv.BridgeProcessingGeneration; if (requireBridgeGeneration) { From b3dff464ea6b3dbf8fd2cd8b1b43c60c5d70e2f6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 13:18:23 +0200 Subject: [PATCH 6/7] [Tests] Complete managed proxy cleanup coverage Keep every JNI local reference exception-safe, retain caller ownership while constructing the unregistered Java object view, and create the lifetime-test target on a short-lived stack to avoid Mono false pinning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 92 ++++++++++++++++--- 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index 866e5a23ec9..3decbeeaee9 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; using Android.Runtime; @@ -15,6 +17,8 @@ namespace Java.InteropTests [Category ("ManagedObjectProxy")] public class ManagedObjectProxyTests { + static IntPtr noPinActionPointer; + // Managed reference identity is the common round-trip contract. Java-visible equality, // hashing, and string conversion are asserted separately because they vary by typemap. [Test] @@ -74,18 +78,25 @@ public void JavaLangObjectView_RoundTripUsesManagedProxyAssociation () var reference = JniEnvironment.Runtime.ValueManager.CreateLocalObjectReferenceArgument (typeof (object), value); try { var viewReference = reference.NewLocalRef (); - using var view = new Java.Lang.Object ( - viewReference.Handle, - JniHandleOwnership.TransferLocalRef | JniHandleOwnership.DoNotRegister); - viewReference = default; + try { + using var view = new Java.Lang.Object ( + viewReference.Handle, + JniHandleOwnership.DoNotTransfer | JniHandleOwnership.DoNotRegister); - Assert.IsTrue (JniEnvironment.Types.IsSameObject (reference, view.PeerReference)); + Assert.IsTrue (JniEnvironment.Types.IsSameObject (reference, view.PeerReference)); - var roundTripReference = view.PeerReference.NewLocalRef (); - var roundTrip = JniEnvironment.Runtime.ValueManager.GetValue ( - ref roundTripReference, JniObjectReferenceOptions.CopyAndDispose); + var roundTripReference = view.PeerReference.NewLocalRef (); + try { + var roundTrip = JniEnvironment.Runtime.ValueManager.GetValue ( + ref roundTripReference, JniObjectReferenceOptions.CopyAndDispose); - Assert.AreSame (value, roundTrip); + Assert.AreSame (value, roundTrip); + } finally { + JniObjectReference.Dispose (ref roundTripReference); + } + } finally { + JniObjectReference.Dispose (ref viewReference); + } } finally { JniObjectReference.Dispose (ref reference); } @@ -192,9 +203,16 @@ public void ProxyObjectMethodsFollowConfiguredJavaSemantics () Assert.IsTrue (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (reference.Handle))); int actualHashCode = JNIEnv.CallIntMethod (reference.Handle, hashCode); - string actualString = JNIEnv.GetString ( + var actualStringReference = new JniObjectReference ( JNIEnv.CallObjectMethod (reference.Handle, toString), - JniHandleOwnership.TransferLocalRef); + JniObjectReferenceType.Local); + string actualString; + try { + actualString = JniEnvironment.Strings.ToString ( + ref actualStringReference, JniObjectReferenceOptions.CopyAndDispose); + } finally { + JniObjectReference.Dispose (ref actualStringReference); + } if (Microsoft.Android.Runtime.RuntimeFeature.TrimmableTypeMap) { Assert.IsFalse (JNIEnv.CallBooleanMethod (reference.Handle, equals, new JValue (equalReference.Handle)), @@ -258,11 +276,24 @@ static int GetJavaIdentityHashCode (JniObjectReference reference) [MethodImpl (MethodImplOptions.NoInlining)] static JavaObjectArray CreateJavaRootedValue (out WeakReference weakValue) { - var value = new ManagedValue (42); var values = new JavaObjectArray (1); - values [0] = value; - weakValue = new WeakReference (value); - return values; + bool initialized = false; + try { + WeakReference createdWeakValue = null; + // A short-lived stack avoids false pinning when Mono conservatively scans the test thread. + PerformNoPinAction (() => { + var value = new ManagedValue (42); + values [0] = value; + createdWeakValue = new WeakReference (value); + }); + weakValue = createdWeakValue; + initialized = true; + return values; + } finally { + if (!initialized) { + values.Dispose (); + } + } } [MethodImpl (MethodImplOptions.NoInlining)] @@ -308,6 +339,37 @@ static bool IsAlive (WeakReference weakValue) return weakValue.TryGetTarget (out _); } + static unsafe void NoPinActionHelper (int depth, Action action) + { + int* values = stackalloc int [20]; + noPinActionPointer = new IntPtr (values); + + if (depth <= 0) { + new object (); + action (); + } else { + NoPinActionHelper (depth - 1, action); + } + } + + static void PerformNoPinAction (Action action) + { + Exception exception = null; + var thread = new Thread (() => { + try { + NoPinActionHelper (128, action); + } catch (Exception e) { + exception = e; + } + }); + thread.Start (); + thread.Join (); + + if (exception != null) { + ExceptionDispatchInfo.Capture (exception).Throw (); + } + } + sealed class ManagedValue { public ManagedValue (int value) From 8abe17b98982b573a6e4dec40396a4bd46b7d8e5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 1 Sep 2026 13:30:25 +0200 Subject: [PATCH 7/7] [Tests] Avoid false roots in proxy GC probes Run weak-reference probes and retention assertions on short-lived stacks so Mono conservative scanning cannot retain stale strong target references between forced collections. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/ManagedObjectProxyTests.cs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs index 3decbeeaee9..9125c96b8d2 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/ManagedObjectProxyTests.cs @@ -301,10 +301,12 @@ static void AssertJavaRootRetainsValue ( JavaObjectArray values, WeakReference weakValue) { - Assert.IsTrue (weakValue.TryGetTarget (out var value), - "A Java collection reference should retain its managed value."); - Assert.AreSame (value, values [0]); - GC.KeepAlive (value); + PerformNoPinAction (() => { + Assert.IsTrue (weakValue.TryGetTarget (out var value), + "A Java collection reference should retain its managed value."); + Assert.AreSame (value, values [0]); + GC.KeepAlive (value); + }); } static async Task WaitForGC (Func predicate, string message, int timeoutMilliseconds = 5000) @@ -336,7 +338,9 @@ static async Task WaitForGC (Func predicate, string message, int timeoutMi [MethodImpl (MethodImplOptions.NoInlining)] static bool IsAlive (WeakReference weakValue) { - return weakValue.TryGetTarget (out _); + bool isAlive = false; + PerformNoPinAction (() => isAlive = weakValue.TryGetTarget (out _)); + return isAlive; } static unsafe void NoPinActionHelper (int depth, Action action)