From 5ed818ff4d41cb22e027aaf497f6dd2b79ebd9cd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Jul 2026 13:37:59 -0500 Subject: [PATCH 1/8] [tests] make `GetObjectArray` peer mismatch self-diagnosing `Android.RuntimeTests.JnienvArrayMarshaling.GetObjectArray` is flaky on CoreCLR (~17 failures / 216 executions), and only on CoreCLR-backed configurations. `Assert.AreSame()` gives us nothing to work with: Expected existing Context peer, got Android.AppTests.App. Expected: same as crc64f5183e500568449d.App@8e34fe2 But was: crc64f5183e500568449d.App@8e34fe2 Both operands render identically, because `Object.ToString()` forwards to the Java `toString()` and both managed peers wrap the *same* Java instance. So the message tells us only that the peers differ -- not why. `JniValueManager.GetPeer()` peeks the registry before creating a new peer, so a mismatch means `PeekPeer()` missed an entry that `Application.Context` still holds strongly. Replace the assert with a failure-only diagnostic that distinguishes the possible causes: * managed hash codes of both peers, so they can be told apart * `JniIdentityHashCode`, `PeerReference` and `JniManagedPeerState` * whether `Application.Context` still returns the captured instance * `IsSameObject()` on the two peer references * what `PeekPeer()` returns now, and whether it is the expected peer, the unexpected one, or nothing at all * every surfaced peer sharing the identity hash code That separates "registry entry was replaced by a second `AddPeer()`" from "entry was evicted" from "weak reference was cleared", which is the information needed to find the root cause. Verified on device by temporarily forcing the failure branch; the diagnostic runs to completion without throwing, and reports the expected all-`True` values in the healthy case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../Android.Runtime/JnienvArrayMarshaling.cs | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index 1d16aa30af0..c0aee549029 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; using Android.App; using Android.Content; @@ -7,6 +9,8 @@ using Android.Runtime; using Android.Views; +using Java.Interop; + using NUnit.Framework; using Java.LangTests; @@ -330,21 +334,77 @@ public void GetObjectArray () object[] data = JNIEnv.GetObjectArray (byteArray.Handle, new[]{typeof (byte), typeof (byte), typeof (byte)}); AssertArrays ("GetObjectArray", data, (object) 1, (object) 2, (object) 3); } + var context = Application.Context; using (var objectArray = new Java.Lang.Object ( JNIEnv.NewArray ( - new Java.Lang.Object[]{Application.Context, 42L, "string"}, + new Java.Lang.Object[]{context, 42L, "string"}, typeof (Java.Lang.Object)), JniHandleOwnership.TransferLocalRef)) { object[] values = JNIEnv.GetObjectArray (objectArray.Handle, new[]{typeof(Context), typeof (int)}); Assert.AreEqual (3, values.Length); - Assert.AreSame (Application.Context, values [0], $"Expected existing Context peer, got {values [0]?.GetType ()}."); + // Deliberately not `Assert.AreSame()`: this intermittently fails on CoreCLR + // (dotnet/android#10973), and both peers render identically, so the default + // message tells us nothing. Only build the diagnostic when it actually fails. + if (!ReferenceEquals (context, values [0])) + Assert.Fail (DescribeContextPeerMismatch (context, values [0])); Assert.IsInstanceOf (values [1], $"Expected converted Int32, got {values [1]?.GetType ()}: {values [1]}."); Assert.AreEqual (42, (int)values [1]); Assert.AreEqual ("string", values [2].ToString ()); } } + // `GetObjectArray()` should hand back the *same* managed peer that `Application.Context` + // holds, because `JniValueManager.GetPeer()` peeks the registry before creating a new peer. + // When that fails, the Java instance is the same but the managed peers differ, so dump + // enough of the registry to tell *why* they disagree: whether the entry was replaced by a + // second `AddPeer()`, evicted entirely, or its weak reference was cleared. + static string DescribeContextPeerMismatch (Context expected, object actual) + { + var sb = new StringBuilder (); + sb.AppendLine ("Expected `Application.Context` and `GetObjectArray ()[0]` to be the same managed peer."); + AppendPeer (sb, "expected (Application.Context)", expected); + AppendPeer (sb, "actual (GetObjectArray ()[0])", actual); + + // Did `Application.Context` itself change after we captured it? + sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); + + if (actual is IJavaPeerable actualPeer && expected.PeerReference.IsValid && actualPeer.PeerReference.IsValid) + sb.AppendLine ($" IsSameObject (expected, actual): {JniEnvironment.Types.IsSameObject (expected.PeerReference, actualPeer.PeerReference)}"); + + var manager = JniRuntime.CurrentRuntime.ValueManager; + + // If this returns `actual`, the registry entry was replaced out from under + // `Application.Context`; if it returns null, the entry was evicted or collected. + var peeked = expected.PeerReference.IsValid ? manager.PeekPeer (expected.PeerReference) : null; + sb.AppendLine ($" PeekPeer (expected.PeerReference) => {Describe (peeked)}"); + sb.AppendLine ($" is expected: {ReferenceEquals (peeked, expected)}; is actual: {ReferenceEquals (peeked, actual)}"); + + sb.AppendLine ($" Surfaced peers with JniIdentityHashCode=0x{expected.JniIdentityHashCode:x}:"); + foreach (var info in manager.GetSurfacedPeers ()) { + if (info.JniIdentityHashCode != expected.JniIdentityHashCode) + continue; + info.SurfacedPeer.TryGetTarget (out var target); + sb.AppendLine ($" {Describe (target)} (is expected: {ReferenceEquals (target, expected)}; is actual: {ReferenceEquals (target, actual)})"); + } + return sb.ToString (); + } + + static void AppendPeer (StringBuilder sb, string label, object value) + { + sb.AppendLine ($" {label}: {Describe (value)}"); + if (value is not IJavaPeerable peer) + return; + sb.AppendLine ($" JniIdentityHashCode=0x{peer.JniIdentityHashCode:x} PeerReference={peer.PeerReference} JniManagedPeerState={peer.JniManagedPeerState}"); + } + + // The Java-side `toString()` is identical for every peer over the same instance, so + // identify peers by their *managed* hash code instead. + static string Describe (object value) + => value == null + ? "" + : $"{value.GetType ().FullName}@managed-0x{RuntimeHelpers.GetHashCode (value):x}"; + [Test] public void NewArray_Int32ArrayArray () { From 131501af3f8eeb6bfa570f70f269036d3d7d9e34 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Jul 2026 16:34:08 -0500 Subject: [PATCH 2/8] [tests] record *when* the `GetObjectArray` peer mismatch appears The self-diagnosing assert added previously caught a real CI failure and reported: expected (Application.Context): App@managed-0x109791d PeerReference=0x2d66/G actual (GetObjectArray ()[0]): App@managed-0x1ee05f PeerReference=0x30aa/G PeekPeer (expected.PeerReference) => App@managed-0x1ee05f is expected: False; is actual: True Surfaced peers with JniIdentityHashCode=0x87e2d7a: App@managed-0x1ee05f So the registry ends up holding exactly one peer, and it is *not* the one `Application.Context` caches. But that end state does not say when the registry stopped agreeing with `Application.Context`, and the two candidate stories predict the same final state: * the entry was still present and got replaced while marshaling, or * the entry was already gone before this test ran, so `PeekPeer()` legitimately missed and a fresh peer was created and registered. Note the first story requires `PeekPeer()` to miss the entry while `AddPeer()`, microseconds later, finds it live and matching -- both using the same `Target` and `IsSameObject()` checks. Sampling the registry before the marshaling distinguishes them directly instead of by argument. Probe `PeekPeer (Application.Context)` at test entry, before `NewArray()` and after `NewArray()`, and report the three samples on failure. Record a description instead of the peer itself, so the probe cannot keep a peer alive and perturb the GC behaviour under investigation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../Android.Runtime/JnienvArrayMarshaling.cs | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index c0aee549029..efb9840679a 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -330,42 +330,80 @@ public void SetArrayItem_JavaLangString () [Category ("JNIObjectArray")] public void GetObjectArray () { + var context = Application.Context; + // Sample the registry at each step, so a failure says *when* `Application.Context` + // stopped being the registered peer rather than only that it did. + var atEntry = ProbeContextPeer (context); using (var byteArray = new Java.Lang.Object (JNIEnv.NewArray (new byte[]{1,2,3}), JniHandleOwnership.TransferLocalRef)) { object[] data = JNIEnv.GetObjectArray (byteArray.Handle, new[]{typeof (byte), typeof (byte), typeof (byte)}); AssertArrays ("GetObjectArray", data, (object) 1, (object) 2, (object) 3); } - var context = Application.Context; + var beforeNewArray = ProbeContextPeer (context); using (var objectArray = new Java.Lang.Object ( JNIEnv.NewArray ( new Java.Lang.Object[]{context, 42L, "string"}, typeof (Java.Lang.Object)), JniHandleOwnership.TransferLocalRef)) { + var afterNewArray = ProbeContextPeer (context); object[] values = JNIEnv.GetObjectArray (objectArray.Handle, new[]{typeof(Context), typeof (int)}); Assert.AreEqual (3, values.Length); // Deliberately not `Assert.AreSame()`: this intermittently fails on CoreCLR // (dotnet/android#10973), and both peers render identically, so the default // message tells us nothing. Only build the diagnostic when it actually fails. if (!ReferenceEquals (context, values [0])) - Assert.Fail (DescribeContextPeerMismatch (context, values [0])); + Assert.Fail (DescribeContextPeerMismatch (context, values [0], atEntry, beforeNewArray, afterNewArray)); Assert.IsInstanceOf (values [1], $"Expected converted Int32, got {values [1]?.GetType ()}: {values [1]}."); Assert.AreEqual (42, (int)values [1]); Assert.AreEqual ("string", values [2].ToString ()); } } + // What the registry reports for `Application.Context` at one point in time. Records a + // description rather than the peer itself: retaining the peer would keep it alive and + // could perturb the very GC behaviour being investigated. + readonly struct PeerProbe { + + readonly bool isExpected; + readonly string description; + + public PeerProbe (bool isExpected, string description) + { + this.isExpected = isExpected; + this.description = description; + } + + public override string ToString () + => $"{description} (is expected: {isExpected})"; + } + + static PeerProbe ProbeContextPeer (Context expected) + { + if (!expected.PeerReference.IsValid) + return new PeerProbe (false, ""); + var peeked = JniRuntime.CurrentRuntime.ValueManager.PeekPeer (expected.PeerReference); + return new PeerProbe (ReferenceEquals (peeked, expected), Describe (peeked)); + } + // `GetObjectArray()` should hand back the *same* managed peer that `Application.Context` // holds, because `JniValueManager.GetPeer()` peeks the registry before creating a new peer. // When that fails, the Java instance is the same but the managed peers differ, so dump // enough of the registry to tell *why* they disagree: whether the entry was replaced by a // second `AddPeer()`, evicted entirely, or its weak reference was cleared. - static string DescribeContextPeerMismatch (Context expected, object actual) + static string DescribeContextPeerMismatch (Context expected, object actual, PeerProbe atEntry, PeerProbe beforeNewArray, PeerProbe afterNewArray) { var sb = new StringBuilder (); sb.AppendLine ("Expected `Application.Context` and `GetObjectArray ()[0]` to be the same managed peer."); AppendPeer (sb, "expected (Application.Context)", expected); AppendPeer (sb, "actual (GetObjectArray ()[0])", actual); + // The registry over time. If the peer was already wrong `atEntry`, something evicted + // or replaced it *before* this test ran and the marshaling is only the messenger. + sb.AppendLine (" PeekPeer (Application.Context) over time:"); + sb.AppendLine ($" at test entry: {atEntry}"); + sb.AppendLine ($" before NewArray: {beforeNewArray}"); + sb.AppendLine ($" after NewArray: {afterNewArray}"); + // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); From afa1a4442cf1a2ce842e4b92566a77baa726c902 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Jul 2026 19:13:07 -0500 Subject: [PATCH 3/8] [tests] name the test that breaks the `Application.Context` peer registration The probe added in the previous commit answered the question it was asked. From a CI failure on `Mono.Android.NET_Tests-CoreCLRTrimmable`: expected (Application.Context): App@managed-0x240d846 PeerReference=0x2d36/G actual (GetObjectArray ()[0]): App@managed-0x31cb802 PeerReference=0x3052/G PeekPeer (Application.Context) over time: at test entry: App@managed-0x31cb802 (is expected: False) before NewArray: App@managed-0x31cb802 (is expected: False) after NewArray: App@managed-0x31cb802 (is expected: False) The registry already held the wrong peer *at test entry*, before this test touched anything. `GetObjectArray()` is not where the bug happens; it is merely the first test that looks. That rules out the marshaling path, and with it every hypothesis framed around `NewArray()`/`GetObjectArray()`. So move the check to where it can name a culprit: an assembly-level `ITestAction` samples `PeekPeer (Application.Context)` after every test and records the first test after which it stops returning the peer that `Application.Context` caches. `GetObjectArray()`'s failure message then reports that test, narrowing ~430 candidates to one and making a local repro possible. `PeekPeer()` only reads the registry -- unlike `GetSurfacedPeers()` it does not drain the collected-peer queue -- so sampling it this often should not perturb the behaviour being measured. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../Android.Runtime/ContextPeerWatch.cs | 50 +++++++++++++++++++ .../Android.Runtime/JnienvArrayMarshaling.cs | 1 + .../Mono.Android.NET-Tests.csproj | 1 + 3 files changed, 52 insertions(+) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs new file mode 100644 index 00000000000..1b4a6df300e --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs @@ -0,0 +1,50 @@ +using System; + +using Android.App; + +using Java.Interop; + +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +[assembly: Android.RuntimeTests.ContextPeerWatchAttribute] + +namespace Android.RuntimeTests { + + // `Application.Context` caches one managed peer for the lifetime of the process, so + // `JniValueManager.PeekPeer()` should keep returning that same peer forever. On CoreCLR it + // intermittently stops doing so, and the first test to *notice* is + // `JnienvArrayMarshaling.GetObjectArray()` -- which is not necessarily the test that broke + // it. Diagnostics there confirmed the registry was already wrong on entry, so sample after + // every test to name the test we were actually running when it diverged. + [AttributeUsage (AttributeTargets.Assembly)] + public sealed class ContextPeerWatchAttribute : Attribute, ITestAction { + + // Only the *first* divergence is interesting; later tests just observe the same + // already-broken registry. + public static string DivergedAfter; + + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest (ITest test) + { + } + + public void AfterTest (ITest test) + { + if (DivergedAfter != null) + return; + + var context = Application.Context; + if (context == null || !context.PeerReference.IsValid) + return; + + // `PeekPeer()` only reads the registry -- in particular it does not drain the + // collected-peer queue the way `GetSurfacedPeers()` does -- so sampling it this + // often does not itself perturb the behaviour under investigation. + var peeked = JniRuntime.CurrentRuntime.ValueManager.PeekPeer (context.PeerReference); + if (!ReferenceEquals (peeked, context)) + DivergedAfter = test.FullName; + } + } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index efb9840679a..3781617f959 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -403,6 +403,7 @@ static string DescribeContextPeerMismatch (Context expected, object actual, Peer sb.AppendLine ($" at test entry: {atEntry}"); sb.AppendLine ($" before NewArray: {beforeNewArray}"); sb.AppendLine ($" after NewArray: {afterNewArray}"); + sb.AppendLine ($" Registry first diverged after test: {ContextPeerWatchAttribute.DivergedAfter ?? ""}"); // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index ee23c0b6f13..40e710d8d6b 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -129,6 +129,7 @@ + From c9092b96394cb16c73866a6fe27998a788116d2f Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Jul 2026 20:03:25 -0500 Subject: [PATCH 4/8] [tests] distinguish a startup divergence from a test-caused one The per-test watcher named a culprit on its first CI run: Registry first diverged after test: Android.AppTests.ApplicationTest.ApplicationContextIsApp That test is the *first* test in the run -- `Android.AppTests` sorts ahead of every other namespace -- and its body is only: Assert.IsTrue (Application.Context is App); Assert.IsTrue (App.Created); which does nothing that could plausibly evict a peer. So the divergence almost certainly predates the test run entirely and happens during app startup. But sampling only in `AfterTest` cannot prove that: it cannot separate "already broken when the run started" from "broken by this test". Sample in `BeforeTest` as well and record which one observed it first, so the report reads `before ` or `after `. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../Android.Runtime/ContextPeerWatch.cs | 12 ++++++++++-- .../Android.Runtime/JnienvArrayMarshaling.cs | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs index 1b4a6df300e..10fdccb1e3a 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/ContextPeerWatch.cs @@ -21,16 +21,24 @@ namespace Android.RuntimeTests { public sealed class ContextPeerWatchAttribute : Attribute, ITestAction { // Only the *first* divergence is interesting; later tests just observe the same - // already-broken registry. + // already-broken registry. Prefixed with "before"/"after" so a divergence that is + // already present when the very first test starts -- i.e. one that happened during + // app startup -- is distinguishable from one a test body caused. public static string DivergedAfter; public ActionTargets Targets => ActionTargets.Test; public void BeforeTest (ITest test) { + Check (test, "before"); } public void AfterTest (ITest test) + { + Check (test, "after"); + } + + static void Check (ITest test, string when) { if (DivergedAfter != null) return; @@ -44,7 +52,7 @@ public void AfterTest (ITest test) // often does not itself perturb the behaviour under investigation. var peeked = JniRuntime.CurrentRuntime.ValueManager.PeekPeer (context.PeerReference); if (!ReferenceEquals (peeked, context)) - DivergedAfter = test.FullName; + DivergedAfter = $"{when} {test.FullName}"; } } } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index 3781617f959..b21f58f6c99 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -403,7 +403,7 @@ static string DescribeContextPeerMismatch (Context expected, object actual, Peer sb.AppendLine ($" at test entry: {atEntry}"); sb.AppendLine ($" before NewArray: {beforeNewArray}"); sb.AppendLine ($" after NewArray: {afterNewArray}"); - sb.AppendLine ($" Registry first diverged after test: {ContextPeerWatchAttribute.DivergedAfter ?? ""}"); + sb.AppendLine ($" Registry first diverged: {ContextPeerWatchAttribute.DivergedAfter ?? ""}"); // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); From 490e2aa16492677635292c27fec98f6344c0f9bf Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Jul 2026 22:31:23 -0500 Subject: [PATCH 5/8] [tests] record when `CollectPeers()` evicts a live peer The watcher pinned down *when* the registry diverges: Registry first diverged: before Android.AppTests.ApplicationTest.ApplicationContextIsApp `before`, on the first test of the run -- so the registry is already wrong when the test run starts, and the divergence happens during app startup. The greferences say which way it went: `Application.Context` caches `0x2d66/G` while the registry holds the later `0x3086/G`, i.e. a newer peer displaced the one `_context` had already cached. Two mechanisms produce that, and build 1529081 separates them. That build tested `f0e746d8c` (a merge of `bfcad439e`), which did contain the `target.Replaceable && !value.Replaceable` guard -- verified with `git show` -- and `GetObjectArray` still failed: * `AddPeer()` *replacing* the entry: blocked by that guard, so ruled out. * the entry being *evicted*, so a later `PeekPeer()` legitimately misses and registers a fresh peer: unaffected by the guard, and consistent. Eviction also fits the rest: exactly one surfaced peer, `Application.Context` holding the older one, CoreCLR-only (`CollectPeers()` has no MonoVM counterpart), and GC-timing flakiness. A context only reaches `CollectedContexts` because the GC bridge reported its peer collected, so by the time `CollectPeers()` evicts it the peer's weak reference should already be cleared. Record the cases where it is not -- a live managed peer, possibly strongly held by `Application.Context` -- and publish the count via `AppContext` so the test can report it without `InternalsVisibleTo`. Recording only: the eviction still happens, so this measures the bug rather than masking it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../JavaMarshalRegisteredPeers.cs | 31 +++++++++++++++++++ .../Android.Runtime/JnienvArrayMarshaling.cs | 3 ++ 2 files changed, 34 insertions(+) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs index cf6f550b5fd..20c7fc3c992 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.InteropServices.Java; +using System.Threading; using Android.Runtime; using Java.Interop; @@ -58,6 +59,35 @@ internal static void InitializeIfNeeded () } } + /// + /// Diagnostic for dotnet/android#10973. A context reaches + /// only because the GC bridge reported its peer as collected, so the peer's weak reference + /// should already be cleared by the time we evict it. If it is *not* -- i.e. the managed + /// peer is still alive, possibly strongly held by something like + /// Application.Context -- then we are about to drop a live registration, which is + /// what makes JniValueManager.PeekPeer() subsequently miss and hand out a second + /// peer for the same Java instance. + /// + /// Published via so tests in other assemblies can report it + /// without needing InternalsVisibleTo. Recording only; the eviction still happens, + /// so this does not mask the bug it is measuring. + /// + internal const string LiveEvictionsKey = "Microsoft.Android.Runtime.LivePeerEvictions"; + + static int liveEvictions; + + static void RecordIfLive (int key, ReferenceTrackingHandle peer) + { + if (peer.Target is not IJavaPeerable live) + return; + + int count = Interlocked.Increment (ref liveEvictions); + AppContext.SetData (LiveEvictionsKey, + $"{count} (most recent: JniIdentityHashCode=0x{key.ToString ("x", CultureInfo.InvariantCulture)} " + + $"Type={live.GetType ().FullName} Instance=0x{RuntimeHelpers.GetHashCode (live).ToString ("x", CultureInfo.InvariantCulture)})"); + GC.KeepAlive (live); + } + public static void CollectPeers () { unsafe { @@ -81,6 +111,7 @@ void Remove (HandleContext* context) for (int i = peers.Count - 1; i >= 0; i--) { var peer = peers [i]; if (peer.BelongsToContext (context)) { + RecordIfLive (key, peer); peers.RemoveAt (i); } } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index b21f58f6c99..9c0a9b9dbda 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -404,6 +404,9 @@ static string DescribeContextPeerMismatch (Context expected, object actual, Peer sb.AppendLine ($" before NewArray: {beforeNewArray}"); sb.AppendLine ($" after NewArray: {afterNewArray}"); sb.AppendLine ($" Registry first diverged: {ContextPeerWatchAttribute.DivergedAfter ?? ""}"); + // Set by JavaMarshalRegisteredPeers.CollectPeers() when it evicts a registration + // whose managed peer is still alive -- the suspected cause of the miss above. + sb.AppendLine ($" Live peers evicted by CollectPeers(): {AppContext.GetData ("Microsoft.Android.Runtime.LivePeerEvictions") ?? ""}"); // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); From dda89b24ec54a5a525f92269f229d9889c2c9a0d Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 01:37:05 -0500 Subject: [PATCH 6/8] [tests] record how a second peer enters the registry The eviction hypothesis is dead. Build 1532632 reproduced the failure with the recorder in place and reported: Live peers evicted by CollectPeers(): So `CollectPeers()` never drops a live registration, and the peer `Application.Context` caches is not being evicted. Re-reading build 1529081's failure message confirms it was this same assertion, on a build that did carry the `Replaceable`/`Replaceable` guard. Taken together with the current dumps, both configurations diverge, but in opposite directions: * without the guard, the registry follows the newer peer while `Application.Context` holds the older one (gref 0x2d66 vs 0x30c6); * with the guard, the registry keeps the older one instead. Which one `AddPeer()` picks is therefore not the bug. The invariant that actually breaks is upstream of that choice: a *second* managed peer is created for the same Java Application instance at all. Whichever one the registry settles on, `Application.Context` has already cached the other. `AddPeer()` sees that happen in exactly two places -- replacing an existing entry, and appending alongside one whose weak reference was cleared -- so record both, with the identity of the old and new peer, and report them from the failing test. Recording only; behaviour is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../JavaMarshalRegisteredPeers.cs | 46 ++++++++++++++----- .../Android.Runtime/JnienvArrayMarshaling.cs | 9 ++-- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs index 20c7fc3c992..493ffd69b76 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs @@ -60,21 +60,26 @@ internal static void InitializeIfNeeded () } /// - /// Diagnostic for dotnet/android#10973. A context reaches - /// only because the GC bridge reported its peer as collected, so the peer's weak reference - /// should already be cleared by the time we evict it. If it is *not* -- i.e. the managed - /// peer is still alive, possibly strongly held by something like - /// Application.Context -- then we are about to drop a live registration, which is - /// what makes JniValueManager.PeekPeer() subsequently miss and hand out a second - /// peer for the same Java instance. + /// Diagnostics for dotnet/android#10973. The registry only ever disagrees with a cached + /// reference such as Application.Context if a *second* managed peer is created for + /// the same Java instance. Record the two places observes that -- an + /// existing entry being replaced, and a second entry being appended because the existing + /// one's weak reference was cleared -- so a failing test can report which happened, to + /// whom, and how often. /// - /// Published via so tests in other assemblies can report it - /// without needing InternalsVisibleTo. Recording only; the eviction still happens, - /// so this does not mask the bug it is measuring. + /// Published via so tests in other assemblies can read them + /// without needing InternalsVisibleTo. Recording only: behaviour is unchanged. /// internal const string LiveEvictionsKey = "Microsoft.Android.Runtime.LivePeerEvictions"; + internal const string ReplacementsKey = "Microsoft.Android.Runtime.PeerReplacements"; + internal const string DuplicateAppendsKey = "Microsoft.Android.Runtime.PeerDuplicateAppends"; static int liveEvictions; + static int replacements; + static int duplicateAppends; + + static string Id (IJavaPeerable peer) + => $"{peer.GetType ().FullName}@managed-0x{RuntimeHelpers.GetHashCode (peer).ToString ("x", CultureInfo.InvariantCulture)}/{peer.PeerReference}"; static void RecordIfLive (int key, ReferenceTrackingHandle peer) { @@ -83,11 +88,26 @@ static void RecordIfLive (int key, ReferenceTrackingHandle peer) int count = Interlocked.Increment (ref liveEvictions); AppContext.SetData (LiveEvictionsKey, - $"{count} (most recent: JniIdentityHashCode=0x{key.ToString ("x", CultureInfo.InvariantCulture)} " + - $"Type={live.GetType ().FullName} Instance=0x{RuntimeHelpers.GetHashCode (live).ToString ("x", CultureInfo.InvariantCulture)})"); + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)} {Id (live)})"); GC.KeepAlive (live); } + static void RecordReplacement (int key, IJavaPeerable replaced, IJavaPeerable replacement) + { + int count = Interlocked.Increment (ref replacements); + AppContext.SetData (ReplacementsKey, + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)} " + + $"replaced {Id (replaced)} with {Id (replacement)})"); + } + + static void RecordDuplicateAppend (int key, IJavaPeerable value, int existing) + { + int count = Interlocked.Increment (ref duplicateAppends); + AppContext.SetData (DuplicateAppendsKey, + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)} " + + $"appended {Id (value)} alongside {existing} existing entr{(existing == 1 ? "y" : "ies")})"); + } + public static void CollectPeers () { unsafe { @@ -152,6 +172,7 @@ public static void AddPeer (IJavaPeerable value) if (!JniEnvironment.Types.IsSameObject (target.PeerReference, value.PeerReference)) continue; if (target.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable)) { + RecordReplacement (key, target, value); peer.Dispose (); peers [i] = new ReferenceTrackingHandle (value); } else { @@ -161,6 +182,7 @@ public static void AddPeer (IJavaPeerable value) return; } + RecordDuplicateAppend (key, value, peers.Count); peers.Add (new ReferenceTrackingHandle (value)); } } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index 9c0a9b9dbda..afeeb3e7de4 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -404,9 +404,12 @@ static string DescribeContextPeerMismatch (Context expected, object actual, Peer sb.AppendLine ($" before NewArray: {beforeNewArray}"); sb.AppendLine ($" after NewArray: {afterNewArray}"); sb.AppendLine ($" Registry first diverged: {ContextPeerWatchAttribute.DivergedAfter ?? ""}"); - // Set by JavaMarshalRegisteredPeers.CollectPeers() when it evicts a registration - // whose managed peer is still alive -- the suspected cause of the miss above. - sb.AppendLine ($" Live peers evicted by CollectPeers(): {AppContext.GetData ("Microsoft.Android.Runtime.LivePeerEvictions") ?? ""}"); + // Set by JavaMarshalRegisteredPeers: the registry can only disagree with a cached + // `Application.Context` if a second managed peer was created for the same Java + // instance, so report how that happened. + sb.AppendLine ($" CollectPeers() evicted live peers: {AppContext.GetData ("Microsoft.Android.Runtime.LivePeerEvictions") ?? ""}"); + sb.AppendLine ($" AddPeer() replaced registrations: {AppContext.GetData ("Microsoft.Android.Runtime.PeerReplacements") ?? ""}"); + sb.AppendLine ($" AddPeer() appended duplicates: {AppContext.GetData ("Microsoft.Android.Runtime.PeerDuplicateAppends") ?? ""}"); // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}"); From d7a31a7d637f3c1b5d8f48fec94edf772a488412 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 03:06:51 -0500 Subject: [PATCH 7/8] Fix `Application.Context` losing its peer registration on CoreCLR Fixes: https://github.com/dotnet/android/issues/10973 `JnienvArrayMarshaling.GetObjectArray()` intermittently failed on CoreCLR (~17 failures / 216 executions) and never on MonoVM or NativeAOT: Expected existing Context peer, got Android.AppTests.App. Expected: same as crc64f5183e500568449d.App@8e34fe2 But was: crc64f5183e500568449d.App@8e34fe2 Both sides print identically because they are two *managed* peers over the same Java instance, so the assert's own message could not explain it. A test-only diagnostic, iterated over several CI runs, produced this: Registry first diverged: before Android.AppTests.ApplicationTest.ApplicationContextIsApp CollectPeers() evicted live peers: AddPeer() replaced registrations: 1 (most recent: key=0xe45a52a replaced App@managed-0x33c0d9d/0x2d66/G with App@managed-0x11c7a8c/0x2d76/G) AddPeer() appended duplicates: `before` on the run's first test means the registry is already wrong when testing starts, so this happens during app startup; `GetObjectArray()` was only the first test to look. And exactly one replacement occurs in the whole process: a second `App` peer displacing the one `Application.Context` had cached. `Application.Context` caches the peer it first sees and never re-reads it, so replacing that registration orphans the cached reference permanently -- `PeekPeer()` then hands out the replacement for the same Java instance. Whether the run fails is a startup race: if `Application.Context` is read before the replacement it keeps the orphaned peer and fails; if after, it caches the survivor and passes. `AddPeer()` replaced whenever the *existing* peer was `Replaceable`, without checking the new one. MonoVM's `AndroidRuntime.JavaObjectValueManager` also requires the new peer to be non-`Replaceable` -- a "replaceable" instance must not replace another "replaceable" instance, per dotnet/android#9862 -- which is why MonoVM never hit this. Apply the same condition here. Also guard the `WarnNotReplacing()` call with `LogGlobalReferenceMessages`: that branch is now reached during normal startup, and its arguments are evaluated eagerly -- including two `GetJniTypeNameFromInstance()` JNI round-trips -- even though `WriteGlobalReferenceLine()` discards them when logging is off. MonoVM guards its equivalent warning the same way. The diagnostics are kept: they are what turned an unexplainable assert into a located bug, and they let CI confirm the fix directly -- a green run must now report no replacement of the `Application.Context` peer, rather than merely not failing a ~50/50 coin flip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../JavaMarshalRegisteredPeers.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs index 493ffd69b76..4b068bc6e99 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs @@ -171,11 +171,34 @@ public static void AddPeer (IJavaPeerable value) continue; if (!JniEnvironment.Types.IsSameObject (target.PeerReference, value.PeerReference)) continue; - if (target.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable)) { + // JNIEnv.NewObject/JNIEnv.CreateInstance() compatibility. + // When two MCWs are created for one Java instance [0], we want the 2nd + // MCW to replace the 1st, as the 2nd is the one the dev created; the + // 1st is an implicit intermediary. + // + // Meanwhile, a new "replaceable" instance must *not* replace an existing + // "replaceable" instance; see dotnet/android#9862. Doing so orphans any + // already-cached reference to the existing peer -- notably + // `Application.Context`, which caches the peer it first sees and never + // re-reads it -- so `PeekPeer()` then hands out a different peer for the + // same Java instance; see dotnet/android#10973. + // + // This mirrors `AndroidRuntime.JavaObjectValueManager`; MonoVM has always + // had the second condition, which is why this only ever fails on CoreCLR. + // + // [0]: If a Java ctor invokes an overridden virtual method, we transition + // into managed code w/o a registered instance, and thus create an + // "intermediary" via the (IntPtr, JniHandleOwnership) .ctor. + if (target.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable) && + !value.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable)) { RecordReplacement (key, target, value); peer.Dispose (); peers [i] = new ReferenceTrackingHandle (value); - } else { + } else if (JniEnvironment.Runtime.ObjectReferenceManager.LogGlobalReferenceMessages) { + // Now a normal startup path rather than a rare one, and the arguments + // below are evaluated eagerly -- including two JNI round-trips for + // `GetJniTypeNameFromInstance()` -- even though + // `WriteGlobalReferenceLine()` discards them when logging is off. WarnNotReplacing (key, value, target); } GC.KeepAlive (target); From 18a69cae47406083378e4660203c58958bfa4bfe Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 05:00:24 -0500 Subject: [PATCH 8/8] [tests] trace every path that mutates a peer registration The `Replaceable`/`Replaceable` guard works -- build 1532806 reports `AddPeer() replaced registrations: ` -- and `GetObjectArray()` still fails. That also explains build 1529081, which carried the same guard and failed the same way, and it means the guard is MonoVM parity rather than the fix for this. What the failing dump now shows is a registry state that none of the instrumented paths produced: CollectPeers() evicted live peers: AddPeer() replaced registrations: AddPeer() appended duplicates: Surfaced peers with JniIdentityHashCode=0x17e2473: Android.AppTests.App@managed-0xf5e94d The registry holds the peer that `Application.Context` did *not* cache, yet nothing replaced, appended or evicted anything. Two possibilities remain, and both run through code that is currently silent: * the registry's peer registered *first* and the guard then turned away the one `Application.Context` went on to cache -- the guard's "not replacing" branch records nothing once `WarnNotReplacing()` is skipped for logging being off; or * the cached peer was registered and then removed by `RemovePeer()`, or by a `CollectPeers()` eviction of an already-cleared entry, neither of which is counted. Record all three. The "declined to replace" entry also establishes registration order, since the peer that registers first is the one kept. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ce866f68-cb01-44de-bce0-7457f7612b72 --- .../JavaMarshalRegisteredPeers.cs | 47 ++++++++++++++++--- .../Android.Runtime/JnienvArrayMarshaling.cs | 4 ++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs index 4b068bc6e99..1fabb20bd09 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalRegisteredPeers.cs @@ -73,10 +73,16 @@ internal static void InitializeIfNeeded () internal const string LiveEvictionsKey = "Microsoft.Android.Runtime.LivePeerEvictions"; internal const string ReplacementsKey = "Microsoft.Android.Runtime.PeerReplacements"; internal const string DuplicateAppendsKey = "Microsoft.Android.Runtime.PeerDuplicateAppends"; + internal const string NotReplacedKey = "Microsoft.Android.Runtime.PeerNotReplaced"; + internal const string RemovedKey = "Microsoft.Android.Runtime.PeerRemoved"; + internal const string DeadEvictionsKey = "Microsoft.Android.Runtime.DeadPeerEvictions"; static int liveEvictions; + static int deadEvictions; static int replacements; static int duplicateAppends; + static int notReplaced; + static int removed; static string Id (IJavaPeerable peer) => $"{peer.GetType ().FullName}@managed-0x{RuntimeHelpers.GetHashCode (peer).ToString ("x", CultureInfo.InvariantCulture)}/{peer.PeerReference}"; @@ -108,6 +114,30 @@ static void RecordDuplicateAppend (int key, IJavaPeerable value, int existing) $"appended {Id (value)} alongside {existing} existing entr{(existing == 1 ? "y" : "ies")})"); } + // The peer that registers *first* wins, so this also records the registration order: + // `kept` was registered before `rejected` ever reached AddPeer(). + static void RecordNotReplaced (int key, IJavaPeerable kept, IJavaPeerable rejected) + { + int count = Interlocked.Increment (ref notReplaced); + AppContext.SetData (NotReplacedKey, + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)} " + + $"kept {Id (kept)}, did not register {Id (rejected)})"); + } + + static void RecordRemoved (int key, IJavaPeerable value) + { + int count = Interlocked.Increment (ref removed); + AppContext.SetData (RemovedKey, + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)} {Id (value)})"); + } + + static void RecordDeadEviction (int key) + { + int count = Interlocked.Increment (ref deadEvictions); + AppContext.SetData (DeadEvictionsKey, + $"{count} (most recent: key=0x{key.ToString ("x", CultureInfo.InvariantCulture)})"); + } + public static void CollectPeers () { unsafe { @@ -132,6 +162,7 @@ void Remove (HandleContext* context) var peer = peers [i]; if (peer.BelongsToContext (context)) { RecordIfLive (key, peer); + RecordDeadEviction (key); peers.RemoveAt (i); } } @@ -194,12 +225,15 @@ public static void AddPeer (IJavaPeerable value) RecordReplacement (key, target, value); peer.Dispose (); peers [i] = new ReferenceTrackingHandle (value); - } else if (JniEnvironment.Runtime.ObjectReferenceManager.LogGlobalReferenceMessages) { - // Now a normal startup path rather than a rare one, and the arguments - // below are evaluated eagerly -- including two JNI round-trips for - // `GetJniTypeNameFromInstance()` -- even though - // `WriteGlobalReferenceLine()` discards them when logging is off. - WarnNotReplacing (key, value, target); + } else { + RecordNotReplaced (key, target, value); + if (JniEnvironment.Runtime.ObjectReferenceManager.LogGlobalReferenceMessages) { + // Now a normal startup path rather than a rare one, and the + // arguments below are evaluated eagerly -- including two JNI + // round-trips for `GetJniTypeNameFromInstance()` -- even though + // `WriteGlobalReferenceLine()` discards them when logging is off. + WarnNotReplacing (key, value, target); + } } GC.KeepAlive (target); return; @@ -268,6 +302,7 @@ public static void RemovePeer (IJavaPeerable value) ReferenceTrackingHandle peer = peers [i]; IJavaPeerable? target = peer.Target; if (ReferenceEquals (value, target)) { + RecordRemoved (key, value); peers.RemoveAt (i); peer.Dispose (); } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs index afeeb3e7de4..520bef71037 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs @@ -408,8 +408,12 @@ static string DescribeContextPeerMismatch (Context expected, object actual, Peer // `Application.Context` if a second managed peer was created for the same Java // instance, so report how that happened. sb.AppendLine ($" CollectPeers() evicted live peers: {AppContext.GetData ("Microsoft.Android.Runtime.LivePeerEvictions") ?? ""}"); + sb.AppendLine ($" CollectPeers() evictions (total): {AppContext.GetData ("Microsoft.Android.Runtime.DeadPeerEvictions") ?? ""}"); sb.AppendLine ($" AddPeer() replaced registrations: {AppContext.GetData ("Microsoft.Android.Runtime.PeerReplacements") ?? ""}"); sb.AppendLine ($" AddPeer() appended duplicates: {AppContext.GetData ("Microsoft.Android.Runtime.PeerDuplicateAppends") ?? ""}"); + // Which peer registered *first* -- the kept one -- and which was turned away. + sb.AppendLine ($" AddPeer() declined to replace: {AppContext.GetData ("Microsoft.Android.Runtime.PeerNotReplaced") ?? ""}"); + sb.AppendLine ($" RemovePeer() removals: {AppContext.GetData ("Microsoft.Android.Runtime.PeerRemoved") ?? ""}"); // Did `Application.Context` itself change after we captured it? sb.AppendLine ($" Application.Context still == expected: {ReferenceEquals (Application.Context, expected)}");