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

[Java.Interop] JNIEnv::NewObject and Replaceable instances #1323

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

jonpryor
Copy link
Member

@jonpryor jonpryor commented Mar 14, 2025

[Java.Interop] JNIEnv::NewObject and Replaceable instances (#1323)

Context: 3043d89
Context: dec35f5
Context: dotnet/android#9862
Context: dotnet/android#9862 (comment)
Context: dotnet/android#10004
Context: https://github.com/xamarin/monodroid/commit/326509e56d4e582c53bbe5dfe6d5c741a27f1af5
Context: https://github.com/xamarin/monodroid/commit/940136ebf1318a7c57a855e2728ce2703c0240af

Ever get the feeling that everything is inextricably related?

JNI has two pattens for create an instance of a Java type:

  1. JNIEnv::NewObject(jclass clazz, jmethodID methodID, const jvalue* args)

  2. JNIEnv::AllocObject(jclass clazz) +
    JNIEnv::CallNonvirtualVoidMethod(jobject obj, jclass clazz, jmethodID methodID, const jvalue* args)

In both patterns:

  • clazz is the java.lang.Class of the type to create.
  • methodID is the constructor to execute
  • args are the constructor arguments.

In .NET terms:

  • JNIEnv::NewObject() is equivalent to using
    System.Reflection.ConstructorInfo.Invoke(object?[]?), while

  • JNIEnv::AllocObject() + JNIEnv::CallNonvirtualVoidMethod() is
    equivalent to using
    System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(Type) +
    System.Reflection.MethodBase.Invoke(object?, object?[]?).

Why prefer one over the other?

When hand-writing your JNI code, JNIEnv::NewObject() is easier.
This is less of a concern when a code generator is used.

The real reason to avoid JNIEnv::NewObject() whenever possible
is the Java Activation scenario, summarized as the "are you sure
you want to do this?" 1 scenario of invoking a virtual method from the
constructor:

class Base {
  public Base() {
    VirtualMethod();
  }
  public virtual void VirtualMethod() {}
}

class Derived : Base {
  public override void VirtualMethod() {}
}

Java and C# are identical here: when a constructor invokes a virtual
method, the most derived method implementation is used, which will
occur before the constructor of the derived type has started
execution. (With lots of quibbling about field initializers…)

Thus, assume you have a Java CallVirtualFromConstructorBase type,
which has its constructor Do The Wrong Thing™ and invoke a virtual
method from the constructor, and that method is overridden in C#?

// Java
public class CallVirtualFromConstructorBase {
  public CallVirtualFromConstructorBase(int value) {
    calledFromConstructor(value);
  }
  public void calledFromConstructor(int value) {
  }
}

// C#
public class CallVirtualFromConstructorDerived : CallVirtualFromConstructorBase {
  public CallVirtualFromConstructorDerived(int value)
    : base (value)
  {
  }

  public override void CalledFromConstructor(int value)
  {
  }
}

What happens with:

var p = new CallVirtualFromConstructorDerived(42);

The answer depends on whether or not JNIEnv::NewObject() is used.

If JNIEnv::NewObject() is not used (the default!)

  1. CallVirtualFromConstructorDerived(int) constructor begins
    execution, immediately calls base(value).

  2. CallVirtualFromConstructorBase(int) constructor runs, uses
    JNIEnv::AllocObject() to create (but not construct!) Java
    CallVirtualFromConstructorDerived instance.

  3. JavaObject.Construct(ref JniObjectReference, JniObjectReferenceOptions)
    invoked, creating a mapping between the C# instance created in
    (1) and the Java instance created in (2).

  4. CallVirtualFromConstructorBase(int) C# constructor calls
    JniPeerMembers.InstanceMethods.FinishGenericCreateInstance(),
    which eventually invokes JNIEnv::CallNonvirtualVoidMethod()
    with the Java CallVirtualFromConstructorDerived(int) ctor.

  5. Java CallVirtualFromConstructorDerived(int) constructor invokes
    Java CallVirtualFromConstructorBase(int) constructor, which
    invokes CallVirtualFromConstructorDerived.calledFromConstructor().

  6. Marshal method (356485e) for
    CallVirtualFromConstructorBase.CalledFromConstructor() invoked,
    immediately calls JniRuntime.JniValueManager.GetPeer()
    (e288589) to obtain an instance upon which to invoke
    .CalledFromConstructor(), finds the instance mapping from (3),
    invokes
    CallVirtualFromConstructorDerived.CalledFromConstructor()
    override.

  7. Marshal Method for CalledFromConstructor() returns, Java
    CallVirtualFromConstructorBase(int) constructor finishes,
    Java CallVirtualFromConstructorDerived(int) constructor
    finishes, JNIEnv::CallNonvirtualVoidMethod() finishes.

  8. CallVirtualFromConstructorDerived instance finishes construction.

If JNIEnv::NewObject() is used:

  1. CallVirtualFromConstructorDerived(int) constructor begins
    execution, immediately calls base(value).

    Note that this is the first created CallVirtualFromConstructorDerived
    instance, but it hasn't been registered yet.

  2. CallVirtualFromConstructorBase(int) constructor runs, uses
    JNIEnv::NewObject() to construct Java
    CallVirtualFromConstructorDerived instance.

  3. JNIEnv::NewObject() invokes Java
    CallVirtualFromConstructorDerived(int) constructor, which invokes
    CallVirtualFromConstructorBase(int) constructor, which invokes
    CallVirtualFromConstructorDerived.calledFromConstructor().

  4. Marshal method (356485e) for
    CallVirtualFromConstructorBase.CalledFromConstructor() invoked,
    immediately calls JniRuntime.JniValueManager.GetPeer()
    (e288589) to obtain an instance upon which to invoke
    .CalledFromConstructor().

    Here is where things go "off the rails" compared to the
    JNIEnv::AllocObject() code path:

    There is no such instance -- we're still in the middle of
    constructing it! -- so we look for an "activation constructor".

  5. CallVirtualFromConstructorDerived(ref JniObjectReference, JniObjectReferenceOptions)
    activation constructor executed.

    This is the second CallVirtualFromConstructorDerived instance
    created, and registers a mapping from the Java instance that
    we started constructing in (3) to what we'll call the
    "activation intermediary".

    The activation intermediary instance is marked as "Replaceable".

  6. CallVirtualFromConstructorDerived.CalledFromConstructor() method
    override invoked on the activation intermediary.

  7. Marshal Method for CalledFromConstructor() returns, Java
    CallVirtualFromConstructorBase(int) constructor finishes,
    Java CallVirtualFromConstructorDerived(int) constructor
    finishes, JNIEnv::NewObject() returns instance.

  8. C# CallVirtualFromConstructorBase(int) constructor calls
    JavaObject.Construct(ref JniObjectReference, JniObjectReferenceOptions),
    to create a mapping between (3) and (1).

    In .NET for Android, this causes the C# instance created in (1)
    to replace the C# instance created in (5), which allows
    "Replaceable" instance to be replaced.

    In dotnet/java-interop, this replacement didn't happen, which
    meant that ValueManager.PeekPeer(p.PeerReference) would return
    the activation intermediary, not p, which confuses everyone.

  9. CallVirtualFromConstructorDerived instance finishes construction.

For awhile, dotnet/java-interop did not fully support this scenario
around JNIEnv::NewObject(). Additionally, support for using
JNIEnv::NewObject() as part of
JniPeerMembers.JniInstanceMethods.StartCreateInstance() was
removed in dec35f5.

Which brings us to dotnet/android#9862: where there is an observed
"race condition" around Android.App.Application subclass creation.
Two instances of AndroidApp were created, one from the "normal"
app startup:

at crc647fae2f69c19dcd0d.AndroidApp.n_onCreate(Native Method)
at crc647fae2f69c19dcd0d.AndroidApp.onCreate(AndroidApp.java:25)
at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1316)

and another from an androidx.work.WorkerFactory:

at mono.android.TypeManager.n_activate(Native Method)
at mono.android.TypeManager.Activate(TypeManager.java:7)
at crc647fae2f69c19dcd0d.SyncWorker.<init>(SyncWorker.java:23)
at java.lang.reflect.Constructor.newInstance0(Native Method)
at java.lang.reflect.Constructor.newInstance(Constructor.java:343)
at androidx.work.WorkerFactory.createWorkerWithDefaultFallback(WorkerFactory.java:95)

However, what was odd about this "race condition" was that the
second instance created would reliably win!

Further investigation suggested that this was less of a
"race condition" and more a bug in AndroidValueManager, wherein when
"Replaceable" instances were created, an existing instance would
always be replaced, even if the new instance was also Replaceable!
This feels bananas; yes, Replaceable should be replaceable, but the
expectation was that it would be replaced by non-Replaceable
instances, not just any instance that came along later.

Update JniRuntimeJniValueManagerContract to add a new
CreatePeer_ReplaceableDoesNotReplace() test to codify the desired
semantic that Replaceable instances do not replace Replaceable
instances.

Surprisingly, this new test did not fail on java-interop, as
ManagedValueManager.AddPeer() bails early when PeekPeer() finds
a value, while AndroidValueManager.AddPeer() does not bail early.

An obvious fix for CreatePeer_ReplaceableDoesNotReplace() within
dotnet/android would be to adopt the "AddPeer() calls PeekPeer()"
logic from java-interop. The problem is that doing so breaks
ObjectTest.JnienvCreateInstance_RegistersMultipleInstances(),
as seen in dotnet/android#10004!

JnienvCreateInstance_RegistersMultipleInstances() in turn fails
when PeekPeer() is used because follows the JNIEnv::NewObject()
construction codepath!

public CreateInstance_OverrideAbsListView_Adapter (Context context)
  : base (
      JNIEnv.CreateInstance (
        JcwType,
        "(Landroid/content/Context;)V",
        new JValue (context)),
      JniHandleOwnership.TransferLocalRef)
{
  AdapterValue = new ArrayAdapter (context, 0);
}

as JNIEnv.CreateInstance() uses JNIEnv.NewObject().

We thus have a conundrum: how do we fix both
CreatePeer_ReplaceableDoesNotReplace() and
JnienvCreateInstance_RegistersMultipleInstances()?

The answer is to add proper support for the JNIEnv::NewObject()
construction scenario to dotnet/java-interop, which in turn requires
"lowering" the setting of .Replaceable. Previously, we would set
.Replaceable after the activation constructor was invoked:

// dotnet/android TypeManager.CreateInstance(), paraphrasing
var result = CreateProxy (type, handle, transfer);
result.SetJniManagedPeerState (JniManagedPeerStates.Replaceable | JniManagedPeerStates.Activatable);
return result;

This is too late, as during execution of the activation constructor,
the instance thinks it isn't replaceable, and thus creation of a new
instance via the activation constructor will replace an already
existing replaceable instance; it's not until after the constructor
finished executing that we'd set .Replaceable.

To fix this, update JniRuntime.JniValueManager.TryCreatePeerInstance()
to first create an uninitialized instance, set .Replaceable, and
then invoke the activation constructor. This allows
JniRuntime.JniValueManager.AddPeer() to check to see if the new
value is also replaceable, and ignore the replacement if appropriate.

This in turn requires replacing:

partial class /* JniRuntime. */ JniValueManager {
  protected virtual IJavaPeerable? TryCreatePeer ()
      ref JniObjectReference reference,
      JniObjectReferenceOptions options,
      Type type);
}

with:

partial class /* JniRuntime. */ JniValueManager {
  protected virtual bool TryConstructPeer ()
      IJavaPeerable self,
      ref JniObjectReference reference,
      JniObjectReferenceOptions options,
      Type type);
}

This is fine because we haven't shipped TryCreatePeer() in a stable
release yet.

Footnotes

  1. See also Framework Design Guidelines > Constructor Design:

    ❌ AVOID calling virtual members on an object inside its constructor.
    Calling a virtual member will cause the most derived override to be
    called, even if the constructor of the most derived type has not
    been fully run yet.

Context: 3043d89
Context: dotnet/android#9862
Context: dotnet/android#9862 (comment)

In dotnet/android#9862, there is an observed "race condition" around
`Android.App.Application` subclass creation.  *Two* instances of
`AndroidApp` were created, one from the "normal" app startup:

	at crc647fae2f69c19dcd0d.AndroidApp.n_onCreate(Native Method)
	at crc647fae2f69c19dcd0d.AndroidApp.onCreate(AndroidApp.java:25)
	at android.app.Instrumentation.callApplicationOnCreate(Instrumentation.java:1316)

and another from an `androidx.work.WorkerFactory`:

	at mono.android.TypeManager.n_activate(Native Method)
	at mono.android.TypeManager.Activate(TypeManager.java:7)
	at crc647fae2f69c19dcd0d.SyncWorker.<init>(SyncWorker.java:23)
	at java.lang.reflect.Constructor.newInstance0(Native Method)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:343)
	at androidx.work.WorkerFactory.createWorkerWithDefaultFallback(WorkerFactory.java:95)

However, what was odd about this "race condition" was that the
*second* instance created would reliably win!

Further investigation suggested that this was less of a
"race condition" and more a bug in `AndroidRuntime`, wherein when
"Replaceable" instances were created, an existing instance would
*always* be replaced.

Aside: JniManagedPeerStates.Replaceable is from 3043d89:

> `JniManagedPeerStates.Replaceable` … means
> that the Peer instance was created through the activation constructor.
> It additionally means that if two managed instances are created around
> the same Java instance, the non-Replaceable instance will be the one
> returned by JniRuntime.JniValueManager.PeekObject().

What we're observing in dotnet/android#9862 is that while the
Replaceable instance is replaced, it's being replaced by *another*
Replaceable instance!  This feels bananas; yes, Replaceable should
be replacable, but only by *non*-Replaceable instances.

Update `JniRuntimeJniValueManagerContract` to add a new
`CreatePeer_ReplaceableDoesNotReplace()` test to codify the desired
semantic that Replaceable instances do not replace Replaceable
instances.

Surprisingly, this does not fail on java-interop!  Apparently
`ManagedValueManager.AddPeer()` bails early when `PeekPeer()` finds
a value, while `AndroidRuntime.AddPeer()` does not bail early.
jonpryor added a commit to dotnet/android that referenced this pull request Mar 14, 2025
Context: dotnet/java-interop#1323
Context: #9862 (comment)

Does It Build™?

(The expectation is that it *does* build -- only unit tests are changed
in dotnet/java-interop#1323 -- but that the new
`JniRuntimeJniValueManagerContract.cs.CreatePeer_ReplaceableDoesNotReplace()`
test will fail.)`
@Redbone8686
Copy link

Write access

@Redbone8686
Copy link

Write access

jonpryor added a commit to dotnet/android that referenced this pull request Apr 4, 2025
Context: dotnet/java-interop#1323
Context: #9862 (comment)

Does It Build™?

(The expectation is that it *does* build -- only unit tests are changed
in dotnet/java-interop#1323 -- but that the new
`JniRuntimeJniValueManagerContract.cs.CreatePeer_ReplaceableDoesNotReplace()`
test will fail.)`
@jonpryor
Copy link
Member Author

Surprisingly, this does not fail on java-interop! Apparently ManagedValueManager.AddPeer() bails early when PeekPeer() finds a value, while AndroidRuntime.AddPeer() does not bail early.

Turns Out™ that this difference is a cause of dotnet/android#10004, causing JnienvCreateInstance_RegistersMultipleInstances() to fail when using NativeAOT.

Further investigation shows that JnienvCreateInstance_RegistersMultipleInstances() and CreatePeer_ReplaceableDoesNotReplace() are unexpectedly closely related and near opposites. Doh.

@jonpryor jonpryor changed the title [Java.Interop-Tests] Add CreatePeer_ReplaceableDoesNotReplace test [Java.Interop] JNIEnv::NewObject and Replaceable instances Apr 11, 2025
@jonpryor jonpryor marked this pull request as ready for review April 11, 2025 14:38
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.

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

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.

3 participants