From 73a1ac62f29fd20d23e6b9440b40ae17060c7c7c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 11:04:05 +0200 Subject: [PATCH 1/5] [tests] Isolate TryFindClass JNI leak checks Run the process-wide GREF assertions alone in a fresh filtered Android test process and amplify sustained per-call leaks without allowing positive count growth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../yaml-templates/stage-package-tests.yaml | 11 ++++ .../Java.Interop/JniTypeUtf8Test.cs | 50 +++++++++++++++---- .../TestInstrumentation.cs | 30 +++++++++-- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/build-tools/automation/yaml-templates/stage-package-tests.yaml b/build-tools/automation/yaml-templates/stage-package-tests.yaml index caf98d3d1e4..bec988bfba0 100644 --- a/build-tools/automation/yaml-templates/stage-package-tests.yaml +++ b/build-tools/automation/yaml-templates/stage-package-tests.yaml @@ -155,6 +155,17 @@ stages: - template: /build-tools/automation/yaml-templates/start-stop-emulator.yaml + # Process-wide JNI reference counts need a fresh process without unrelated tests. + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + buildConfiguration: $(XA.Build.Configuration) + configuration: Debug + testName: Mono.Android.NET_Tests-JniReferenceLeaks + project: tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj + extraBuildArgs: -p:TestsFlavor=JniReferenceLeaks -p:IncludeCategories=JniReferenceLeak -p:UseMonoRuntime=false + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/Mono.Android.NET_Tests-Signed.apk + artifactFolder: $(DotNetTargetFramework)-JniReferenceLeaks + # Smoke coverage that the test app still builds and runs under Mono. - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml parameters: diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index feba304c0a5..f9b3e738fb4 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -11,6 +11,9 @@ namespace Java.InteropTests [TestFixture] public class JniTypeUtf8Test : JavaVMFixture { + const string JniReferenceLeakCategory = "JniReferenceLeak"; + const int LeakCheckIterations = 100; + [Test] public unsafe void Sanity_Utf8 () { @@ -91,24 +94,53 @@ public void TryFindClass_Utf8 () } [Test] - [Ignore ("Frequently failing: https://github.com/dotnet/android/issues/12031")] + [Category (JniReferenceLeakCategory)] public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () { - int grefsBefore = JniEnvironment.Runtime.GlobalReferenceCount; - JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out _); - int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount; - Assert.AreEqual (grefsBefore, grefsAfter, - "TryFindClass for non-existent classes should not leak global references"); + AssertNoGlobalReferenceLeak (() => { + Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out var notFound)); + Assert.IsFalse (notFound.IsValid); + }); } [Test] + [Category (JniReferenceLeakCategory)] public void TryFindClass_String_DoesNotLeakGlobalRefs () { + AssertNoGlobalReferenceLeak (() => { + Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist", out var notFound)); + Assert.IsFalse (notFound.IsValid); + }); + } + + static void AssertNoGlobalReferenceLeak (Action action) + { + for (int i = 0; i < LeakCheckIterations; i++) { + action (); + } + CollectPeers (); + int grefsBefore = JniEnvironment.Runtime.GlobalReferenceCount; - JniEnvironment.Types.TryFindClass ("does/not/Exist", out _); + for (int i = 0; i < LeakCheckIterations; i++) { + action (); + } + CollectPeers (); int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount; - Assert.AreEqual (grefsBefore, grefsAfter, - "TryFindClass for non-existent classes should not leak global references"); + + Assert.LessOrEqual (grefsAfter, grefsBefore, + $"TryFindClass should not leak global references after {LeakCheckIterations} iterations. " + + $"Before={grefsBefore}, After={grefsAfter}, Delta={grefsAfter - grefsBefore}"); + } + + static void CollectPeers () + { + for (int i = 0; i < 3; i++) { + GC.Collect (); + GC.WaitForPendingFinalizers (); + } + + JniEnvironment.Runtime.ValueManager.CollectPeers (); + JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing (); } [Test] diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs index 8970d10ed5b..b318fcc9b86 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs @@ -11,6 +11,8 @@ namespace Xamarin.Android.RuntimeTests [Instrumentation (Name = "xamarin.android.runtimetests.TestInstrumentation")] public class TestInstrumentation : Xamarin.Android.UnitTests.TestInstrumentation { + const string JniReferenceLeakCategory = "JniReferenceLeak"; + protected TestInstrumentation (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { @@ -54,6 +56,11 @@ protected override IEnumerable? ExcludedCategories { categories.Add ("NetworkInterfaces"); } + // Process-wide reference counts are only stable in the dedicated filtered run. + if (!IsCategoryIncluded (JniReferenceLeakCategory)) { + categories.Add (JniReferenceLeakCategory); + } + return categories.Count > 0 ? categories : null; } } @@ -66,13 +73,28 @@ protected override IEnumerable? IncludedCategories { // `configProperties` section, and we read it back with `AppContext.GetData`. // Used by lanes that want to scope a run to specific categories, e.g. // `-p:IncludeCategories=Intune` in stage-package-tests.yaml. - var value = AppContext.GetData ("IncludeCategories") as string; - if (string.IsNullOrEmpty (value)) - return null; - return value!.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + var categories = GetIncludedCategories (); + return categories.Length > 0 ? categories : null; } } + static bool IsCategoryIncluded (string category) + { + foreach (var includedCategory in GetIncludedCategories ()) { + if (string.Equals (includedCategory, category, StringComparison.Ordinal)) + return true; + } + return false; + } + + static string [] GetIncludedCategories () + { + var value = AppContext.GetData ("IncludeCategories") as string; + if (string.IsNullOrEmpty (value)) + return []; + return value.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + } + static bool HasAppContextSwitch (string key) => AppContext.TryGetSwitch (key, out var value) && value; From efb148f98905f9a1b5f1e43d8dbfbc906fda8a20 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 12:52:40 +0200 Subject: [PATCH 2/5] [tests] Keep leak cleanup outside measurement Use only GC and finalizer synchronization before the after-count, and add a retained-global-reference control proving the assertion detects positive GREF growth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniTypeUtf8Test.cs | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index f9b3e738fb4..59e5397596c 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using Java.Interop; @@ -113,6 +114,25 @@ public void TryFindClass_String_DoesNotLeakGlobalRefs () }); } + [Test] + [Category (JniReferenceLeakCategory)] + public void AssertNoGlobalReferenceLeak_DetectsRetainedGlobalReference () + { + var objectClass = JniEnvironment.Types.FindClass ("java/lang/Object"); + var retainedReferences = new List (); + try { + Assert.Throws (() => AssertNoGlobalReferenceLeak (() => { + retainedReferences.Add (objectClass.NewGlobalRef ()); + })); + } finally { + foreach (var retainedReference in retainedReferences) { + var reference = retainedReference; + JniObjectReference.Dispose (ref reference); + } + JniObjectReference.Dispose (ref objectClass); + } + } + static void AssertNoGlobalReferenceLeak (Action action) { for (int i = 0; i < LeakCheckIterations; i++) { @@ -124,23 +144,28 @@ static void AssertNoGlobalReferenceLeak (Action action) for (int i = 0; i < LeakCheckIterations; i++) { action (); } - CollectPeers (); + CollectGarbage (); int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount; Assert.LessOrEqual (grefsAfter, grefsBefore, - $"TryFindClass should not leak global references after {LeakCheckIterations} iterations. " + + $"Operation should not leak global references after {LeakCheckIterations} iterations. " + $"Before={grefsBefore}, After={grefsAfter}, Delta={grefsAfter - grefsBefore}"); } static void CollectPeers () + { + CollectGarbage (); + JniEnvironment.Runtime.ValueManager.CollectPeers (); + JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing (); + CollectGarbage (); + } + + static void CollectGarbage () { for (int i = 0; i < 3; i++) { GC.Collect (); GC.WaitForPendingFinalizers (); } - - JniEnvironment.Runtime.ValueManager.CollectPeers (); - JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing (); } [Test] From 31eccedfc5de9ad3c5e572f473659d5728c20c99 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 8 Sep 2026 21:47:08 +0200 Subject: [PATCH 3/5] [tests] Clarify sustained GREF growth check Rename the helper and retained-reference control so their names reflect warmup and net-growth measurement semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index 59e5397596c..b17d0bd44a8 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -98,7 +98,7 @@ public void TryFindClass_Utf8 () [Category (JniReferenceLeakCategory)] public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () { - AssertNoGlobalReferenceLeak (() => { + AssertNoSustainedGlobalReferenceGrowth (() => { Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out var notFound)); Assert.IsFalse (notFound.IsValid); }); @@ -108,7 +108,7 @@ public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () [Category (JniReferenceLeakCategory)] public void TryFindClass_String_DoesNotLeakGlobalRefs () { - AssertNoGlobalReferenceLeak (() => { + AssertNoSustainedGlobalReferenceGrowth (() => { Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist", out var notFound)); Assert.IsFalse (notFound.IsValid); }); @@ -116,12 +116,12 @@ public void TryFindClass_String_DoesNotLeakGlobalRefs () [Test] [Category (JniReferenceLeakCategory)] - public void AssertNoGlobalReferenceLeak_DetectsRetainedGlobalReference () + public void AssertNoSustainedGlobalReferenceGrowth_DetectsRetainedGlobalReference () { var objectClass = JniEnvironment.Types.FindClass ("java/lang/Object"); var retainedReferences = new List (); try { - Assert.Throws (() => AssertNoGlobalReferenceLeak (() => { + Assert.Throws (() => AssertNoSustainedGlobalReferenceGrowth (() => { retainedReferences.Add (objectClass.NewGlobalRef ()); })); } finally { @@ -133,7 +133,7 @@ public void AssertNoGlobalReferenceLeak_DetectsRetainedGlobalReference () } } - static void AssertNoGlobalReferenceLeak (Action action) + static void AssertNoSustainedGlobalReferenceGrowth (Action action) { for (int i = 0; i < LeakCheckIterations; i++) { action (); From 850a172cf1c03a82d1ed7a5244b26876f97d04f0 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 10 Sep 2026 11:45:04 +0200 Subject: [PATCH 4/5] Use explicit isolation for JNI leak tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniTypeUtf8Test.cs | 3 ++ .../TestInstrumentation.cs | 30 +++---------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index b17d0bd44a8..0eca4da72f8 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -95,6 +95,7 @@ public void TryFindClass_Utf8 () } [Test] + [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () { @@ -105,6 +106,7 @@ public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () } [Test] + [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void TryFindClass_String_DoesNotLeakGlobalRefs () { @@ -115,6 +117,7 @@ public void TryFindClass_String_DoesNotLeakGlobalRefs () } [Test] + [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void AssertNoSustainedGlobalReferenceGrowth_DetectsRetainedGlobalReference () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs index b318fcc9b86..8970d10ed5b 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs @@ -11,8 +11,6 @@ namespace Xamarin.Android.RuntimeTests [Instrumentation (Name = "xamarin.android.runtimetests.TestInstrumentation")] public class TestInstrumentation : Xamarin.Android.UnitTests.TestInstrumentation { - const string JniReferenceLeakCategory = "JniReferenceLeak"; - protected TestInstrumentation (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { @@ -56,11 +54,6 @@ protected override IEnumerable? ExcludedCategories { categories.Add ("NetworkInterfaces"); } - // Process-wide reference counts are only stable in the dedicated filtered run. - if (!IsCategoryIncluded (JniReferenceLeakCategory)) { - categories.Add (JniReferenceLeakCategory); - } - return categories.Count > 0 ? categories : null; } } @@ -73,26 +66,11 @@ protected override IEnumerable? IncludedCategories { // `configProperties` section, and we read it back with `AppContext.GetData`. // Used by lanes that want to scope a run to specific categories, e.g. // `-p:IncludeCategories=Intune` in stage-package-tests.yaml. - var categories = GetIncludedCategories (); - return categories.Length > 0 ? categories : null; - } - } - - static bool IsCategoryIncluded (string category) - { - foreach (var includedCategory in GetIncludedCategories ()) { - if (string.Equals (includedCategory, category, StringComparison.Ordinal)) - return true; + var value = AppContext.GetData ("IncludeCategories") as string; + if (string.IsNullOrEmpty (value)) + return null; + return value!.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); } - return false; - } - - static string [] GetIncludedCategories () - { - var value = AppContext.GetData ("IncludeCategories") as string; - if (string.IsNullOrEmpty (value)) - return []; - return value.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); } static bool HasAppContextSwitch (string key) From 34cd64b3c5caf62f022d31254005f8527e81b637 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 11 Sep 2026 17:39:17 +0200 Subject: [PATCH 5/5] Fix Android JNI leak test selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniTypeUtf8Test.cs | 3 --- .../TestInstrumentation.cs | 27 ++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs index 0eca4da72f8..b17d0bd44a8 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeUtf8Test.cs @@ -95,7 +95,6 @@ public void TryFindClass_Utf8 () } [Test] - [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () { @@ -106,7 +105,6 @@ public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () } [Test] - [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void TryFindClass_String_DoesNotLeakGlobalRefs () { @@ -117,7 +115,6 @@ public void TryFindClass_String_DoesNotLeakGlobalRefs () } [Test] - [Explicit ("Run only in isolated JniReferenceLeak test runs.")] [Category (JniReferenceLeakCategory)] public void AssertNoSustainedGlobalReferenceGrowth_DetectsRetainedGlobalReference () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs index 8970d10ed5b..868cf25760c 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/TestInstrumentation.cs @@ -11,6 +11,8 @@ namespace Xamarin.Android.RuntimeTests [Instrumentation (Name = "xamarin.android.runtimetests.TestInstrumentation")] public class TestInstrumentation : Xamarin.Android.UnitTests.TestInstrumentation { + const string JniReferenceLeakCategory = "JniReferenceLeak"; + protected TestInstrumentation (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { @@ -54,6 +56,11 @@ protected override IEnumerable? ExcludedCategories { categories.Add ("NetworkInterfaces"); } + // Process-wide reference counts are only stable in the dedicated filtered run. + if (!IsOnlyIncludedCategory (JniReferenceLeakCategory)) { + categories.Add (JniReferenceLeakCategory); + } + return categories.Count > 0 ? categories : null; } } @@ -66,13 +73,25 @@ protected override IEnumerable? IncludedCategories { // `configProperties` section, and we read it back with `AppContext.GetData`. // Used by lanes that want to scope a run to specific categories, e.g. // `-p:IncludeCategories=Intune` in stage-package-tests.yaml. - var value = AppContext.GetData ("IncludeCategories") as string; - if (string.IsNullOrEmpty (value)) - return null; - return value!.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); + var categories = GetIncludedCategories (); + return categories.Length > 0 ? categories : null; } } + static bool IsOnlyIncludedCategory (string category) + { + var categories = GetIncludedCategories (); + return categories.Length == 1 && string.Equals (categories [0], category, StringComparison.Ordinal); + } + + static string [] GetIncludedCategories () + { + var value = AppContext.GetData ("IncludeCategories") as string; + if (value == null) + return []; + return value.Split (new [] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + static bool HasAppContextSwitch (string key) => AppContext.TryGetSwitch (key, out var value) && value;