Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Mono.Android] fix "replaceable" objects in ManagedValueManager #10004

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

jonathanpeppers
Copy link
Member

The following test is failing on NativeAOT as well as any case we'd use ManagedValueManager:

[Test]
public void JnienvCreateInstance_RegistersMultipleInstances ()
{
    using (var adapter = new CreateInstance_OverrideAbsListView_Adapter (Application.Context)) {

        var intermediate  = CreateInstance_OverrideAbsListView_Adapter.Intermediate;
        var registered    = Java.Lang.Object.GetObject<CreateInstance_OverrideAbsListView_Adapter>(adapter.Handle, JniHandleOwnership.DoNotTransfer);

        Assert.AreNotSame (adapter, intermediate); // Passes
        Assert.AreSame (adapter, registered);      // Fails!
    }
}

With the assertion:

Expected: same as <com.xamarin.android.runtimetests.CreateInstance_OverrideAbsListView_Adapter{cbd0e5a V.ED.VC.. ......I. 0,0-0,0}>
But was:  <com.xamarin.android.runtimetests.CreateInstance_OverrideAbsListView_Adapter{cbd0e5a V.ED.VC.. ......I. 0,0-0,0}>

The second assertion fails because registered is the same instance as intermediate. In this example, this is a code path where intermediate should be "replaced" with adapter.

After lots of debugging, I found the problem are these lines in the ManagedValueManager.AddPeer() method:

var o = PeekPeer (value.PeerReference);
if (o != null)
    return;

If we PeekPeer() in the middle of AddPeer() and a type is "replaceable", it would find an instance and not replace it! I did not find equivalent code in AndroidValueManager.AddPeer(), which is what is used in Mono & production today.

With these lines removed, the test passes. I will look if we should also update these lines in dotnet/java-interop in a future PR.

The following test is failing on NativeAOT as well as any case we'd
use `ManagedValueManager`:

    [Test]
    public void JnienvCreateInstance_RegistersMultipleInstances ()
    {
        using (var adapter = new CreateInstance_OverrideAbsListView_Adapter (Application.Context)) {

            var intermediate  = CreateInstance_OverrideAbsListView_Adapter.Intermediate;
            var registered    = Java.Lang.Object.GetObject<CreateInstance_OverrideAbsListView_Adapter>(adapter.Handle, JniHandleOwnership.DoNotTransfer);

            Assert.AreNotSame (adapter, intermediate); // Passes
            Assert.AreSame (adapter, registered);      // Fails!
        }
    }

With the assertion:

    Expected: same as <com.xamarin.android.runtimetests.CreateInstance_OverrideAbsListView_Adapter{cbd0e5a V.ED.VC.. ......I. 0,0-0,0}>
    But was:  <com.xamarin.android.runtimetests.CreateInstance_OverrideAbsListView_Adapter{cbd0e5a V.ED.VC.. ......I. 0,0-0,0}>

The second assertion fails because `registered` is the same instance
as `intermediate`. In this example, this is a code path where
`intermediate` should be "replaced" with `adapter`.

After lots of debugging, I found the problem are these lines in the
`ManagedValueManager.AddPeer()` method:

    var o = PeekPeer (value.PeerReference);
    if (o != null)
        return;

If we `PeekPeer()` in the middle of `AddPeer()` and a type is
"replaceable", it would find an instance and not replace it! I did not
find equivalent code in `AndroidValueManager.AddPeer()`, which is what
is used in Mono & production today.

With these lines removed, the test passes. I will look if we should
also update these lines in dotnet/java-interop in a future PR.
jonathanpeppers added a commit to dotnet/java-interop that referenced this pull request Apr 3, 2025
Context: dotnet/android#10004

This breaks the "replaceable" logic otherwise.
@jonathanpeppers jonathanpeppers requested a review from Copilot April 3, 2025 16:50
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR fixes a bug in ManagedValueManager where "replaceable" objects were not properly replaced, leading to incorrect instance registrations in tests.

  • Removed an early return check that prevented the replacement of instances.
  • The changes align ManagedValueManager's behavior with AndroidValueManager's implementation.
Comments suppressed due to low confidence (1)

src/Mono.Android/Microsoft.Android.Runtime/ManagedValueManager.cs:70

  • Removing the PeekPeer check ensures that replaceable objects are correctly updated, but please add a code comment to clarify why this behavior is necessary to prevent future regressions.
var o = PeekPeer (value.PeerReference);

@jonathanpeppers
Copy link
Member Author

jonpryor added a commit to dotnet/java-interop that referenced this pull request Apr 9, 2025
Context: dotnet/android#10004

It looks like dotnet/android#10004 is closely tied to dotnet/android#9862.

Might as well update tests to hit this behavior!
@jonpryor
Copy link
Member

jonpryor commented Apr 9, 2025

What's "funny" is the interaction between #9862, dotnet/java-interop#1323, and this PR #10004.

The "problem" is that the current AndroidValueManager.AddPeer() semantics are "wrong" wrt #9862, in that we would like to preserve/retain the "replaceable" value if the new value is also "replaceable".

I think that ManagedValueManager, because of the PeekPeer() call within AddPeer(), has this semantic!

…but at the possible cost of the scenario #10004 is trying to address.

Additionally this doesn't "lower" things so that the issue is visible in dotnet/java-interop. I had thought that an existing test case there hit this scenario, but I was wrong; there is partial overlap, but not complete overlap. I've updated dotnet/java-interop#1323 so that JnienvCreateInstance_RegistersMultipleInstances() and CreateManagedInstanceFirst() more closely resemble each other…

@jonpryor
Copy link
Member

After more thinking… I think the actual bug is in CreateInstance_OverrideAbsListView_Adapter(Context):

https://github.com/dotnet/android/blob/main/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Lang/ObjectTest.cs#L151-L160

As per private chat, this doesn't resemble current binding constructors at all. I just didn't think further on it at the time…

Aside: dotnet/java-interop@3043d89

Additional aside: We no longer support NewInvokeRequired: dotnet/java-interop@dec35f5, which means bindings never use JNIEnv::NewObject().

What the constructor should be is:

/*    01 */ public CreateInstance_OverrideAbsListView_Adapter (Context context)
/*    02 */ 	: base (IntPtr.Zero, JniHandleOwnership.DoNotTransfer)
/*    03 */ {
/*    04 */ 	const string __id = "(Landroid/content/Context;)V";
/*    05 */ 
/*    06 */ 	if (((global::Java.Lang.Object) this).Handle != IntPtr.Zero)
/*    07 */ 		return;
/*    08 */ 
/*    09 */ 	try {
/*    10 */ 		JniArgumentValue* __args = stackalloc JniArgumentValue [1];
/*    11 */ 		__args [0] = new JniArgumentValue ((context == null) ? IntPtr.Zero : ((global::Java.Lang.Object) context).Handle);
/*    12 */ 		var __r = _members.InstanceMethods.StartCreateInstance (__id, ((object) this).GetType (), __args);
/*    13 */ 		SetHandle (__r.Handle, JniHandleOwnership.TransferLocalRef);
/*    14 */ 		_members.InstanceMethods.FinishCreateInstance (__id, this, __args);
/*    15 */ 	} finally {
/*    16 */ 		global::System.GC.KeepAlive (context);
/*    17 */ 	}
/*    18 */ 
/*    19 */ 	AdapterValue = new ArrayAdapter (context, 0);
/*    20 */ }

Line 2: null handle, we're creating a new object below.

Line 6-7: If Handle is already set, we don't need to create a new instance; bail.

Lines 10-11: JNI argument marshaling

Line 12: _members.InstanceMethods.StartCreateInstance() is a JNIEnv::AllocObject(), which allocates the Java peer but does not run the constructor. It returns the JNI handle to the allocated Java peer.

Line 13: SetHandle() eventually calls AddPeer(), which creates the mapping between the Java peer in __r and this

Line 14: _members.InstanceMethods.FinishCreateInstance() invokes the Java side constructor.

This "allocate, create mapping, invoke constructor" pattern allows us to avoid the whole conundrum of "the Java constructor invokes an overridden virtual method! What instance do we invoke it on!" by ensuring that we have the mapping before the Java constructor is even invoked!

Which brings us to the "faulty" existing implementation:

		public CreateInstance_OverrideAbsListView_Adapter (Context context)
			: base (
					JNIEnv.CreateInstance (
						JcwType,
						"(Landroid/content/Context;)V",
						new JValue (context)),
					JniHandleOwnership.TransferLocalRef)

The use of JNIEnv.CreateInstance() is equivalent to using JNIEnv::NewObject(), which invokes the Java constructor and doesn't return until after the Java constructor has finished executing. This is what causes CreateInstance_OverrideAbsListView_Adapter.Intermediate to be set to a non-null value, because (in this scenario) an "intermediate" instance via Activation Constructor needs to exist to handle the overridden getAdapter() method / Adapter property, which is invoked by the AbsListView constructor, and that intermediate can't be the registered value, because we're in the constructor that is constructing the value that we need to register!

JNIEnv::NewObject() / JNIEnv.CreateInstance() has always been problematic, and has not been used in our bindings in over a decade! See also:


Thus, the question: what is the "fix"? What are we trying to fix?

Option 1: Update CreateInstance_OverrideAbsListView_Adapter(Context) to resemble a proper binding.

  • Pro: Consistency!
  • "Pro": JnienvCreateInstance_RegistersMultipleInstances() doesn't break, because intermediate will be null, which isn't the same as adapter!
  • Con: We won't have any tests which attempt to test this behavior around JNIEnv::NewObject().
  • @jonpryor isn't sure we want to continue testing this behavior. Only pre-API-10 targets require JNIEnv::NewObject(), so unless you're avoiding bindings and manually calling JNIEnv::NewObject()/JniEnvironment.Object.NewObject(), you should never encounter this behavior.

Option 2: Assert.Ignore() this test for CoreCLR & NativeAOT.

  • Pro: we don't need to worry about it!
  • Con? Uncertain.

Option 3: Add a JNIEnv::NewObject() variant test to dotnet/java-interop, and make that work.

  • Pro: Assert that this scenario works.
  • Con: @jonpryor isn't sure this is useful?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants