Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions build-tools/automation/yaml-templates/stage-package-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#nullable enable

using System;
using System.Collections.Generic;

using Java.Interop;

Expand All @@ -11,6 +12,9 @@ namespace Java.InteropTests
[TestFixture]
public class JniTypeUtf8Test : JavaVMFixture {

const string JniReferenceLeakCategory = "JniReferenceLeak";
const int LeakCheckIterations = 100;

[Test]
public unsafe void Sanity_Utf8 ()
{
Expand Down Expand Up @@ -91,24 +95,77 @@ 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");
AssertNoSustainedGlobalReferenceGrowth (() => {
Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist"u8, out var notFound));
Assert.IsFalse (notFound.IsValid);
});
}

[Test]
[Category (JniReferenceLeakCategory)]
public void TryFindClass_String_DoesNotLeakGlobalRefs ()
{
AssertNoSustainedGlobalReferenceGrowth (() => {
Assert.IsFalse (JniEnvironment.Types.TryFindClass ("does/not/Exist", out var notFound));
Assert.IsFalse (notFound.IsValid);
});
}

[Test]
[Category (JniReferenceLeakCategory)]
public void AssertNoSustainedGlobalReferenceGrowth_DetectsRetainedGlobalReference ()
{
var objectClass = JniEnvironment.Types.FindClass ("java/lang/Object");
var retainedReferences = new List<JniObjectReference> ();
try {
Assert.Throws<AssertionException> (() => AssertNoSustainedGlobalReferenceGrowth (() => {
retainedReferences.Add (objectClass.NewGlobalRef ());
}));
} finally {
foreach (var retainedReference in retainedReferences) {
var reference = retainedReference;
JniObjectReference.Dispose (ref reference);
}
JniObjectReference.Dispose (ref objectClass);
}
}

static void AssertNoSustainedGlobalReferenceGrowth (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 ();
}
CollectGarbage ();
int grefsAfter = JniEnvironment.Runtime.GlobalReferenceCount;
Assert.AreEqual (grefsBefore, grefsAfter,
"TryFindClass for non-existent classes should not leak global references");

Assert.LessOrEqual (grefsAfter, grefsBefore,
$"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 ();
}
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -54,6 +56,11 @@ protected override IEnumerable<string>? ExcludedCategories {
categories.Add ("NetworkInterfaces");
}

// Process-wide reference counts are only stable in the dedicated filtered run.
if (!IsOnlyIncludedCategory (JniReferenceLeakCategory)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Testing — This isolation check considers only IncludeCategories from runtimeconfig, but BuildNUnitFilter() also merges categories from the instrumentation include argument. If Android MTP starts forwarding its execution filter (the limitation discussed above), the effective run could include JniReferenceLeak plus unrelated categories while this guard still removes the exclusion. Could the isolation decision be based on the final merged include set, or on a dedicated isolation switch, so future filter plumbing cannot silently reintroduce the process-wide-count flake?

Rule: Deterministic test isolation

categories.Add (JniReferenceLeakCategory);
}

return categories.Count > 0 ? categories : null;
}
}
Expand All @@ -66,13 +73,25 @@ protected override IEnumerable<string>? 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;

Expand Down