From d4f6c2a6e4e469e0941e642eb5e108110fe7cb94 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 11 Jul 2026 19:22:50 +0200 Subject: [PATCH 01/10] Add isolated JNI reference leak tests Move JNI reference and peer leak checks into a dedicated serial MSTest/MTP app and run it under CoreCLR and Mono in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9 --- .../skills/tests/references/test-catalog.md | 10 ++ Xamarin.Android-Tests.slnx | 1 + .../automation/azure-pipelines-nightly.yaml | 10 ++ .../yaml-templates/stage-package-tests.yaml | 20 +++ .../Java.Interop/JavaArrayContract.cs | 3 +- .../Java.Interop/JavaObjectArrayTest.cs | 2 + .../Java.Interop/JavaObjectTest.cs | 2 + .../Java.Interop/JniEnvironmentTests.cs | 3 +- .../JniRuntimeJniValueManagerContract.cs | 47 ++---- .../Java.Interop/JniTypeUtf8Test.cs | 3 +- .../BindingTests.cs | 5 - .../JniReferenceLeakTests/AndroidManifest.xml | 6 + tests/JniReferenceLeakTests/AssemblyInfo.cs | 3 + .../GlobalReferenceTests.cs | 84 +++++++++++ .../JniReferenceLeakTests.csproj | 31 ++++ .../LocalReferenceTests.cs | 69 +++++++++ .../PeerReferenceTests.cs | 135 ++++++++++++++++++ .../ReferenceTestHelpers.cs | 36 +++++ .../Resources/layout/leak_test_widget.axml | 6 + .../TestInstrumentation.cs | 110 ++++++++++++++ .../WidgetReferenceTests.cs | 39 +++++ tests/JniReferenceLeakTests/global.json | 5 + .../ActivationProbeFactory.java | 9 ++ .../Android.Widget/CustomWidgetTests.cs | 41 ------ .../Java.Interop/JnienvTest.cs | 53 ------- 25 files changed, 596 insertions(+), 137 deletions(-) create mode 100644 tests/JniReferenceLeakTests/AndroidManifest.xml create mode 100644 tests/JniReferenceLeakTests/AssemblyInfo.cs create mode 100644 tests/JniReferenceLeakTests/GlobalReferenceTests.cs create mode 100644 tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj create mode 100644 tests/JniReferenceLeakTests/LocalReferenceTests.cs create mode 100644 tests/JniReferenceLeakTests/PeerReferenceTests.cs create mode 100644 tests/JniReferenceLeakTests/ReferenceTestHelpers.cs create mode 100644 tests/JniReferenceLeakTests/Resources/layout/leak_test_widget.axml create mode 100644 tests/JniReferenceLeakTests/TestInstrumentation.cs create mode 100644 tests/JniReferenceLeakTests/WidgetReferenceTests.cs create mode 100644 tests/JniReferenceLeakTests/global.json create mode 100644 tests/JniReferenceLeakTests/java/net/dot/jni/referenceleaktests/ActivationProbeFactory.java diff --git a/.github/skills/tests/references/test-catalog.md b/.github/skills/tests/references/test-catalog.md index ca48bdadc0d..441d74dc117 100644 --- a/.github/skills/tests/references/test-catalog.md +++ b/.github/skills/tests/references/test-catalog.md @@ -123,6 +123,7 @@ Device: **Yes** | Test Area | Project | Notes | |-----------|---------|-------| | **runtime** (all) | `tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj` | Core runtime tests | +| **JNI reference leaks** | `tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj` | Dedicated serial MSTest/MTP app for JNI reference and peer leak checks | | **networking** | Same project — tests in `Xamarin.Android.Net/` and `System.Net/` | `AndroidMessageHandlerTests`, `AndroidMessageHandlerIntegrationTests` | | **java interop (on-device)** | Same project — tests in `Java.Interop/` | `JnienvTest`, `JavaListTest` | | **android app** | Same project — tests in `Android.App/` | `Application`, `Activity` tests | @@ -154,6 +155,15 @@ Command: Results are `.trx` files under `bin/TestDebug/TestResults/` and are published as VSTest results in CI. Always pass the same configuration and MSBuild properties to the install and `dotnet test --no-build` commands (for example, `-c Release -p:UseMonoRuntime=false`). +For the dedicated JNI reference leak tests: +```bash +./dotnet-local.sh build -t:Install -c Debug tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +( + cd tests/JniReferenceLeakTests + ../../dotnet-local.sh test JniReferenceLeakTests.csproj --no-build -c Debug --report-trx --results-directory ../../bin/TestDebug/TestResults +) +``` + For `Xamarin.Android.JcwGen-Tests`, use the same pattern from `tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/`: ```bash ./dotnet-local.sh build -t:Install -c Debug tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/Xamarin.Android.JcwGen-Tests.csproj diff --git a/Xamarin.Android-Tests.slnx b/Xamarin.Android-Tests.slnx index 14fb985a7a5..6d830e68144 100644 --- a/Xamarin.Android-Tests.slnx +++ b/Xamarin.Android-Tests.slnx @@ -27,6 +27,7 @@ + diff --git a/build-tools/automation/azure-pipelines-nightly.yaml b/build-tools/automation/azure-pipelines-nightly.yaml index 12733b86759..a4bb08d0281 100644 --- a/build-tools/automation/azure-pipelines-nightly.yaml +++ b/build-tools/automation/azure-pipelines-nightly.yaml @@ -145,6 +145,16 @@ stages: artifactSource: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)-android/Mono.Android.NET_Tests-Signed.aab artifactFolder: Mono.Android-$(XA.Build.Configuration)-$(avdApiLevel) + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + buildConfiguration: $(XA.Build.Configuration) + configuration: Debug + testName: JniReferenceLeakTests-$(avdApiLevel) + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:UseMonoRuntime=false -p:TestAvdApiLevel=$(avdApiLevel) -p:TestAvdAbi=$(avdAbi) -p:TestAvdType=$(avdType) + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactFolder: JniReferenceLeakTests-$(avdApiLevel) + - template: /build-tools/automation/yaml-templates/upload-results.yaml parameters: configuration: $(XA.Build.Configuration) diff --git a/build-tools/automation/yaml-templates/stage-package-tests.yaml b/build-tools/automation/yaml-templates/stage-package-tests.yaml index d4a684b183c..246cc17a78d 100644 --- a/build-tools/automation/yaml-templates/stage-package-tests.yaml +++ b/build-tools/automation/yaml-templates/stage-package-tests.yaml @@ -140,6 +140,26 @@ stages: - template: /build-tools/automation/yaml-templates/start-stop-emulator.yaml + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + buildConfiguration: $(XA.Build.Configuration) + configuration: Debug + testName: JniReferenceLeakTests-CoreCLR + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:UseMonoRuntime=false + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-CoreCLR + + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + buildConfiguration: $(XA.Build.Configuration) + configuration: Debug + testName: JniReferenceLeakTests-Mono + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:UseMonoRuntime=true -p:_DisableCheckForUnsupportedMonoMobileRuntime=true + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-Mono + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml parameters: configuration: $(XA.Build.Configuration) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaArrayContract.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaArrayContract.cs index 1279e28426c..5dd341166fa 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaArrayContract.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaArrayContract.cs @@ -10,6 +10,7 @@ namespace Java.InteropTests { public abstract class JavaArrayContract : ListContract { +#if !__ANDROID__ int lrefStartCount; [OneTimeSetUp] @@ -24,6 +25,7 @@ public void EndArrayTests () int lref = JniEnvironment.LocalReferenceCount; Assert.AreEqual (lrefStartCount, lref, "JNI local references"); } +#endif // !__ANDROID__ [Test] public void ToArray () @@ -41,4 +43,3 @@ public void ToArray () } } } - diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaObjectArrayTest.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaObjectArrayTest.cs index 3e5b9c9fc56..30bdbc21868 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaObjectArrayTest.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JavaObjectArrayTest.cs @@ -157,6 +157,7 @@ public class JavaObjectArray_object_ContractTest : JavaObjectArrayContractTest (() => o.UnregisterFromRuntime ()); } +#endif // !__ANDROID__ [Test] public void RegisterWithVM_PermitsAliases () diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniEnvironmentTests.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniEnvironmentTests.cs index fd91beb3d72..e71cf856db4 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniEnvironmentTests.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniEnvironmentTests.cs @@ -55,6 +55,7 @@ public unsafe void Handles_NewReturnToJniRef () } } +#if !__ANDROID__ [Test] public void References_CreatedReference_InvalidRef () { @@ -142,6 +143,6 @@ public void References_CreatedReference_GlobalRef () GetDeleteRefFunc ("java_interop_jnienv_delete_global_ref")?.Invoke (JniEnvironment.EnvironmentPointer, o.Handle); } } +#endif // !__ANDROID__ } } - diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniRuntimeJniValueManagerContract.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniRuntimeJniValueManagerContract.cs index dc41461b685..48a73295035 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniRuntimeJniValueManagerContract.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniRuntimeJniValueManagerContract.cs @@ -61,45 +61,33 @@ public void AddPeer () { } - int GetSurfacedPeersCount () + int GetSurfacedPeerCount (IJavaPeerable peer) { - return valueManager.GetSurfacedPeers ().Count; + return valueManager.GetSurfacedPeers ().Count (candidate => { + return candidate.SurfacedPeer.TryGetTarget (out var target) && object.ReferenceEquals (target, peer); + }); } [Test] public void AddPeer_NoDuplicates () { - int startPeerCount = GetSurfacedPeersCount (); using (var v = new MyDisposableObject ()) { // MyDisposableObject ctor implicitly calls AddPeer(); - Assert.AreEqual (startPeerCount + 1, GetSurfacedPeersCount (), DumpPeers ()); + Assert.AreEqual (1, GetSurfacedPeerCount (v), DumpPeers ()); valueManager.AddPeer (v); - Assert.AreEqual (startPeerCount + 1, GetSurfacedPeersCount (), DumpPeers ()); + Assert.AreEqual (1, GetSurfacedPeerCount (v), DumpPeers ()); } } [Test] public void ConstructPeer_ImplicitViaBindingConstructor_PeerIsInSurfacedPeers () { - int startPeerCount = GetSurfacedPeersCount (); - var g = new GetThis (); - var surfaced = valueManager.GetSurfacedPeers (); - Assert.AreEqual (startPeerCount + 1, surfaced.Count); - - var found = false; - foreach (var pr in surfaced) { - if (!pr.SurfacedPeer.TryGetTarget (out var p)) - continue; - if (object.ReferenceEquals (g, p)) { - found = true; - } - } - Assert.IsTrue (found); + Assert.AreEqual (1, GetSurfacedPeerCount (g), DumpPeers ()); var localRef = g.PeerReference.NewLocalRef (); g.Dispose (); - Assert.AreEqual (startPeerCount, GetSurfacedPeersCount ()); + Assert.AreEqual (0, GetSurfacedPeerCount (g)); Assert.IsNull (valueManager.PeekPeer (localRef)); JniObjectReference.Dispose (ref localRef); } @@ -107,29 +95,17 @@ public void ConstructPeer_ImplicitViaBindingConstructor_PeerIsInSurfacedPeers () [Test] public void ConstructPeer_ImplicitViaBindingMethod_PeerIsInSurfacedPeers () { - int startPeerCount = GetSurfacedPeersCount (); - var g = new GetThis (); - var surfaced = valueManager.GetSurfacedPeers (); - Assert.AreEqual (startPeerCount + 1, surfaced.Count); - - var found = false; - foreach (var pr in surfaced) { - if (!pr.SurfacedPeer.TryGetTarget (out var p)) - continue; - if (object.ReferenceEquals (g, p)) { - found = true; - } - } - Assert.IsTrue (found); + Assert.AreEqual (1, GetSurfacedPeerCount (g), DumpPeers ()); var localRef = g.PeerReference.NewLocalRef (); g.Dispose (); - Assert.AreEqual (startPeerCount, GetSurfacedPeersCount ()); + Assert.AreEqual (0, GetSurfacedPeerCount (g)); Assert.IsNull (valueManager.PeekPeer (localRef)); JniObjectReference.Dispose (ref localRef); } +#if !__ANDROID__ // https://github.com/dotnet/android/issues/11101 [Test] public void ConstructPeer_CalledMultipleTimes_ShouldNotLeakGlobalRefs () @@ -169,6 +145,7 @@ public void ConstructPeer_CalledMultipleTimes_ShouldNotLeakGlobalRefs () } } } +#endif // !__ANDROID__ [Test] 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..753acf11fef 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 @@ -90,8 +90,8 @@ public void TryFindClass_Utf8 () Assert.IsFalse (notFound.IsValid); } +#if !__ANDROID__ [Test] - [Ignore ("Frequently failing: https://github.com/dotnet/android/issues/12031")] public void TryFindClass_Utf8_DoesNotLeakGlobalRefs () { int grefsBefore = JniEnvironment.Runtime.GlobalReferenceCount; @@ -110,6 +110,7 @@ public void TryFindClass_String_DoesNotLeakGlobalRefs () Assert.AreEqual (grefsBefore, grefsAfter, "TryFindClass for non-existent classes should not leak global references"); } +#endif // !__ANDROID__ [Test] public void GetMethodID_Utf8_MatchesStringOverload () diff --git a/tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/BindingTests.cs b/tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/BindingTests.cs index 33132c9479e..c4054b74286 100644 --- a/tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/BindingTests.cs +++ b/tests/CodeGen-Binding/Xamarin.Android.JcwGen-Tests/BindingTests.cs @@ -101,16 +101,12 @@ public void JavaSideActivation () // To ensure that CallMethodFromCtor.class_ref is initialized } using (var c = Java.Lang.Class.FromType (typeof (ConstructorTest))) { - int initGref = Java.Interop.Runtime.GlobalReferenceCount; using (var j = Com.Xamarin.Android.CallMethodFromCtor.NewInstance (c)) { var instance = j.JavaCast(); Assert.AreSame (j, instance); Assert.IsTrue (instance.DefaultConstructorInvoked); Assert.IsTrue (instance.ActivationConstructorInvoked); } - int finiGref = Java.Interop.Runtime.GlobalReferenceCount; - Assert.AreEqual (initGref, finiGref, - string.Format ("Initial grefc={0}; final gref={1}; No GREFs should be lost!", initGref, finiGref)); } } @@ -391,4 +387,3 @@ public class B : Java.Lang.Object { } } } - diff --git a/tests/JniReferenceLeakTests/AndroidManifest.xml b/tests/JniReferenceLeakTests/AndroidManifest.xml new file mode 100644 index 00000000000..960b6866d49 --- /dev/null +++ b/tests/JniReferenceLeakTests/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/JniReferenceLeakTests/AssemblyInfo.cs b/tests/JniReferenceLeakTests/AssemblyInfo.cs new file mode 100644 index 00000000000..3119b2d65c5 --- /dev/null +++ b/tests/JniReferenceLeakTests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: DoNotParallelize] diff --git a/tests/JniReferenceLeakTests/GlobalReferenceTests.cs b/tests/JniReferenceLeakTests/GlobalReferenceTests.cs new file mode 100644 index 00000000000..48dc05f52d4 --- /dev/null +++ b/tests/JniReferenceLeakTests/GlobalReferenceTests.cs @@ -0,0 +1,84 @@ +using System.Runtime.CompilerServices; + +using Android.Runtime; +using Java.Interop; + +namespace JniReferenceLeakTests; + +[TestClass] +public sealed class GlobalReferenceTests +{ + [TestMethod] + public void TryFindClassUtf8DoesNotLeakGlobalReferences () + { + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { + Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out var notFound)); + Assert.IsFalse (notFound.IsValid); + }); + } + + [TestMethod] + public void TryFindClassStringDoesNotLeakGlobalReferences () + { + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { + Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist", out var notFound)); + Assert.IsFalse (notFound.IsValid); + }); + } + + [TestMethod] + public void JavaObjectArrayOperationsDoNotLeakGlobalReferences () + { + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (CreateAndDisposeObjectArray); + } + + [TestMethod] + public void JavaSideActivationDoesNotLeakGlobalReferences () + { + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { + using var instance = ActivationProbe.CreateFromJava (); + Assert.IsTrue (instance.DefaultConstructorInvoked); + }); + } + + [MethodImpl (MethodImplOptions.NoInlining)] + static void CreateAndDisposeObjectArray () + { + var value = new object (); + using var array = new JavaObjectArray (1); + array [0] = value; + } +} + +[Register (JniName)] +public class ActivationProbe : Java.Lang.Object +{ + internal const string JniName = "net/dot/jni/referenceleaktests/ActivationProbe"; + const string FactoryJniName = "net/dot/jni/referenceleaktests/ActivationProbeFactory"; + + public bool DefaultConstructorInvoked { get; } + + public ActivationProbe () + { + DefaultConstructorInvoked = true; + } + + public static unsafe ActivationProbe CreateFromJava () + { + using var type = new JniType (JniName); + using var factory = new JniType (FactoryJniName); + var create = factory.GetStaticMethod ("create", "(Ljava/lang/Class;)Ljava/lang/Object;"); + JniArgumentValue* arguments = stackalloc JniArgumentValue [1]; + arguments [0] = new JniArgumentValue (type.PeerReference); + var reference = JniEnvironment.StaticMethods.CallStaticObjectMethod (factory.PeerReference, create, arguments); + try { + if (JniEnvironment.Runtime.ValueManager.PeekPeer (reference) is not ActivationProbe instance) { + throw new InvalidOperationException ("Java-side activation did not create an ActivationProbe peer."); + } + + return instance; + } finally { + JniObjectReference.Dispose (ref reference); + } + } +} diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj new file mode 100644 index 00000000000..1054823a597 --- /dev/null +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -0,0 +1,31 @@ + + + + + $(DotNetAndroidTargetFramework) + $(AndroidMinimumDotNetApiLevel) + ..\..\bin\Test$(Configuration) + Exe + true + enable + enable + true + net.dot.jni.referenceleaktests + 1 + 1.0 + + full + + + + + + + + + + + diff --git a/tests/JniReferenceLeakTests/LocalReferenceTests.cs b/tests/JniReferenceLeakTests/LocalReferenceTests.cs new file mode 100644 index 00000000000..22917cc9b49 --- /dev/null +++ b/tests/JniReferenceLeakTests/LocalReferenceTests.cs @@ -0,0 +1,69 @@ +using Java.Interop; + +namespace JniReferenceLeakTests; + +[TestClass] +public sealed class LocalReferenceTests +{ + [TestMethod] + public void InvalidCreatedReferenceDoesNotChangeLocalReferenceCount () + { + int before = JniEnvironment.LocalReferenceCount; + var reference = new JniObjectReference (); + JniEnvironment.References.CreatedReference (reference); + Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); + } + + [TestMethod] + public void ArrayOperationsDoNotLeakLocalReferences () + { + int before = JniEnvironment.LocalReferenceCount; + for (int i = 0; i < 20; i++) { + ExerciseArray (new JavaBooleanArray (new [] { true }), false); + ExerciseArray (new JavaSByteArray (new sbyte [] { 1 }), (sbyte)2); + ExerciseArray (new JavaCharArray (new [] { 'a' }), 'b'); + ExerciseArray (new JavaInt16Array (new short [] { 1 }), (short)2); + ExerciseArray (new JavaInt32Array (new [] { 1 }), 2); + ExerciseArray (new JavaInt64Array (new long [] { 1 }), 2L); + ExerciseArray (new JavaSingleArray (new [] { 1f }), 2f); + ExerciseArray (new JavaDoubleArray (new [] { 1d }), 2d); + ExerciseArray (new JavaObjectArray (new [] { 1 }), 2); + ExerciseArray (new JavaObjectArray (new [] { new [] { 1 } }), new [] { 2 }); + ExerciseArray (new JavaObjectArray (new [] { new [] { new [] { 1 } } }), new [] { new [] { 2 } }); + ExerciseArray (new JavaObjectArray (new [] { "value" }), "replacement"); + ExerciseArray (new JavaObjectArray (new object [] { new object () }), new object ()); + + using var firstJavaArray = new JavaInt32Array (new [] { 1 }); + using var secondJavaArray = new JavaInt32Array (new [] { 2 }); + ExerciseArray (new JavaObjectArray (new [] { firstJavaArray }), secondJavaArray); + + using var firstJavaObject = new JavaObject (); + using var secondJavaObject = new JavaObject (); + ExerciseArray (new JavaObjectArray (new [] { firstJavaObject }), secondJavaObject); + } + + Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); + } + + [TestMethod] + public void JavaSideActivationDoesNotLeakLocalReferences () + { + int before = JniEnvironment.LocalReferenceCount; + for (int i = 0; i < 20; i++) { + using var instance = ActivationProbe.CreateFromJava (); + Assert.IsTrue (instance.DefaultConstructorInvoked); + } + + Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); + } + + static void ExerciseArray (JavaArray array, T replacement) + { + using (array) { + _ = array [0]; + array [0] = replacement; + Assert.AreEqual (1, array.ToArray ().Length); + } + } + +} diff --git a/tests/JniReferenceLeakTests/PeerReferenceTests.cs b/tests/JniReferenceLeakTests/PeerReferenceTests.cs new file mode 100644 index 00000000000..4bb463fecc6 --- /dev/null +++ b/tests/JniReferenceLeakTests/PeerReferenceTests.cs @@ -0,0 +1,135 @@ +using System.Runtime.CompilerServices; + +using Java.Interop; + +namespace JniReferenceLeakTests; + +[TestClass] +public sealed class PeerReferenceTests +{ + [TestMethod] + public void UnregisterFromRuntimeRemovesSurfacedPeer () + { + using (var warmup = new JavaObject ()) { + } + ReferenceTestHelpers.CollectGarbage (); + + JniObjectReference localReference; + JavaObject instance; + using (instance = new JavaObject ()) { + localReference = instance.PeerReference.NewLocalRef (); + Assert.AreEqual (JniObjectReferenceType.Global, instance.PeerReference.Type); + Assert.AreEqual (1, CountSurfacedPeer (instance)); + Assert.AreSame (instance, JniRuntime.CurrentRuntime.ValueManager.PeekPeer (localReference)); + } + + Assert.AreEqual (0, CountSurfacedPeer (instance)); + Assert.IsNull (JniRuntime.CurrentRuntime.ValueManager.PeekPeer (localReference)); + JniObjectReference.Dispose (ref localReference); + Assert.Throws (() => instance.UnregisterFromRuntime ()); + } + + [TestMethod] + public void AddPeerDoesNotRegisterDuplicates () + { + var instance = new JavaObject (); + try { + Assert.AreEqual (1, CountSurfacedPeer (instance)); + JniRuntime.CurrentRuntime.ValueManager.AddPeer (instance); + Assert.AreEqual (1, CountSurfacedPeer (instance)); + } finally { + instance.Dispose (); + } + + Assert.AreEqual (0, CountSurfacedPeer (instance)); + } + + [TestMethod] + public void RepeatedConstructPeerDoesNotLeakGlobalReferences () + { + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { + using var original = new JavaObject (); + var handle = original.PeerReference; + var peer = (IJavaPeerable)RuntimeHelpers.GetUninitializedObject (typeof (JavaObject)); + peer.SetPeerReference (new JniObjectReference (handle.Handle)); + + try { + var first = new JniObjectReference (handle.Handle); + JniRuntime.CurrentRuntime.ValueManager.ConstructPeer (peer, ref first, JniObjectReferenceOptions.Copy); + + var second = new JniObjectReference (handle.Handle); + JniRuntime.CurrentRuntime.ValueManager.ConstructPeer (peer, ref second, JniObjectReferenceOptions.Copy); + } finally { + peer.Dispose (); + } + }); + } + + [TestMethod] + public void WeakPeerIsCollectedWithoutLeakingReferences () + { + if (AppContext.TryGetSwitch ("Microsoft.Android.Runtime.RuntimeFeature.IsNativeAotRuntime", out bool isNativeAot) && isNativeAot) { + Assert.Inconclusive ("Failing in NativeAOT: https://github.com/dotnet/android/issues/11690"); + return; + } + + ReferenceTestHelpers.CollectGarbage (); + int before = JniEnvironment.Runtime.ValueManager.GetSurfacedPeers ().Count; + + WeakReference []? weakReferences = null; + Exception? threadException = null; + var thread = new Thread (() => { + try { + weakReferences = CreateWeakReferences (100); + } catch (Exception ex) { + threadException = ex; + } + }); + thread.Start (); + thread.Join (); + + if (threadException is not null) { + throw new AssertFailedException ("Worker thread failed.", threadException); + } + + ReferenceTestHelpers.CollectGarbage (); + if (weakReferences is null) { + throw new AssertFailedException ("The worker thread did not create the expected weak references."); + } + + Assert.IsTrue (weakReferences.All (reference => !reference.TryGetTarget (out _))); + + int after = JniEnvironment.Runtime.ValueManager.GetSurfacedPeers ().Count; + Assert.IsTrue ( + after - before <= 5, + $"Surfaced peer count increased after collecting 100 weak peers. Before={before}, After={after}, Delta={after - before}"); + } + + [MethodImpl (MethodImplOptions.NoInlining)] + static WeakReference [] CreateWeakReferences (int count) + { + var instances = new LeakRunnable [count]; + var references = new WeakReference [count]; + for (int i = 0; i < count; i++) { + instances [i] = new LeakRunnable (); + references [i] = new WeakReference (instances [i]); + } + + GC.KeepAlive (instances); + return references; + } + + static int CountSurfacedPeer (IJavaPeerable peer) + { + return JniRuntime.CurrentRuntime.ValueManager.GetSurfacedPeers ().Count (candidate => { + return candidate.SurfacedPeer.TryGetTarget (out var target) && ReferenceEquals (target, peer); + }); + } +} + +sealed class LeakRunnable : Java.Lang.Object, Java.Lang.IRunnable +{ + public void Run () + { + } +} diff --git a/tests/JniReferenceLeakTests/ReferenceTestHelpers.cs b/tests/JniReferenceLeakTests/ReferenceTestHelpers.cs new file mode 100644 index 00000000000..7961134a0bb --- /dev/null +++ b/tests/JniReferenceLeakTests/ReferenceTestHelpers.cs @@ -0,0 +1,36 @@ +using Java.Interop; + +namespace JniReferenceLeakTests; + +static class ReferenceTestHelpers +{ + public static void AssertNoGlobalReferenceLeak (Action action, int iterations = 100, int allowedIncrease = 5) + { + for (int i = 0; i < iterations; i++) { + action (); + } + CollectGarbage (); + + int before = JniEnvironment.Runtime.GlobalReferenceCount; + for (int i = 0; i < iterations; i++) { + action (); + } + CollectGarbage (); + int after = JniEnvironment.Runtime.GlobalReferenceCount; + + Assert.IsTrue ( + after - before <= allowedIncrease, + $"Global reference count increased by more than {allowedIncrease} after {iterations} iterations. Before={before}, After={after}, Delta={after - before}"); + } + + public static void CollectGarbage () + { + for (int i = 0; i < 3; i++) { + GC.Collect (); + GC.WaitForPendingFinalizers (); + } + + JniEnvironment.Runtime.ValueManager.CollectPeers (); + JniEnvironment.Runtime.ValueManager.WaitForGCBridgeProcessing (); + } +} diff --git a/tests/JniReferenceLeakTests/Resources/layout/leak_test_widget.axml b/tests/JniReferenceLeakTests/Resources/layout/leak_test_widget.axml new file mode 100644 index 00000000000..ab55cb00d79 --- /dev/null +++ b/tests/JniReferenceLeakTests/Resources/layout/leak_test_widget.axml @@ -0,0 +1,6 @@ + + diff --git a/tests/JniReferenceLeakTests/TestInstrumentation.cs b/tests/JniReferenceLeakTests/TestInstrumentation.cs new file mode 100644 index 00000000000..9dfdc0bf4be --- /dev/null +++ b/tests/JniReferenceLeakTests/TestInstrumentation.cs @@ -0,0 +1,110 @@ +using Android.Runtime; +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; + +namespace JniReferenceLeakTests; + +[Instrumentation (Name = "net.dot.jni.referenceleaktests.TestInstrumentation")] +public class TestInstrumentation : Instrumentation +{ + protected TestInstrumentation (IntPtr handle, JniHandleOwnership ownership) + : base (handle, ownership) + { + } + + public override void OnCreate (Bundle? arguments) + { + base.OnCreate (arguments); + Start (); + } + + public override async void OnStart () + { + base.OnStart (); + + var consumer = new ResultConsumer (this); + var bundle = new Bundle (); + try { + var writeablePath = Application.Context.GetExternalFilesDir (null)?.AbsolutePath ?? Path.GetTempPath (); + var resultsPath = Path.Combine (writeablePath, "TestResults"); + var builder = await TestApplication.CreateBuilderAsync ([ + "--results-directory", resultsPath, + "--report-trx", + ]); + builder.AddMSTest (() => [GetType ().Assembly]); + builder.AddTrxReportProvider (); + builder.TestHost.AddDataConsumer (_ => consumer); + + using ITestApplication app = await builder.BuildAsync (); + await app.RunAsync (); + + bundle.PutInt ("passed", consumer.Passed); + bundle.PutInt ("failed", consumer.Failed); + bundle.PutInt ("skipped", consumer.Skipped); + bundle.PutString ("resultsPath", consumer.TrxReportPath); + Finish (Result.Ok, bundle); + } catch (Exception ex) { + bundle.PutString ("error", ex.ToString ()); + Finish (Result.Canceled, bundle); + } + } + + class ResultConsumer (Instrumentation instrumentation) : IDataConsumer + { + int passed; + int failed; + int skipped; + + public int Passed => passed; + public int Failed => failed; + public int Skipped => skipped; + public string? TrxReportPath { get; private set; } + + public string Uid => nameof (ResultConsumer); + public string DisplayName => nameof (ResultConsumer); + public string Description => ""; + public string Version => "1.0"; + + public Type [] DataTypesConsumed => [typeof (TestNodeUpdateMessage), typeof (SessionFileArtifact)]; + + public Task IsEnabledAsync () => Task.FromResult (true); + + public Task ConsumeAsync (IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + if (value is SessionFileArtifact artifact) { + TrxReportPath = artifact.FileInfo.FullName; + return Task.CompletedTask; + } + + if (value is not TestNodeUpdateMessage { TestNode: var node }) { + return Task.CompletedTask; + } + + var state = node.Properties.SingleOrDefault (); + string? outcome = state switch { + PassedTestNodeStateProperty => "passed", + FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty => "failed", + SkippedTestNodeStateProperty => "skipped", + _ => null, + }; + if (outcome is null) { + return Task.CompletedTask; + } + + _ = outcome switch { + "passed" => Interlocked.Increment (ref passed), + "failed" => Interlocked.Increment (ref failed), + _ => Interlocked.Increment (ref skipped), + }; + + var id = node.Properties.SingleOrDefault (); + var bundle = new Bundle (); + bundle.PutString ("test", id is not null ? $"{id.Namespace}.{id.TypeName}.{id.MethodName}" : node.DisplayName); + bundle.PutString ("outcome", outcome); + instrumentation.SendStatus (0, bundle); + return Task.CompletedTask; + } + } +} diff --git a/tests/JniReferenceLeakTests/WidgetReferenceTests.cs b/tests/JniReferenceLeakTests/WidgetReferenceTests.cs new file mode 100644 index 00000000000..3e5b5aafa19 --- /dev/null +++ b/tests/JniReferenceLeakTests/WidgetReferenceTests.cs @@ -0,0 +1,39 @@ +using Android.App; +using Android.Content; +using Android.Runtime; +using Android.Util; +using Android.Views; +using Android.Widget; + +namespace JniReferenceLeakTests; + +[TestClass] +public sealed class WidgetReferenceTests +{ + [TestMethod] + public void InflateCustomViewDoesNotLeakGlobalReferences () + { + var inflater = Application.Context.GetSystemService (Context.LayoutInflaterService) as LayoutInflater; + if (inflater is null) { + throw new AssertFailedException ("Could not obtain LayoutInflater."); + } + + ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { + using var view = inflater.Inflate (Resource.Layout.leak_test_widget, null); + }); + } +} + +[Register ("net/dot/jni/referenceleaktests/LeakTestButton")] +public class LeakTestButton : Button +{ + public LeakTestButton (Context context) + : base (context) + { + } + + public LeakTestButton (Context context, IAttributeSet attributes) + : base (context, attributes) + { + } +} diff --git a/tests/JniReferenceLeakTests/global.json b/tests/JniReferenceLeakTests/global.json new file mode 100644 index 00000000000..3140116df39 --- /dev/null +++ b/tests/JniReferenceLeakTests/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/tests/JniReferenceLeakTests/java/net/dot/jni/referenceleaktests/ActivationProbeFactory.java b/tests/JniReferenceLeakTests/java/net/dot/jni/referenceleaktests/ActivationProbeFactory.java new file mode 100644 index 00000000000..57cb2d6b389 --- /dev/null +++ b/tests/JniReferenceLeakTests/java/net/dot/jni/referenceleaktests/ActivationProbeFactory.java @@ -0,0 +1,9 @@ +package net.dot.jni.referenceleaktests; + +public final class ActivationProbeFactory +{ + public static Object create (Class type) throws ReflectiveOperationException + { + return type.getDeclaredConstructor ().newInstance (); + } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Widget/CustomWidgetTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Widget/CustomWidgetTests.cs index 455905b1bd6..28ed9a36246 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Widget/CustomWidgetTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Widget/CustomWidgetTests.cs @@ -46,47 +46,6 @@ public void UpperAndLowerCaseCustomWidget_FromLibrary_ShouldNotThrowInflateExcep }, "Regression test for widgets with uppercase and lowercase namespace (bug #23880) failed."); } - // https://github.com/dotnet/android/issues/11101 - [Test] - [Ignore ("Currently failing/flaky due to unresolved custom view global ref leak: https://github.com/dotnet/android/issues/11201")] - public void InflateCustomView_ShouldNotLeakGlobalRefs () - { - var inflater = (LayoutInflater) Application.Context.GetSystemService (Context.LayoutInflaterService); - Assert.IsNotNull (inflater); - - // Warm up: inflate once to populate caches and type mappings, - // and let any background thread activity from previous tests settle. - inflater.Inflate (Resource.Layout.lowercase_custom, null); - CollectGarbage (times: 3); - - int grefBefore = Java.Interop.Runtime.GlobalReferenceCount; - - // Use a large number of inflations so that a real leak (3+ global refs - // per inflate) produces a delta far above any background noise from - // Android system services, GC bridge processing, or finalizer threads. - const int inflateCount = 100; - for (int i = 0; i < inflateCount; i++) { - inflater.Inflate (Resource.Layout.lowercase_custom, null); - } - - CollectGarbage (times: 3); - - int grefAfter = Java.Interop.Runtime.GlobalReferenceCount; - int delta = grefAfter - grefBefore; - - // A real leak would produce delta >= 300 (3 leaked refs per inflate). - // Use a generous threshold to tolerate background noise on real devices. - Assert.IsTrue (delta <= 100, - $"Global reference leak detected: {delta} extra global refs after inflating/GC'ing {inflateCount} custom views. Before={grefBefore}, After={grefAfter}"); - - static void CollectGarbage (int times) - { - for (int i = 0; i < times; i++) { - GC.Collect (); - GC.WaitForPendingFinalizers (); - } - } - } } public class CustomButton : Button diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs index 917710ab797..50908570941 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs @@ -506,52 +506,6 @@ public void ManagedToJavaTypeMapping () Assert.AreEqual (null, m, "`JnienvTest` does *not* subclass Java.Lang.Object, it should *not* be in the typemap!"); } - [Test, Category ("GCBridge")] - [Ignore ("Failing in NativeAOT: https://github.com/dotnet/android/issues/11690")] - public void DoNotLeakWeakReferences () - { - GC.Collect (); - GC.WaitForPendingFinalizers (); - - var surfaced = Runtime.GetSurfacedObjects (); - int startCount = surfaced.Count; - - Assert.IsTrue (surfaced.All (s => s.Target != null), "#1"); - - // `Runtime.GetSurfacedObjects()` is process-global: NUnit/MTP - // infrastructure (per-test TestExecutionContext, listeners, Console - // capture, etc.) creates and releases transient Java.Lang.Object - // peers around every test, so the count drifts by a few entries - // between the snapshots below. Allow a small tolerance instead of - // requiring an exact count -- a real leak would dwarf this. - const int tolerance = 10; - - WeakReference r = null; - Exception threadException = null; - var t = new Thread (() => { - try { - var c = new MyCb (); - Assert.That (Runtime.GetSurfacedObjects ().Count, - Is.EqualTo (startCount + 1).Within (tolerance), "#2"); - r = new WeakReference (c); - } catch (Exception e) { - threadException = e; - } - }); - t.Start (); - t.Join (); - if (threadException != null) - throw new Exception ("Worker thread failed", threadException); - - GC.Collect (); - GC.WaitForPendingFinalizers (); - GC.Collect (); - GC.WaitForPendingFinalizers (); - - surfaced = Runtime.GetSurfacedObjects (); - Assert.That (surfaced.Count, Is.EqualTo (startCount).Within (tolerance), "#3"); - Assert.IsTrue (surfaced.All (s => s.Target != null), "#4"); - } } [Register ("from/NewNativeThreadOne")] @@ -576,13 +530,6 @@ public override void Run () } } - class MyCb : Java.Lang.Object, Java.Lang.IRunnable { - public void Run () - { - Console.WriteLine ("MyCb.Run! JNIEnv.Handle={0}", JNIEnv.Handle.ToString ("x")); - } - } - class ContainsExportedMethods : Java.Lang.Object { public bool Constructed; From 888fef0c6c910380f356bff45651213b09599f4a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 11 Jul 2026 20:09:58 +0200 Subject: [PATCH 02/10] Remove Mono JNI leak test lane Keep the dedicated on-device JNI reference leak checks on CoreCLR only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9 --- .../automation/yaml-templates/stage-package-tests.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/build-tools/automation/yaml-templates/stage-package-tests.yaml b/build-tools/automation/yaml-templates/stage-package-tests.yaml index 246cc17a78d..dc233aaff3b 100644 --- a/build-tools/automation/yaml-templates/stage-package-tests.yaml +++ b/build-tools/automation/yaml-templates/stage-package-tests.yaml @@ -150,16 +150,6 @@ stages: artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-CoreCLR - - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml - parameters: - buildConfiguration: $(XA.Build.Configuration) - configuration: Debug - testName: JniReferenceLeakTests-Mono - project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj - extraBuildArgs: -p:UseMonoRuntime=true -p:_DisableCheckForUnsupportedMonoMobileRuntime=true - artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk - artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-Mono - - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml parameters: configuration: $(XA.Build.Configuration) From 3d45d62530dc4b1bb25b28c411b69f78081ac643 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sun, 12 Jul 2026 00:10:22 +0200 Subject: [PATCH 03/10] Pin stable MSTest package version Treat warnings as errors in the leak-test project locally so unavailable package versions fail before CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9 --- tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj index 1054823a597..76b7e3a671d 100644 --- a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -7,6 +7,7 @@ ..\..\bin\Test$(Configuration) Exe true + true enable enable true @@ -22,7 +23,7 @@ - + From 2e85d0044cfbc3f244ba254e046e587c04fe34da Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sun, 12 Jul 2026 00:19:43 +0200 Subject: [PATCH 04/10] Update MSTest dependency to 4.3.0 Track the stable TestFX release centrally and keep the leak-test project on MSTestPackageVersion with local warnings treated as errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7be7d34c-aff3-4338-b15b-6061bc7a3ba9 --- eng/Version.Details.xml | 4 ++-- eng/Versions.props | 2 +- tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 15fa92708b6..bba8767a19e 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -48,9 +48,9 @@ https://github.com/dotnet/dotnet b4b350a66ea5dcf13420747036d8b263cdf6cbef - + https://github.com/microsoft/testfx - 8609d3bf430db360f420fbe9af9b223aa372ec2b + 79f26ff23fac8769c84783fe2fa0cfc20134bcf8 diff --git a/eng/Versions.props b/eng/Versions.props index 7323919cabd..d92ef0b8f14 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,7 +15,7 @@ $(MicrosoftNETWorkloadEmscriptenCurrentManifest110100preview4PackageVersion) 11.0.100-preview.7.26355.102 0.11.5-preview.26355.102 - 4.3.0-preview.26355.11 + 4.3.0 10.0.9 11.0.0-preview.1.26104.118 diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj index 76b7e3a671d..923b8dd72fe 100644 --- a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -23,7 +23,7 @@ - + From edb60877bf555a88894dbe127d5e33fe736f769d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 13 Jul 2026 23:48:08 +0200 Subject: [PATCH 05/10] [tests] Address JNI reference leak test review feedback Addresses the Android PR Reviewer feedback on #12038: - Remove the unreachable `return;` after `Assert.Inconclusive` in PeerReferenceTests (Assert.Inconclusive always throws). - Log MTP run exceptions to logcat in TestInstrumentation so failures remain observable via `adb logcat` even if the result Bundle is missed or truncated. - Add a Release JniReferenceLeakTests lane to the nightly pipeline so full trimming (TrimMode=full, Release-only) reference leaks are exercised. The PR package-tests lane stays Debug to keep PR CI fast. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad688158-19a2-49f4-a9d3-09a9457d0dfd --- .../automation/azure-pipelines-nightly.yaml | 14 ++++++++++++++ tests/JniReferenceLeakTests/PeerReferenceTests.cs | 1 - tests/JniReferenceLeakTests/TestInstrumentation.cs | 4 ++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/build-tools/automation/azure-pipelines-nightly.yaml b/build-tools/automation/azure-pipelines-nightly.yaml index a4bb08d0281..eb424459045 100644 --- a/build-tools/automation/azure-pipelines-nightly.yaml +++ b/build-tools/automation/azure-pipelines-nightly.yaml @@ -155,6 +155,20 @@ stages: artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk artifactFolder: JniReferenceLeakTests-$(avdApiLevel) + # Also run in Release so full trimming (TrimMode=full, Release-only) is + # exercised - trimming can remove code paths that keep peers/references + # alive, exactly the regressions this app exists to catch. Kept in nightly + # (not PR package-tests) to avoid adding an emulator run to every PR. + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + buildConfiguration: $(XA.Build.Configuration) + configuration: Release + testName: JniReferenceLeakTests-Release-$(avdApiLevel) + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:UseMonoRuntime=false -p:TestAvdApiLevel=$(avdApiLevel) -p:TestAvdAbi=$(avdAbi) -p:TestAvdType=$(avdType) + artifactSource: bin/TestRelease/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactFolder: JniReferenceLeakTests-Release-$(avdApiLevel) + - template: /build-tools/automation/yaml-templates/upload-results.yaml parameters: configuration: $(XA.Build.Configuration) diff --git a/tests/JniReferenceLeakTests/PeerReferenceTests.cs b/tests/JniReferenceLeakTests/PeerReferenceTests.cs index 4bb463fecc6..743a67b00ff 100644 --- a/tests/JniReferenceLeakTests/PeerReferenceTests.cs +++ b/tests/JniReferenceLeakTests/PeerReferenceTests.cs @@ -70,7 +70,6 @@ public void WeakPeerIsCollectedWithoutLeakingReferences () { if (AppContext.TryGetSwitch ("Microsoft.Android.Runtime.RuntimeFeature.IsNativeAotRuntime", out bool isNativeAot) && isNativeAot) { Assert.Inconclusive ("Failing in NativeAOT: https://github.com/dotnet/android/issues/11690"); - return; } ReferenceTestHelpers.CollectGarbage (); diff --git a/tests/JniReferenceLeakTests/TestInstrumentation.cs b/tests/JniReferenceLeakTests/TestInstrumentation.cs index 9dfdc0bf4be..6a6a31ae352 100644 --- a/tests/JniReferenceLeakTests/TestInstrumentation.cs +++ b/tests/JniReferenceLeakTests/TestInstrumentation.cs @@ -46,6 +46,10 @@ public override async void OnStart () bundle.PutString ("resultsPath", consumer.TrxReportPath); Finish (Result.Ok, bundle); } catch (Exception ex) { + // Also surface the failure in logcat: if the harness that reads the + // result Bundle isn't running or truncates the string, this is the + // only place the stack trace survives for post-mortem debugging. + Android.Util.Log.Error (nameof (TestInstrumentation), ex.ToString ()); bundle.PutString ("error", ex.ToString ()); Finish (Result.Canceled, bundle); } From d8366e9bcbe7683d24e8aa4e7a51e710a0e18ccb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 14 Jul 2026 08:15:15 +0200 Subject: [PATCH 06/10] [tests] Add dotnet-tools NuGet feed for MSTest previews JniReferenceLeakTests is the first project to restore the Maestro-managed MSTest package ($(MSTestPackageVersion)) in the public dnceng-public pipeline. That version (currently 4.4.0-preview.26360.5, flowed from microsoft/testfx) is published to the public `dotnet-tools` feed, which was missing from NuGet.config. Restore therefore failed with: error NU1102: Unable to find package MSTest with version (>= 4.4.0-preview.26360.5) [nearest on dotnet-public: 4.3.2] Add the `dotnet-tools` feed so the darc-managed MSTest preview resolves in the public pipeline, fixing the JniReferenceLeakTests build lanes (and any other $(MSTestPackageVersion) consumer) without pinning away from the centrally-managed version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad688158-19a2-49f4-a9d3-09a9457d0dfd --- NuGet.config | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NuGet.config b/NuGet.config index 739c5cfacf7..35a89bc0b2c 100644 --- a/NuGet.config +++ b/NuGet.config @@ -13,6 +13,8 @@ + + From e254bf44aa95c148eca5aba81f8c2272747197ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:08:30 +0000 Subject: [PATCH 07/10] [tests] Pin CodeCoverage for MSTest preview restore Co-authored-by: simonrozsival <374616+simonrozsival@users.noreply.github.com> --- tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj index 923b8dd72fe..d9b9ee02c71 100644 --- a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -24,6 +24,7 @@ + From 4e2e29bd97403d31f95c28af91274b5e2fcd6231 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 15 Jul 2026 15:13:15 +0200 Subject: [PATCH 08/10] [tests] Use test-tools NuGet feed for MSTest previews Switch the MSTest preview feed from dotnet-tools to test-tools to match the feed convention already used in the repo for microsoft/testfx previews. Both feeds host the package; test-tools is the established one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72dc2338-ad30-4719-8d9e-24893af0683f --- NuGet.config | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NuGet.config b/NuGet.config index 35a89bc0b2c..9829ff35956 100644 --- a/NuGet.config +++ b/NuGet.config @@ -13,8 +13,8 @@ - - + + From 415d03bb932dde7cee3a536e0b84a5aaa2af1e8c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 15 Jul 2026 15:24:36 +0200 Subject: [PATCH 09/10] [tests] Convert JniReferenceLeakTests to NUnit + shared TestRunner.Core Reuse the shared Xamarin.Android.UnitTests.TestInstrumentation runner (tests/TestRunner.Core) instead of a bespoke MSTest/MTP instrumentation, matching the sibling on-device NUnit apps (e.g. JcwGen-Tests). The runner executes synchronously (NumberOfTestWorkers=0), so serial execution is preserved without [assembly: DoNotParallelize], and process isolation is unchanged (still a dedicated app run via apk-instrumentation.yaml). This lets us drop the MSTest preview PackageReference (and the pinned Microsoft.Testing.Extensions.CodeCoverage), which in turn removes the need for the extra NuGet feed entirely - NuGet.config now matches main. - TestInstrumentation.cs: 15-line subclass overriding GetTestAssemblies. - csproj: NUnit + ProjectReference to TestRunner.Core; IsTestingPlatform- Application/GenerateTestingPlatformEntryPoint/AndroidInstrumentation to match the shared MTP-on-device wiring; global using NUnit.Framework. - Tests: [TestClass]/[TestMethod] -> [TestFixture]/[Test]; AssertFailedException -> AssertionException (classic Assert.* unchanged). - Remove AssemblyInfo.cs (MSTest DoNotParallelize) and the test-tools feed. - Update test-catalog.md description (MSTest/MTP -> NUnit/MTP). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72dc2338-ad30-4719-8d9e-24893af0683f --- .../skills/tests/references/test-catalog.md | 2 +- NuGet.config | 2 - tests/JniReferenceLeakTests/AssemblyInfo.cs | 3 - .../GlobalReferenceTests.cs | 10 +- .../JniReferenceLeakTests.csproj | 16 ++- .../LocalReferenceTests.cs | 8 +- .../PeerReferenceTests.cs | 14 +-- .../TestInstrumentation.cs | 109 ++---------------- .../WidgetReferenceTests.cs | 6 +- 9 files changed, 39 insertions(+), 131 deletions(-) delete mode 100644 tests/JniReferenceLeakTests/AssemblyInfo.cs diff --git a/.github/skills/tests/references/test-catalog.md b/.github/skills/tests/references/test-catalog.md index 441d74dc117..76826bf5a36 100644 --- a/.github/skills/tests/references/test-catalog.md +++ b/.github/skills/tests/references/test-catalog.md @@ -123,7 +123,7 @@ Device: **Yes** | Test Area | Project | Notes | |-----------|---------|-------| | **runtime** (all) | `tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj` | Core runtime tests | -| **JNI reference leaks** | `tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj` | Dedicated serial MSTest/MTP app for JNI reference and peer leak checks | +| **JNI reference leaks** | `tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj` | Dedicated serial NUnit/MTP app for JNI reference and peer leak checks | | **networking** | Same project — tests in `Xamarin.Android.Net/` and `System.Net/` | `AndroidMessageHandlerTests`, `AndroidMessageHandlerIntegrationTests` | | **java interop (on-device)** | Same project — tests in `Java.Interop/` | `JnienvTest`, `JavaListTest` | | **android app** | Same project — tests in `Android.App/` | `Application`, `Activity` tests | diff --git a/NuGet.config b/NuGet.config index 9829ff35956..739c5cfacf7 100644 --- a/NuGet.config +++ b/NuGet.config @@ -13,8 +13,6 @@ - - diff --git a/tests/JniReferenceLeakTests/AssemblyInfo.cs b/tests/JniReferenceLeakTests/AssemblyInfo.cs deleted file mode 100644 index 3119b2d65c5..00000000000 --- a/tests/JniReferenceLeakTests/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -[assembly: DoNotParallelize] diff --git a/tests/JniReferenceLeakTests/GlobalReferenceTests.cs b/tests/JniReferenceLeakTests/GlobalReferenceTests.cs index 48dc05f52d4..b1a1ac49668 100644 --- a/tests/JniReferenceLeakTests/GlobalReferenceTests.cs +++ b/tests/JniReferenceLeakTests/GlobalReferenceTests.cs @@ -5,10 +5,10 @@ namespace JniReferenceLeakTests; -[TestClass] +[TestFixture] public sealed class GlobalReferenceTests { - [TestMethod] + [Test] public void TryFindClassUtf8DoesNotLeakGlobalReferences () { ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { @@ -17,7 +17,7 @@ public void TryFindClassUtf8DoesNotLeakGlobalReferences () }); } - [TestMethod] + [Test] public void TryFindClassStringDoesNotLeakGlobalReferences () { ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { @@ -26,13 +26,13 @@ public void TryFindClassStringDoesNotLeakGlobalReferences () }); } - [TestMethod] + [Test] public void JavaObjectArrayOperationsDoNotLeakGlobalReferences () { ReferenceTestHelpers.AssertNoGlobalReferenceLeak (CreateAndDisposeObjectArray); } - [TestMethod] + [Test] public void JavaSideActivationDoesNotLeakGlobalReferences () { ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj index d9b9ee02c71..8bbbe83769d 100644 --- a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -10,7 +10,6 @@ true enable enable - true net.dot.jni.referenceleaktests 1 1.0 @@ -23,11 +22,20 @@ - - + - + + + + + + + + net.dot.jni.referenceleaktests.TestInstrumentation + true + false + diff --git a/tests/JniReferenceLeakTests/LocalReferenceTests.cs b/tests/JniReferenceLeakTests/LocalReferenceTests.cs index 22917cc9b49..b61e134f2ee 100644 --- a/tests/JniReferenceLeakTests/LocalReferenceTests.cs +++ b/tests/JniReferenceLeakTests/LocalReferenceTests.cs @@ -2,10 +2,10 @@ namespace JniReferenceLeakTests; -[TestClass] +[TestFixture] public sealed class LocalReferenceTests { - [TestMethod] + [Test] public void InvalidCreatedReferenceDoesNotChangeLocalReferenceCount () { int before = JniEnvironment.LocalReferenceCount; @@ -14,7 +14,7 @@ public void InvalidCreatedReferenceDoesNotChangeLocalReferenceCount () Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); } - [TestMethod] + [Test] public void ArrayOperationsDoNotLeakLocalReferences () { int before = JniEnvironment.LocalReferenceCount; @@ -45,7 +45,7 @@ public void ArrayOperationsDoNotLeakLocalReferences () Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); } - [TestMethod] + [Test] public void JavaSideActivationDoesNotLeakLocalReferences () { int before = JniEnvironment.LocalReferenceCount; diff --git a/tests/JniReferenceLeakTests/PeerReferenceTests.cs b/tests/JniReferenceLeakTests/PeerReferenceTests.cs index 743a67b00ff..8adf4c1085f 100644 --- a/tests/JniReferenceLeakTests/PeerReferenceTests.cs +++ b/tests/JniReferenceLeakTests/PeerReferenceTests.cs @@ -4,10 +4,10 @@ namespace JniReferenceLeakTests; -[TestClass] +[TestFixture] public sealed class PeerReferenceTests { - [TestMethod] + [Test] public void UnregisterFromRuntimeRemovesSurfacedPeer () { using (var warmup = new JavaObject ()) { @@ -29,7 +29,7 @@ public void UnregisterFromRuntimeRemovesSurfacedPeer () Assert.Throws (() => instance.UnregisterFromRuntime ()); } - [TestMethod] + [Test] public void AddPeerDoesNotRegisterDuplicates () { var instance = new JavaObject (); @@ -44,7 +44,7 @@ public void AddPeerDoesNotRegisterDuplicates () Assert.AreEqual (0, CountSurfacedPeer (instance)); } - [TestMethod] + [Test] public void RepeatedConstructPeerDoesNotLeakGlobalReferences () { ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { @@ -65,7 +65,7 @@ public void RepeatedConstructPeerDoesNotLeakGlobalReferences () }); } - [TestMethod] + [Test] public void WeakPeerIsCollectedWithoutLeakingReferences () { if (AppContext.TryGetSwitch ("Microsoft.Android.Runtime.RuntimeFeature.IsNativeAotRuntime", out bool isNativeAot) && isNativeAot) { @@ -88,12 +88,12 @@ public void WeakPeerIsCollectedWithoutLeakingReferences () thread.Join (); if (threadException is not null) { - throw new AssertFailedException ("Worker thread failed.", threadException); + throw new AssertionException ("Worker thread failed.", threadException); } ReferenceTestHelpers.CollectGarbage (); if (weakReferences is null) { - throw new AssertFailedException ("The worker thread did not create the expected weak references."); + throw new AssertionException ("The worker thread did not create the expected weak references."); } Assert.IsTrue (weakReferences.All (reference => !reference.TryGetTarget (out _))); diff --git a/tests/JniReferenceLeakTests/TestInstrumentation.cs b/tests/JniReferenceLeakTests/TestInstrumentation.cs index 6a6a31ae352..b09eefb2a94 100644 --- a/tests/JniReferenceLeakTests/TestInstrumentation.cs +++ b/tests/JniReferenceLeakTests/TestInstrumentation.cs @@ -1,114 +1,19 @@ +using System.Reflection; using Android.Runtime; -using Microsoft.Testing.Extensions; -using Microsoft.Testing.Platform.Builder; -using Microsoft.Testing.Platform.Extensions; -using Microsoft.Testing.Platform.Extensions.Messages; +using Xamarin.Android.UnitTests; namespace JniReferenceLeakTests; [Instrumentation (Name = "net.dot.jni.referenceleaktests.TestInstrumentation")] -public class TestInstrumentation : Instrumentation +public class TestInstrumentation : Xamarin.Android.UnitTests.TestInstrumentation { - protected TestInstrumentation (IntPtr handle, JniHandleOwnership ownership) - : base (handle, ownership) + public TestInstrumentation (IntPtr handle, JniHandleOwnership transfer) + : base (handle, transfer) { } - public override void OnCreate (Bundle? arguments) + protected override IEnumerable GetTestAssemblies () { - base.OnCreate (arguments); - Start (); - } - - public override async void OnStart () - { - base.OnStart (); - - var consumer = new ResultConsumer (this); - var bundle = new Bundle (); - try { - var writeablePath = Application.Context.GetExternalFilesDir (null)?.AbsolutePath ?? Path.GetTempPath (); - var resultsPath = Path.Combine (writeablePath, "TestResults"); - var builder = await TestApplication.CreateBuilderAsync ([ - "--results-directory", resultsPath, - "--report-trx", - ]); - builder.AddMSTest (() => [GetType ().Assembly]); - builder.AddTrxReportProvider (); - builder.TestHost.AddDataConsumer (_ => consumer); - - using ITestApplication app = await builder.BuildAsync (); - await app.RunAsync (); - - bundle.PutInt ("passed", consumer.Passed); - bundle.PutInt ("failed", consumer.Failed); - bundle.PutInt ("skipped", consumer.Skipped); - bundle.PutString ("resultsPath", consumer.TrxReportPath); - Finish (Result.Ok, bundle); - } catch (Exception ex) { - // Also surface the failure in logcat: if the harness that reads the - // result Bundle isn't running or truncates the string, this is the - // only place the stack trace survives for post-mortem debugging. - Android.Util.Log.Error (nameof (TestInstrumentation), ex.ToString ()); - bundle.PutString ("error", ex.ToString ()); - Finish (Result.Canceled, bundle); - } - } - - class ResultConsumer (Instrumentation instrumentation) : IDataConsumer - { - int passed; - int failed; - int skipped; - - public int Passed => passed; - public int Failed => failed; - public int Skipped => skipped; - public string? TrxReportPath { get; private set; } - - public string Uid => nameof (ResultConsumer); - public string DisplayName => nameof (ResultConsumer); - public string Description => ""; - public string Version => "1.0"; - - public Type [] DataTypesConsumed => [typeof (TestNodeUpdateMessage), typeof (SessionFileArtifact)]; - - public Task IsEnabledAsync () => Task.FromResult (true); - - public Task ConsumeAsync (IDataProducer dataProducer, IData value, CancellationToken cancellationToken) - { - if (value is SessionFileArtifact artifact) { - TrxReportPath = artifact.FileInfo.FullName; - return Task.CompletedTask; - } - - if (value is not TestNodeUpdateMessage { TestNode: var node }) { - return Task.CompletedTask; - } - - var state = node.Properties.SingleOrDefault (); - string? outcome = state switch { - PassedTestNodeStateProperty => "passed", - FailedTestNodeStateProperty or ErrorTestNodeStateProperty or TimeoutTestNodeStateProperty => "failed", - SkippedTestNodeStateProperty => "skipped", - _ => null, - }; - if (outcome is null) { - return Task.CompletedTask; - } - - _ = outcome switch { - "passed" => Interlocked.Increment (ref passed), - "failed" => Interlocked.Increment (ref failed), - _ => Interlocked.Increment (ref skipped), - }; - - var id = node.Properties.SingleOrDefault (); - var bundle = new Bundle (); - bundle.PutString ("test", id is not null ? $"{id.Namespace}.{id.TypeName}.{id.MethodName}" : node.DisplayName); - bundle.PutString ("outcome", outcome); - instrumentation.SendStatus (0, bundle); - return Task.CompletedTask; - } + return [Assembly.GetExecutingAssembly ()]; } } diff --git a/tests/JniReferenceLeakTests/WidgetReferenceTests.cs b/tests/JniReferenceLeakTests/WidgetReferenceTests.cs index 3e5b5aafa19..34326ecb01b 100644 --- a/tests/JniReferenceLeakTests/WidgetReferenceTests.cs +++ b/tests/JniReferenceLeakTests/WidgetReferenceTests.cs @@ -7,15 +7,15 @@ namespace JniReferenceLeakTests; -[TestClass] +[TestFixture] public sealed class WidgetReferenceTests { - [TestMethod] + [Test] public void InflateCustomViewDoesNotLeakGlobalReferences () { var inflater = Application.Context.GetSystemService (Context.LayoutInflaterService) as LayoutInflater; if (inflater is null) { - throw new AssertFailedException ("Could not obtain LayoutInflater."); + throw new AssertionException ("Could not obtain LayoutInflater."); } ReferenceTestHelpers.AssertNoGlobalReferenceLeak (() => { From e57f65d7abe0c9974becb781464c3fce476f4daf Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Jul 2026 14:39:24 +0200 Subject: [PATCH 10/10] [tests] Restore JNI leak coverage across runtimes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac393634-8640-4ed6-8094-0331551307e4 --- .../automation/azure-pipelines-nightly.yaml | 4 +- .../yaml-templates/stage-package-tests.yaml | 20 ++- src/Mono.Android/Android.Runtime/JNIEnv.cs | 2 +- .../JniReferenceLeakTests.csproj | 15 ++ .../LocalReferenceTests.cs | 149 +++++++++++++++--- tests/JniReferenceLeakTests/NativeAOT.rd.xml | 21 +++ 6 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 tests/JniReferenceLeakTests/NativeAOT.rd.xml diff --git a/build-tools/automation/azure-pipelines-nightly.yaml b/build-tools/automation/azure-pipelines-nightly.yaml index eb424459045..7683ca3cc95 100644 --- a/build-tools/automation/azure-pipelines-nightly.yaml +++ b/build-tools/automation/azure-pipelines-nightly.yaml @@ -152,7 +152,7 @@ stages: testName: JniReferenceLeakTests-$(avdApiLevel) project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj extraBuildArgs: -p:UseMonoRuntime=false -p:TestAvdApiLevel=$(avdApiLevel) -p:TestAvdAbi=$(avdAbi) -p:TestAvdType=$(avdType) - artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/net.dot.jni.referenceleaktests-Signed.apk artifactFolder: JniReferenceLeakTests-$(avdApiLevel) # Also run in Release so full trimming (TrimMode=full, Release-only) is @@ -166,7 +166,7 @@ stages: testName: JniReferenceLeakTests-Release-$(avdApiLevel) project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj extraBuildArgs: -p:UseMonoRuntime=false -p:TestAvdApiLevel=$(avdApiLevel) -p:TestAvdAbi=$(avdAbi) -p:TestAvdType=$(avdType) - artifactSource: bin/TestRelease/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactSource: bin/TestRelease/$(DotNetTargetFramework)-android/net.dot.jni.referenceleaktests-Signed.apk artifactFolder: JniReferenceLeakTests-Release-$(avdApiLevel) - template: /build-tools/automation/yaml-templates/upload-results.yaml diff --git a/build-tools/automation/yaml-templates/stage-package-tests.yaml b/build-tools/automation/yaml-templates/stage-package-tests.yaml index dc233aaff3b..f207d46724d 100644 --- a/build-tools/automation/yaml-templates/stage-package-tests.yaml +++ b/build-tools/automation/yaml-templates/stage-package-tests.yaml @@ -147,9 +147,27 @@ stages: testName: JniReferenceLeakTests-CoreCLR project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj extraBuildArgs: -p:UseMonoRuntime=false - artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/JniReferenceLeakTests-Signed.apk + artifactSource: bin/TestDebug/$(DotNetTargetFramework)-android/net.dot.jni.referenceleaktests-Signed.apk artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-CoreCLR + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + configuration: $(XA.Build.Configuration) + testName: JniReferenceLeakTests-CoreCLRTrimmable + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:_AndroidTypeMapImplementation=trimmable -p:UseMonoRuntime=false + artifactSource: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)-android/net.dot.jni.referenceleaktests-Signed.apk + artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-CoreCLRTrimmable + + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + configuration: $(XA.Build.Configuration) + testName: JniReferenceLeakTests-NativeAOT + project: tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj + extraBuildArgs: -p:PublishAot=true + artifactSource: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)-android/net.dot.jni.referenceleaktests-Signed.apk + artifactFolder: $(DotNetTargetFramework)-JniReferenceLeakTests-NativeAOT + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml parameters: configuration: $(XA.Build.Configuration) diff --git a/src/Mono.Android/Android.Runtime/JNIEnv.cs b/src/Mono.Android/Android.Runtime/JNIEnv.cs index b8a4d73914e..18466fde9e6 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnv.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnv.cs @@ -384,7 +384,7 @@ public static void EnsureLocalCapacity (int capacity) internal static void DeleteRef (IntPtr handle, JniHandleOwnership transfer) { - switch (transfer) { + switch (transfer & ~JniHandleOwnership.DoNotRegister) { case JniHandleOwnership.DoNotTransfer: break; case JniHandleOwnership.TransferLocalRef: diff --git a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj index 8bbbe83769d..7ac6408dc3e 100644 --- a/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj +++ b/tests/JniReferenceLeakTests/JniReferenceLeakTests.csproj @@ -8,6 +8,8 @@ Exe true true + + false enable enable net.dot.jni.referenceleaktests @@ -29,6 +31,19 @@ + + + + + + true + false + + + + + + diff --git a/tests/JniReferenceLeakTests/LocalReferenceTests.cs b/tests/JniReferenceLeakTests/LocalReferenceTests.cs index b61e134f2ee..e1c6467a021 100644 --- a/tests/JniReferenceLeakTests/LocalReferenceTests.cs +++ b/tests/JniReferenceLeakTests/LocalReferenceTests.cs @@ -5,6 +5,10 @@ namespace JniReferenceLeakTests; [TestFixture] public sealed class LocalReferenceTests { + // JNINativeInterface function indexes are fixed by the JNI specification. + const int DeleteLocalReferenceFunctionIndex = 23; + const int NewLocalReferenceFunctionIndex = 25; + [Test] public void InvalidCreatedReferenceDoesNotChangeLocalReferenceCount () { @@ -14,32 +18,51 @@ public void InvalidCreatedReferenceDoesNotChangeLocalReferenceCount () Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); } + [Test] + public void CreatedLocalReferenceUpdatesLocalReferenceCount () + { + using var type = new JniType ("java/lang/Object"); + int before = JniEnvironment.LocalReferenceCount; + IntPtr handle = NewReference ( + NewLocalReferenceFunctionIndex, + JniEnvironment.EnvironmentPointer, + type.PeerReference.Handle); + var reference = new JniObjectReference (handle, JniObjectReferenceType.Local); + bool registered = false; + + try { + Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); + JniEnvironment.References.CreatedReference (reference); + registered = true; + Assert.AreEqual (before + 1, JniEnvironment.LocalReferenceCount); + } finally { + if (registered) { + JniObjectReference.Dispose (ref reference); + } else { + DeleteReference ( + DeleteLocalReferenceFunctionIndex, + JniEnvironment.EnvironmentPointer, + reference.Handle); + } + } + + Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); + } + + [Test] + public void CreatedGlobalReferenceIsRejected () + { + using var type = new JniType ("java/lang/Object"); + var reference = new JniObjectReference (type.PeerReference.Handle, JniObjectReferenceType.Global); + Assert.Throws (() => JniEnvironment.References.CreatedReference (reference)); + } + [Test] public void ArrayOperationsDoNotLeakLocalReferences () { int before = JniEnvironment.LocalReferenceCount; for (int i = 0; i < 20; i++) { - ExerciseArray (new JavaBooleanArray (new [] { true }), false); - ExerciseArray (new JavaSByteArray (new sbyte [] { 1 }), (sbyte)2); - ExerciseArray (new JavaCharArray (new [] { 'a' }), 'b'); - ExerciseArray (new JavaInt16Array (new short [] { 1 }), (short)2); - ExerciseArray (new JavaInt32Array (new [] { 1 }), 2); - ExerciseArray (new JavaInt64Array (new long [] { 1 }), 2L); - ExerciseArray (new JavaSingleArray (new [] { 1f }), 2f); - ExerciseArray (new JavaDoubleArray (new [] { 1d }), 2d); - ExerciseArray (new JavaObjectArray (new [] { 1 }), 2); - ExerciseArray (new JavaObjectArray (new [] { new [] { 1 } }), new [] { 2 }); - ExerciseArray (new JavaObjectArray (new [] { new [] { new [] { 1 } } }), new [] { new [] { 2 } }); - ExerciseArray (new JavaObjectArray (new [] { "value" }), "replacement"); - ExerciseArray (new JavaObjectArray (new object [] { new object () }), new object ()); - - using var firstJavaArray = new JavaInt32Array (new [] { 1 }); - using var secondJavaArray = new JavaInt32Array (new [] { 2 }); - ExerciseArray (new JavaObjectArray (new [] { firstJavaArray }), secondJavaArray); - - using var firstJavaObject = new JavaObject (); - using var secondJavaObject = new JavaObject (); - ExerciseArray (new JavaObjectArray (new [] { firstJavaObject }), secondJavaObject); + ExerciseArrays (); } Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); @@ -57,13 +80,87 @@ public void JavaSideActivationDoesNotLeakLocalReferences () Assert.AreEqual (before, JniEnvironment.LocalReferenceCount); } - static void ExerciseArray (JavaArray array, T replacement) + static void ExerciseArrays () { - using (array) { - _ = array [0]; - array [0] = replacement; - Assert.AreEqual (1, array.ToArray ().Length); + ExerciseArray (values => new JavaBooleanArray (values), true, false); + ExerciseArray (values => new JavaSByteArray (values), (sbyte)1, (sbyte)2); + ExerciseArray (values => new JavaCharArray (values), 'a', 'b'); + ExerciseArray (values => new JavaInt16Array (values), (short)1, (short)2); + ExerciseArray (values => new JavaInt32Array (values), 1, 2); + ExerciseArray (values => new JavaInt64Array (values), 1L, 2L); + ExerciseArray (values => new JavaSingleArray (values), 1f, 2f); + ExerciseArray (values => new JavaDoubleArray (values), 1d, 2d); + ExerciseArray (values => new JavaObjectArray (values), 1, 2); + ExerciseArray (values => new JavaObjectArray (values), new [] { 1 }, new [] { 2 }); + ExerciseArray (values => new JavaObjectArray (values), new [] { new [] { 1 } }, new [] { new [] { 2 } }); + ExerciseArray (values => new JavaObjectArray (values), "value", "replacement"); + ExerciseArray (values => new JavaObjectArray (values), new object (), new object ()); + + using var firstJavaArray = new JavaInt32Array (new [] { 1 }); + using var secondJavaArray = new JavaInt32Array (new [] { 2 }); + ExerciseArray (values => new JavaObjectArray (values), firstJavaArray, secondJavaArray); + + using var firstJavaObject = new JavaObject (); + using var secondJavaObject = new JavaObject (); + ExerciseArray (values => new JavaObjectArray (values), firstJavaObject, secondJavaObject); + } + + static void ExerciseArray (Func> createArray, T first, T second) + { + using (var empty = createArray ([])) { + var emptyCollection = (ICollection)empty; + var emptyList = (IList)empty; + Assert.AreEqual (0, emptyCollection.Count); + Assert.IsFalse (emptyCollection.Contains (first)); + Assert.AreEqual (-1, emptyList.IndexOf (first)); + Assert.Throws (() => emptyCollection.Add (first)); + Assert.Throws (() => emptyCollection.Remove (first)); + Assert.Throws (() => emptyList.Insert (-1, first)); + Assert.Throws (() => emptyList.Insert (1, first)); + Assert.Throws (() => emptyList.Insert (0, first)); + Assert.Throws (() => emptyList.RemoveAt (-1)); + Assert.Throws (() => emptyList.RemoveAt (0)); + } + + using (var array = createArray ([first, second])) { + var collection = (ICollection)array; + var list = (IList)array; + Assert.AreEqual (2, collection.Count); + Assert.IsTrue (collection.Contains (first)); + Assert.IsTrue (collection.Contains (second)); + Assert.AreEqual (0, list.IndexOf (first)); + Assert.AreEqual (1, list.IndexOf (second)); + + _ = list [0]; + list [0] = second; + Assert.Catch (() => _ = list [-1]); + Assert.Catch (() => _ = list [list.Count]); + Assert.Catch (() => list [-1] = first); + Assert.Catch (() => list [list.Count] = first); + + var destination = new T [collection.Count + 2]; + collection.CopyTo (destination, 1); + Assert.Throws (() => collection.CopyTo (new T [collection.Count], -1)); + Assert.Throws (() => collection.CopyTo (new T [collection.Count], 1)); + Assert.Throws (() => collection.CopyTo ([], 0)); + Assert.AreEqual (collection.Count, array.ToArray ().Length); + + collection.Clear (); + Assert.AreEqual (2, collection.Count); } } + static unsafe IntPtr NewReference (int functionIndex, IntPtr environment, IntPtr reference) + { + var functions = *(IntPtr**)environment; + var function = (delegate* unmanaged)functions [functionIndex]; + return function (environment, reference); + } + + static unsafe void DeleteReference (int functionIndex, IntPtr environment, IntPtr reference) + { + var functions = *(IntPtr**)environment; + var function = (delegate* unmanaged)functions [functionIndex]; + function (environment, reference); + } } diff --git a/tests/JniReferenceLeakTests/NativeAOT.rd.xml b/tests/JniReferenceLeakTests/NativeAOT.rd.xml new file mode 100644 index 00000000000..5419010471c --- /dev/null +++ b/tests/JniReferenceLeakTests/NativeAOT.rd.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +