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] Avoid Type.GetType() in ManagedPeer #1168

Merged
merged 6 commits into from
Dec 2, 2023

Conversation

jonpryor
Copy link
Member

Fixes: #1165

Context: #1153
Context: #1157

When building for NativeAOT (#1153) or when building .NET Android apps with -p:IsAotcompatible=true (#1157), we get IL2057 warnings from ManagedPeer.cs:

    ManagedPeer.cs(93,19,93,112): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.
    ManagedPeer.cs(156,18,156,65): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.
    ManagedPeer.cs(198,35,198,92): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.

These warnings are because ManagedPeer.Construct() and ManagedPeer.RegisterNativeMembers() use Type.GetType() on string values provided from Java code, and thus the IL trimmer does not have visibility into those strings, and thus cannot reliably determine which types need to be preserved:

// Java Callable Wrapper
/* partial */ class ManagedType
{
  public static final String __md_methods;
  static {
    __md_methods =
      "n_GetString:()Ljava/lang/String;:__export__\n" +
      "";
    net.dot.jni.ManagedPeer.registerNativeMembers (
        /* nativeClass */             ManagedType.class,
        /* assemblyQualifiedName */   "Example.ManagedType, Hello-NativeAOTFromJNI",
        /* methods */                 __md_methods);
  }

  public ManagedType (int p0)
  {
    super ();
    if (getClass () == ManagedType.class) {
      net.dot.jni.ManagedPeer.construct (
          /* self */                  this,
          /* assemblyQualifiedName */ "Example.ManagedType, Hello-NativeAOTFromJNI",
          /* constructorSignature */  "System.Int32, System.Runtime",
          /* arguments */             new java.lang.Object[] { p0 });
    }
  }
}

ManagedPeer.construct() passes two sets of assembly-qualified type names: assemblyQualifiedName contains the type to construct, while constructorSignature contains a :-separated list of assembly-qualified type names for the constructor parameters. Each of these are passed to Type.GetType().

ManagedPeer.registerNativeMembers() passes an assembly-qualified type name to ManagedPeer.RegisterNativeMembers(), which passes the assembly-qualified type name to Type.GetType() to find the type to register native methods for.

If we more strongly rely on JNI signatures, we can remove the need for Java Callable Wrappers to contain assembly-qualified type names entirely, thus removing the need for ManagedPeer to use Type.GetType(), removing the IL2057 warnings.

For ManagedPeer.construct(), assemblyQualifiedName can be replaced with getting the JNI type signature from self.getClass(), and constructorSignature can be replaced with a
JNI method signature of the calling constructor.

For ManagedPeer.registerNativeMembers(), assemblyQualifiedName can be replaced with getting the JNI type signature from nativeClass.

// Java Callable Wrapper
/* partial */ class ManagedType
{
  public static final String __md_methods;
  static {
    __md_methods =
      "n_GetString:()Ljava/lang/String;:__export__\n" +
      "";
    net.dot.jni.ManagedPeer.registerNativeMembers (
        /* nativeClass */             ManagedType.class,
        /* methods */                 __md_methods);
  }

  public ManagedType (int p0)
  {
    super ();
    if (getClass () == ManagedType.class) {
      net.dot.jni.ManagedPeer.construct (
          /* self */                  this,
          /* constructorSignature */  "(I)V",
          /* arguments */             new java.lang.Object[] { p0 });
    }
  }
}

Furthermore, if we add [DynamicallyAccessedMembers] to JniRuntime.JniTypeManager.GetType(), we can fix some IL2075 warnings which appeared after fixing the IL2057 warnings.

Aside: Excising assembly-qualified type names from Java Callable Wrappers had some "interesting" knock-on effects in the unit tests, requiring that more typemap information be explicitly provided. (This same information was implicitly provided before, via the provision of assembly-qualified type names everywhere…)

Fixes: #1165

Context: #1153
Context: #1157

When building for NativeAOT (#1153) or when building .NET Android
apps with `-p:IsAotcompatible=true` (#1157), we get [IL2057][0]
warnings from `ManagedPeer.cs`:

        ManagedPeer.cs(93,19,93,112): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.
        ManagedPeer.cs(156,18,156,65): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.
        ManagedPeer.cs(198,35,198,92): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type.

These warnings are because `ManagedPeer.Construct()` and
`ManagedPeer.RegisterNativeMembers()` use `Type.GetType()` on string
values provided *from Java code*, and thus the IL trimmer does not
have visibility into those strings, and thus cannot reliably
determine which types need to be preserved:

	// Java Callable Wrapper
	/* partial */ class ManagedType
	{
	  public static final String __md_methods;
	  static {
	    __md_methods =
	      "n_GetString:()Ljava/lang/String;:__export__\n" +
	      "";
	    net.dot.jni.ManagedPeer.registerNativeMembers (
	        /* nativeClass */             ManagedType.class,
	        /* assemblyQualifiedName */   "Example.ManagedType, Hello-NativeAOTFromJNI",
	        /* methods */                 __md_methods);
	  }

	  public ManagedType (int p0)
	  {
	    super ();
	    if (getClass () == ManagedType.class) {
	      net.dot.jni.ManagedPeer.construct (
	          /* self */                  this,
	          /* assemblyQualifiedName */ "Example.ManagedType, Hello-NativeAOTFromJNI",
	          /* constructorSignature */  "System.Int32, System.Runtime",
	          /* arguments */             new java.lang.Object[] { p0 });
	    }
	  }
	}

`ManagedPeer.construct()` passes *two* sets of assembly-qualified
type names: `assemblyQualifiedName` contains the type to construct,
while `constructorSignature` contains a `:`-separated list of
assembly-qualified type names for the constructor parameters.
Each of these are passed to `Type.GetType()`.

`ManagedPeer.registerNativeMembers()` passes an assembly-qualified
type name to `ManagedPeer.RegisterNativeMembers()`, which passes the
assembly-qualified type name to `Type.GetType()` to find the type
to register native methods for.

If we more strongly rely on JNI signatures, we can remove the need
for Java Callable Wrappers to contain assembly-qualified type names
entirely, thus removing the need for `ManagedPeer` to use
`Type.GetType()`, removing the IL2057 warnings.

For `ManagedPeer.construct()`, `assemblyQualifiedName` can be
replaced with getting the JNI type signature from `self.getClass()`,
and `constructorSignature` can be replaced with a
*JNI method signature* of the calling constructor.

For `ManagedPeer.registerNativeMembers()`, `assemblyQualifiedName`
can be replaced with getting the JNI type signature from `nativeClass`.

	// Java Callable Wrapper
	/* partial */ class ManagedType
	{
	  public static final String __md_methods;
	  static {
	    __md_methods =
	      "n_GetString:()Ljava/lang/String;:__export__\n" +
	      "";
	    net.dot.jni.ManagedPeer.registerNativeMembers (
	        /* nativeClass */             ManagedType.class,
	        /* methods */                 __md_methods);
	  }

	  public ManagedType (int p0)
	  {
	    super ();
	    if (getClass () == ManagedType.class) {
	      net.dot.jni.ManagedPeer.construct (
	          /* self */                  this,
	          /* constructorSignature */  "(I)V",
	          /* arguments */             new java.lang.Object[] { p0 });
	    }
	  }
	}

Furthermore, if we add `[DynamicallyAccessedMembers]` to
`JniRuntime.JniTypeManager.GetType()`, we can fix some [IL2075][1]
warnings which appeared after fixing the IL2057 warnings.

Aside: Excising assembly-qualified type names from Java Callable
Wrappers had some "interesting" knock-on effects in the unit tests,
requiring that more typemap information be explicitly provided.
(This same information was *implicitly* provided before, via the
provision of assembly-qualified type names everywhere…)

[0]: https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-warnings/IL2057
[1]: https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-warnings/il2075
@jonpryor jonpryor force-pushed the dev/jonp/jonp-ManagedPeer-il2057 branch from 0fab1d3 to b75ad19 Compare November 25, 2023 17:47
@jonpryor
Copy link
Member Author

There is a remaining problem with this approach: there is no requirement of a 1:1 mapping between Java types and managed types. There are already scenarios in which the mapping is ambiguous, and the pre-existing solution of using assembly-qualified type names resolved the ambiguity: arrays.

Consider Java int[]: this has a JNI signature of [I, and can be bound as either int[] or Java.Interop.JavaInt32Array. The approach suggested in this PR does not handle that ambiguity, and will arbitrarily pick one (not sure which at the moment), preventing the other from being usable in constructor signatures.

This will be addressed in a future commit to this PR.

Context: #1168 (comment)

> There is a remaining problem with this approach: there is no
> requirement of a 1:1 mapping between Java types and managed types.

A useful example of that is with arrays: a Java `int[]` array can be
treated as one of the following types, in various contexts:

  * C# `int[]`
  * `JavaArray<int>`
  * `JavaPrimitiveArray<int>`
  * `JavaInt32Array`

Update `JavaCallableExample` to demonstrate this:

	partial class JavaCallableExample {
	    [JavaCallableConstructor(SuperConstructorExpression="")]
	    public JavaCallableExample (int[] a, JavaInt32Array b);
	}

The intention is twofold:

 1. This should result in a Java Callable Wrapper constructor with
    signature `JavaCallableExample(int[] p0, int[] p1)`, and

 2. Java code should be able to invoke this constructor.

Turns out, neither of these worked when `Type.GetType()` is not used
for constructor argument lookup: `JavaCallableWrapperGenerator`
didn't fully support e.g. `[JniTypeSignature("I", ArrayRank=1)]`,
so didn't know what to do with `JavaInt32Array`.  Once (1) was fixed,
(2) would fail because
`JniRuntime.JniTypeManager.GetType(JniTypeSignature.Parse("[I"))`
would return `JavaPrimitiveArray<int>`, which wasn't used in
`JavaCallableExample`, resulting in:

	System.NotSupportedException : Unable to find constructor
	  Java.InteropTests.JavaCallableExample(Java.Interop.JavaPrimitiveArray`1[[System.Int32, System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]], Java.Interop.JavaPrimitiveArray`1[[System.Int32, System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]).
	  Please provide the missing constructor.
	  ----> Java.Interop.JniLocationException : Exception of type 'Java.Interop.JniLocationException' was thrown.
	  Stack Trace:
	     at Java.Interop.ManagedPeer.GetConstructor(JniTypeManager typeManager, Type type, String signature, Type[]& parameterTypes)
	   at Java.Interop.ManagedPeer.Construct(IntPtr jnienv, IntPtr klass, IntPtr n_self, IntPtr n_constructorSignature, IntPtr n_constructorArguments)
	…
	  --- End of managed Java.Interop.JavaException stack trace ---
	java.lang.Throwable
		at net.dot.jni.ManagedPeer.construct(Native Method)
		at net.dot.jni.test.JavaCallableExample.<init>(JavaCallableExample.java:32)
		at net.dot.jni.test.UseJavaCallableExample.test(UseJavaCallableExample.java:8)

Intent (2) had two causes:

 1. Using `JniRuntime.JniTypeManager.GetType()` can only return a
    single type, but there are multiple possible matches.  Thus,
    we need to instead use `JniRuntime.JniTypeManager.GetTypes()`.

 2. `JniRuntime.JniTypeManager.GetTypes()` was incomplete, which is
    a longstanding limitation from f60906c: for `[I`, it would only
    return `JavaPrimitiveArray<int>` and `int[]`, in that order.

Fix both of these.
`JniRuntime.JniTypeManager.GetTypes(JniTypeSignature.Parse("[I"))`
will now include:

  * `JavaArray<int>`
  * `JavaPrimitiveArray<int>`
  * `JavaInt32Array`
  * `int[]`

This now allows the `JavaCallableExample` constructor to be invoked
from Java.

Because `ManagedPeer.Construct()` is now doing so much extra work
in order to find the `ConstructorInfo` to invoke, cache the lookups.
(Technically this is a "memory leak," as cache entries are never
removed.)
Copy link
Member

@jonathanpeppers jonathanpeppers left a comment

Choose a reason for hiding this comment

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

My general questions about performance depend on how often these would be called at runtime by a MAUI app?

If it's at build time -- or rare cases, we probably won't even notice these.

Comment on lines +44 to +46
foreach (var e in JniPrimitiveArrayTypes) {
if (Array.IndexOf (e.ArrayTypes, type) < 0)
continue;
Copy link
Member

Choose a reason for hiding this comment

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

Does this happen on device? This looks like it is O(N^2). Is there a way to rework the data to be a Dictionary<Type, JniPrimitiveArrayInfo> to make this lookup faster? I guess you'd also want the keys to map to duplicate entries as the values.

Copy link
Member Author

@jonpryor jonpryor Nov 28, 2023

Choose a reason for hiding this comment

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

Probably not, and we're talking O(N**2) on 8 entries, each containing 4 elements within e.ArrayTypes, or 32 entries total. O(N**2) on 32 entries should be negligible.

Is there a way to rework the data to be a Dictionary<Type, JniPrimitiveArrayInfo> to make this lookup faster?

Yes, but as this is static data, it'll be constructed during app startup, even for .NET Android (unless we make it conditional, and please no?), so I'm trying to figure out how to minimize startup overhead.

My assumption (un-timed) is that creating an array of structs will be faster than populating a Dictionary, particularly when we're dealing with so few elements (8).

Copy link
Member Author

@jonpryor jonpryor Nov 28, 2023

Choose a reason for hiding this comment

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

So, about that init vs. lookup time…

Repro App ```csharp

using System.Diagnostics;

const int CreationCount = 1000000;
const int LookupCount = 1000000;

var a = Stopwatch.StartNew ();
for (int i = 0; i < CreationCount; ++i) {
CreateArray ();
}
a.Stop ();

var d = Stopwatch.StartNew ();
for (int i = 0; i < CreationCount; ++i) {
CreateDict ();
}
d.Stop ();

Console.WriteLine ($"Array Creation: {a.ElapsedMilliseconds}ms");
Console.WriteLine ($" Dict Creation: {d.ElapsedMilliseconds}ms");

var av = CreateArray ();
a = Stopwatch.StartNew ();
for (int i = 0; i < LookupCount; ++i) {
TryArrayLookup (av, typeof (int));
}
a.Stop ();

var bv = CreateDict ();
for (int i = 0; i < LookupCount; ++i) {
TryDictLookup (bv, typeof (int));
}
d.Stop ();
Console.WriteLine ($"Array Lookup: {a.ElapsedMilliseconds}ms");
Console.WriteLine ($" Dict Lookup: {d.ElapsedMilliseconds}ms");

static JniPrimitiveArrayInfo[] CreateArray ()
{
return new JniPrimitiveArrayInfo[]{
new ("Z", typeof (Boolean), typeof (Boolean[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaBooleanArray)),
new ("B", typeof (SByte), typeof (SByte[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaSByteArray)),
new ("C", typeof (Char), typeof (Char[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaCharArray)),
new ("S", typeof (Int16), typeof (Int16[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt16Array)),
new ("I", typeof (Int32), typeof (Int32[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt32Array)),
new ("J", typeof (Int64), typeof (Int64[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt64Array)),
new ("F", typeof (Single), typeof (Single[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaSingleArray)),
new ("D", typeof (Double), typeof (Double[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaDoubleArray)),
};
}

static bool TryArrayLookup (JniPrimitiveArrayInfo[] array, Type type)
{
foreach (var e in array) {
if (Array.IndexOf (e.ArrayTypes, type) < 0)
continue;
return true;
}
return false;
}

static Dictionary<Type, JniPrimitiveArrayInfo> CreateDict ()
{
return new Dictionary<Type, JniPrimitiveArrayInfo> () {
[typeof (Boolean)] = new ("Z", typeof (Boolean), typeof (Boolean[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaBooleanArray)),
[typeof (SByte) ] = new ("B", typeof (SByte), typeof (SByte[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaSByteArray)),
[typeof (Char) ] = new ("C", typeof (Char), typeof (Char[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaCharArray)),
[typeof (Int16) ] = new ("S", typeof (Int16), typeof (Int16[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt16Array)),
[typeof (Int32) ] = new ("I", typeof (Int32), typeof (Int32[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt32Array)),
[typeof (Int64) ] = new ("J", typeof (Int64), typeof (Int64[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaInt64Array)),
[typeof (Single) ] = new ("F", typeof (Single), typeof (Single[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaSingleArray)),
[typeof (Double) ] = new ("D", typeof (Double), typeof (Double[]), typeof (JavaArray), typeof (JavaPrimitiveArray), typeof (JavaDoubleArray)),
};
}

static bool TryDictLookup (Dictionary<Type, JniPrimitiveArrayInfo> dict, Type type)
{
foreach (var v in dict.Values) {
if (Array.IndexOf (v.ArrayTypes, type) < 0)
return true;
}
return false;
}

class JavaArray {}
class JavaPrimitiveArray : JavaArray {}
class JavaBooleanArray : JavaPrimitiveArray {}
class JavaSByteArray : JavaPrimitiveArray {}
class JavaCharArray : JavaPrimitiveArray {}
class JavaInt16Array : JavaPrimitiveArray {}
class JavaInt32Array : JavaPrimitiveArray {}
class JavaInt64Array : JavaPrimitiveArray {}
class JavaSingleArray : JavaPrimitiveArray {}
class JavaDoubleArray : JavaPrimitiveArray {}

readonly struct JniPrimitiveArrayInfo {
public readonly JniTypeSignature JniTypeSignature;
public readonly Type PrimitiveType;
public readonly Type[] ArrayTypes;
public JniPrimitiveArrayInfo (string jniSimpleReference, Type primitiveType, params Type[] arrayTypes)
{
JniTypeSignature = new JniTypeSignature (jniSimpleReference, arrayRank: 1, keyword: true);
PrimitiveType = primitiveType;
ArrayTypes = arrayTypes;
}
}

struct JniTypeSignature {
public string SimpleName;
public int ArrayRank;
public bool Keyword;
public JniTypeSignature (string simpleName, int arrayRank, bool keyword)
{
SimpleName = simpleName;
ArrayRank = arrayRank;
Keyword = keyword;
}
}


</details>

Copy link
Member Author

Choose a reason for hiding this comment

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

Apparently <details/> doesn't work like i thought, so here's the execution + summary:

Results:

% dotnet run
Array Creation: 646ms
 Dict Creation: 1131ms
Array Lookup: 29ms
 Dict Lookup: 1131ms

% dotnet run -c Release
Array Creation: 389ms
 Dict Creation: 770ms
Array Lookup: 21ms
 Dict Lookup: 770ms

As expected, array lookup is faster -- half the time! -- and lookup is also much faster with arrays.

That said, this investigation did present a bug in GetBuiltInTypeArraySignature(): it shouldn't have been looking at .ArrayTypes, but instead .PrimitiveType, because the callsite in GetTypeSignature(Type) "unwraps" arrays, which kinda makes that particular codepath unusable, as in GetBuiltInTypeArraySignature() should just be removed entirely. 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

…and GetBuiltInTypeArraySignature() is required; trying to remove it breaks unit tests. Which means my "repro app" isn't accurate on the lookup side. After updating it, arrays are still faster:

% dotnet run -c Release
Array Creation: 392ms
 Dict Creation: 753ms
Array Lookup: 156ms
 Dict Lookup: 753ms

Copy link
Member

Choose a reason for hiding this comment

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

Seeing the manual work involved to test, should we have a BenchmarkDotNet project in this repo?

It seems like it would be useful to commit your benchmark above, and we'd have the option to run it later. BDN also does appropriate warmup, runs out of process, does some math, that might generally make it better than a manual benchmark.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, we should probably have one, and it should probably be in tests/Java.Interop-PerformanceTests (and/or I could "just" add the above app to Java.Interop-PerformanceTests…).

for (int c = constructors.Count; c > 0; --c) {
var parameters = constructors [c-1].GetParameters ();
for (int i = 0; i < parameters.Length; ++i) {
if (!candidateParameterTypes [i].Contains (parameters [i].ParameterType)) {
Copy link
Member

Choose a reason for hiding this comment

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

Should candidateParameterTypes be a HashSet? So Contains() can be faster?

Copy link
Member Author

Choose a reason for hiding this comment

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

As mentioned above, I'm concerned about app startup time overheads, and I thus believe/assert that HashSet/Dictionary/etc. creation and population will take much more time than an array, and because this array is (1) small, and (2) never changed, init time should be minimal. (But I should verify this.)

Copy link
Member Author

Choose a reason for hiding this comment

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

I think I was confused above; this is from ManagedPeer.Construct(), which isn't part of app startup, and no static data is in the picture.

Despite being confused, I'm think I'm more or less right, though: each element of candidateParameterTypes will be a small list, likely no more than 5 elements. I believe that the overhead of a HashSet vs. a List at such small sizes is not warranted. Arrays are fast!

@jpobst
Copy link
Contributor

jpobst commented Nov 28, 2023

Should we run an XA test PR on this before merging?

@jonpryor
Copy link
Member Author

My general questions about performance depend on how often these would be called at runtime by a MAUI app?

Right now, nothing; this codepath is only hit by "JavaInterop1" Java Callable Wrappers, not Xamarin.Android "XAJavaInterop1" Java Callable Wrappers. (The codepath is present in .NET Android; it's just that only our unit tests will actually exercise it in practice.) Rephrased: the only way to hit it is for Java code to call ManagedPeer.construct(), and .NET Android apps use TypeManager.Activate(). No "real" .NET Android apps currently use ManagedPeer.construct().

This is thus a more "exploratory quest" to answer the question: so what about Type.GetType() (IL2057), and what can we do instead? (TL;DR: yes, it's possible. It's also fugly. I have no idea if it's actually worth it.) That question needs to be "appropriately answered" in order to support NativeAOT, if that is ever to happen.

@jonpryor
Copy link
Member Author

@jpobst asked

Should we run an XA test PR on this before merging?

Yes, but given how we have a unit test failing here, I'll need to fix that first. We might be able to consider merging by end-of-year? November is out.

Attempting to return an `int[]` from a `[JavaCallable]` results
in invalid IL:

	% $HOME/.dotnet/tools/ilverify bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll \
	    --tokens --system-module System.Private.CoreLib \
	    -r 'bin/TestDebug-net7.0/*.dll' \
	    -r '/usr/local/share/dotnet/shared/Microsoft.NETCore.App/7.0.10/*.dll'
	[IL]: Error [StackUnderflow]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x0000002F] Stack underflow.

The offending IL is due to:

	call JavaInt32Array.ValueMarshaler..ctor

which doesn't make sense; one should use `newobj` as part of
`.ctor` invocations.

Find the problem in `CecilCompilerExpressionVisitor`, attempt to fix
it, re-run, and:

	[IL]: Error [MethodAccess]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x0000002F] Method is not visible.
	[IL]: Error [StackUnexpected]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x00000052][found value '[Java.Interop]Java.Interop.JniValueMarshalerState'][expected address of '[Java.Interop]Java.Interop.JniValueMarshalerState'] Unexpected type on the stack.
	[IL]: Error [StackUnexpected]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x00000057][found value '[Java.Interop]Java.Interop.JniObjectReference'][expected address of '[Java.Interop]Java.Interop.JniObjectReference'] Unexpected type on the stack.
	[IL]: Error [StackUnexpected]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x00000064][found value '[Java.Interop]Java.Interop.JniValueMarshalerState'][expected address of '[Java.Interop]Java.Interop.JniValueMarshalerState'] Unexpected type on the stack.
	[IL]: Error [StackUnexpected]: […/bin/TestDebug-net7.0/Java.Interop.Export-Tests.dll : .__<$>_jni_marshal_methods::n_GetA(native int, native int)][offset 0x000000D0][found value '[Java.Interop]Java.Interop.JniValueMarshalerState'][expected address of '[Java.Interop]Java.Interop.JniValueMarshalerState'] Unexpected type on the stack.

…which is why `jnimarshalmethod-gen` is, at best, a "research project"
("toy"), and not an Actual Product.

(But `int[]` marshaling works *without* `jnimarshalmethod-gen`!)

Avoid the problem, and return an `int` instead of an `int[]`.
Copy link
Member

@jonathanpeppers jonathanpeppers left a comment

Choose a reason for hiding this comment

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

Seeing that we tested out the performance, I don't really have any other concerns here. 👍

Comment on lines +390 to +392
var isKeyProp = attr.Properties.FirstOrDefault (p => p.Name == "IsKeyword");
var isKeyword = isKeyProp.Name != null && ((bool) isKeyProp.Argument.Value) == true;
var arrRankProp = attr.Properties.FirstOrDefault (p => p.Name == "ArrayRank");
Copy link
Member

Choose a reason for hiding this comment

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

Is the C# attribute available where we could use nameof()? The resulting IL would be the same, but might help typos.

Copy link
Member Author

Choose a reason for hiding this comment

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

No, this assembly doesn't have a reference to Java.Interop.dll.

jonpryor added a commit to jonpryor/xamarin-android that referenced this pull request Nov 29, 2023
…Peer-il2057

Sanity check: PublicApiAnalyzers!
Context: #1168 (comment)

Why arrays and not dictionaries, especially considering that
7079166 involves an O(N\*\*2) lookup?

For two reasons:

 1. the `JniPrimitiveArrayTypes` array is created for *all* apps,
    and thus contributes to app startup overheads for all apps.
    Thus, we want to minimize creation time.
 2. the data is *small*: 8 entries within `JniPrimitiveArrayTypes`,
    each of which contains an array of 4 elements.  *N* is *small*
    within O(N\*\*2).

While this works for a gut feeling, time for some performance tests!

Update `Java.Interop-PerformanceTests` to measure the timing overhead
to create 1000000 instances of `JniPrimitiveArrayInfo[]` vs. the time
to create 1000000 instances of `Dictionary<Type, JniPrimitiveArrayInfo>`.

	% dotnet test --logger "console;verbosity=detailed" bin/TestDebug-net7.0/Java.Interop-PerformanceTests.dll
	…
	# CreationTiming: Array Creation: 403ms
	# CreationTiming:  Dict Creation: 1483ms
	…
	# LookupTiming: Array Lookup: 97ms
	# LookupTiming:  Dict Lookup: 173ms

Array creation is pretty fast compared to a Dictionary, with
Array creation taking 27% the time of Dictionaries.

Lookup is also pretty fast, verifying that when *N* is small,
O(N\*\*2) isn't much to worry about; lookup takes half the time.

Note that this is for a *Debug* build; Release builds should
make things better for Arrays.
@jonpryor jonpryor merged commit 005c914 into main Dec 2, 2023
@jonpryor jonpryor deleted the dev/jonp/jonp-ManagedPeer-il2057 branch December 2, 2023 15:16
@jonpryor
Copy link
Member Author

@jpobst, @jonathanpeppers: @Suchiman on https://libera.chat / #csharp mentioned an alternate way to make Type.GetType() work in a NativeAOT context:

  1. Create a new method that contains all the Type.GetType() invocations that are needed.
  2. The method in (1) needs to be called, but doesn't need to actually "do" anything; it can early return (but it the early return needs to be conditioned on a parameter):
    void TellNativeAotAboutTypes(bool loadTypes)
    {
        if (!loadTypes)
            return;
        Type.GetType ("System.Int32, System.Runtime");
        // …
    }
  3. You can then Type.GetType() a non-const value and the type will be found and loaded.

For example:

using System.Text;

TellNativeAotAboutTypes ();

var t = Type.GetType (GetTypeName (), throwOnError: true);

Console.WriteLine (t);

string GetTypeName() =>
    new StringBuilder()
    .Append ("System.")
    .Append ("Int32")
    .Append (", System.Runtime")
    .ToString ();

void TellNativeAotAboutTypes (bool load = false)
{
    if (!load)
        return;
    Type.GetType ("System.Int32, System.Runtime");
}

The above app works at runtime. If you remove the TellNativeAotAboutTypes() invocation, or remove the bool load=false parameter while keeping the early return, then execution fails with:

Unhandled Exception: System.IO.FileNotFoundException: Could not resolve assembly 'System.Runtime'.
   at System.Reflection.TypeNameParser.ResolveAssembly(String) + 0x97
   at System.Reflection.TypeNameParser.GetType(String, ReadOnlySpan`1, String) + 0x32
   at System.Reflection.TypeNameParser.NamespaceTypeName.ResolveType(TypeNameParser&, String) + 0x17
   at System.Reflection.TypeNameParser.GetType(String, Func`2, Func`4, Boolean, Boolean, Boolean, String) + 0x99
   at Program.<Main>$(String[] args) + 0x3d
   at native-aot-with-Suchiman-workaround!<BaseAddress>+0x120dbc

However, compilation still generates an IL2057 warning:

% dotnet publish -c Release -r osx-x64
…
…/Program.cs(8,9): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'. It's not possible to guarantee the availability of the target type. 

So the good news is that we have a more plausible workaround in Java.Interop that doesn't require changing TypeManager.Activate() to do what was done in this PR, and further suggests that we could revert #1168 (this PR).

The bad news is that this described workaround means we have IL2057 trim warnings, but we could [UnconditionalSuppressMessage]/etc. the IL2057 warning in that context.

@jonathanpeppers, @jpobst: what do you think we should do? Revert #1168 so that ManagedPeer is semantically closer to TypeManager and require this "pre-register types" workaround? Or keep #1168?

@MichalStrehovsky
Copy link
Member

Instead of TellNativeAotAboutTypes, could you replace the uses of Type.GetType with an enum? For example:

Type GetTheType(TypeId id) =>
    id switch
    {
        Id.Int32 => typeof(int),
        // or Id.Int32 => Type.GetType("System.Int32, System.Runtime"),
    }

This way you don't have to do any suppressions, it's trim safe by construction.

Alternatively:

var dict = new Dictionary<string, Type>
{
    { "System.Int32, System.Runtime", typeof(int) },
    ...
}

I assume you're going to do something with the type (like Activator.CreateInstance it), so you'll probably actually need something along the lines of this:

struct DictionaryOfConstructableTypes
{
    Dictionary<string, Type> _inner;

    public void Add(string s, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type t) => _inner.Add(s, t);

    // We need to do a suppression here because static analysis cannot guarantee
    // whatever comes out of the dictionary has the constructors kept, but it's easy to audit
    // that it is in fact the case manually because the method that adds them is annotated to keep them.
    [UnconditionalSuppressMessage(...)]
    [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
    public Type Get(string s) => _inner[s];
}

This way you can pass anything that comes out of this dictionary to Activator.CreateInstance because the static analysis can guarantee both the type and the constructor are available.

@jonpryor
Copy link
Member Author

(The perils of comments after closing the PR. I didn't see @MichalStrehovsky 's comment until just now…)

@MichalStrehovsky: I lack the imagination to see how replacing Type.GetType() with an enum would work, in practice.

Firstly, there's architecture: "core" assemblies like Java.Interop.dll need this "alternate mechanism", but your hypothetical enum cannot be constructed until app build time, when all assemblies in the app are known. By definition, Java.Interop.dll cannot reference such an enum, unless the enum is empty, at which point it's basically an int. "Trim-safe by construction" it's not.

Secondly, generated Java code needs to be able to refer to C# types, and this mapping is done at packaging time. With enough hand-waving one can imagine a world where at app packaging time we create an int::type mapping, hard-coding these int values into the Java code.

Thirdly, this isn't incremental: change an assembly, add a new type, and all your previous int::type mappings are potentially wrong. Rebuilding the world is slow, and something we need to avoid in Debug builds. This in turn might be solvable, but it doesn't look easy in the 5s of mental energy I gave it.

It also doesn't quite consider the sheer number of entries we're looking at. The Mono.Android-Tests unit tests (debug builds, unlinked) contains 8274 typemap entries, and that isn't a MAUI app, nor does it reference AndroidX, either/both of which will explode the number of types involved. (A default MAUI app, debug build, contains 14309 typemap entries.)

The Dictionary<string, Type> approach appears viable, except for scale. Again, 8274 entries, and this dict needs to be initialized during app startup in order for anything to work properly. That's an actually significant amount of startup overhead, which is scary. (Related.)

The approach committed in 005c914 (which may or may not need to be reverted) builds atop the typemap infrastructure which already exists, and needs to exist, and has been optimized a fair bit. The alternate approach has the benefit of needing fewer changes overall, and avoids the need for me to construct a gigantic multi-thousand entry dict mapping strings to types. (Native AOT will presumably need to create such a mapping, somehow, but that's Not My Problem™ {until it is, due to perf hits}.)

jonpryor added a commit that referenced this pull request Jan 24, 2024
Context: dotnet/android#8543
Context: dotnet/android#8625
Context: #1168
Context: def5bc0
Context: 005c914

dotnet/android#8543 tested PR #1168, was Totally Green™ --
finding no issues -- and so we merged PR #1168 into 005c914.

Enter dotnet/android#8625, which bumps xamarin-android to
use def5bc0, which includes 005c914.  dotnet/android#8625
contains *failing unit tests* (?!), including
`Java.InteropTests.InvokeVirtualFromConstructorTests()`:

	Java.Lang.LinkageError : net.dot.jni.test.CallVirtualFromConstructorDerived
	----> System.NotSupportedException : Could not find System.Type corresponding to Java type JniTypeSignature(TypeName=net/dot/jni/test/CallVirtualFromConstructorDerived ArrayRank=0 Keyword=False) .
	   at Java.Interop.JniEnvironment.StaticMethods.GetStaticMethodID(JniObjectReference , String , String )
	   at Java.Interop.JniType.GetStaticMethod(String , String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.GetMethodInfo(String , String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.GetMethodInfo(String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.InvokeObjectMethod(String , JniArgumentValue* )
	   at Java.InteropTests.CallVirtualFromConstructorDerived.NewInstance(Int32 value)
	   at Java.InteropTests.InvokeVirtualFromConstructorTests.ActivationConstructor()
	   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
	   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object , BindingFlags )
	  --- End of managed Java.Lang.LinkageError stack trace ---
	java.lang.NoClassDefFoundError: net.dot.jni.test.CallVirtualFromConstructorDerived
		at crc643df67da7b13bb6b1.TestInstrumentation_1.n_onStart(Native Method)
		at crc643df67da7b13bb6b1.TestInstrumentation_1.onStart(TestInstrumentation_1.java:35)
		at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2189)
	Caused by: android.runtime.JavaProxyThrowable: [System.NotSupportedException]: Could not find System.Type corresponding to Java type JniTypeSignature(TypeName=net/dot/jni/test/CallVirtualFromConstructorDerived ArrayRank=0 Keyword=False) .
		at Java.Interop.ManagedPeer.GetTypeFromSignature(Unknown Source:0)
		at Java.Interop.ManagedPeer.RegisterNativeMembers(Unknown Source:0)
		at net.dot.jni.ManagedPeer.registerNativeMembers(Native Method)
		at net.dot.jni.test.CallVirtualFromConstructorDerived.<clinit>(CallVirtualFromConstructorDerived.java:12)
		... 3 more

	--NotSupportedException
	   at Java.Interop.ManagedPeer.GetTypeFromSignature(JniTypeManager , JniTypeSignature , String )
	   at Java.Interop.ManagedPeer.RegisterNativeMembers(IntPtr jnienv, IntPtr klass, IntPtr n_nativeClass, IntPtr n_methods)

:shocked-pikachu-face: (But dotnet/android#8543 was green!)

The problem is twofold:

 1. 005c914 now requires the presence of typemap entries from e.g.
    `Java.InteropTests.CallVirtualFromConstructorDerived` to
    `net.dot.jni.test.CallVirtualFromConstructorDerived`.

 2. `Java.Interop.Tools.JavaCallableWrappers` et al doesn't create
    typemap entries for `Java.Interop.JavaObject` subclasses which
    have `[JniTypeSignature]`.

Consequently, our units tests fail (and apparently weren't *run* on
dotnet/android#8543?!  Still not what happened.)

Fix typemap generation by adding a new `TypeDefinition.HasJavaPeer()`
extension method to replace all the `.IsSubclassOf("Java.Lang.Object")`
and similar checks, extending it to also check for
`Java.Interop.JavaObject` and `Java.Interop.JavaException` base types.
(Continuing to use base type checks is done instead of just relying
on implementation of `Java.Interop.IJavaPeerable` as a performance
optimization, as there could be *lots* of interface types to check.)

Additionally, @jonathanpeppers -- while trying to investigate all
this -- ran across a build failure:

	obj\Debug\net9.0-android\android\src\java\lang\Object.java(7,15): javac.exe error JAVAC0000:  error: cyclic inheritance involving Object

This suggests that `Java.Interop.Tools.JavaCallableWrappers` was
encountering `Java.Interop.JavaObject` -- or some other type which
has `[JniTypeSignature("java/lang/Object")]` -- which is why
`java/lang/Object.java` was being generated.

Audit all `[JniTypeSignature]` attributes, and add
`GenerateJavaPeer=false` to all types which should *not* hava a
Java Callable Wrapper generated for them.  This includes nearly
everything within `Java.Interop-Tests.dll`.  (We want the typemaps!
We *don't* want generated Java source, as we have hand-written Java
peer types for those tests.)

---

Aside: this project includes [T4 Text Templates][0].  To regenerate
the output files *without involving Visual Studio*, you can install
the [`dotnet-t4`][1] tool:

	$ dotnet tool install --global dotnet-t4

then run it separately for each `.tt` file:

	$HOME/.dotnet/tools/t4 -o src/Java.Interop/Java.Interop/JavaPrimitiveArrays.cs \
	  src/Java.Interop/Java.Interop/JavaPrimitiveArrays.tt

[0]: https://learn.microsoft.com/visualstudio/modeling/code-generation-and-t4-text-templates?view=vs-2022
[1]: https://www.nuget.org/packages/dotnet-t4/
jonpryor added a commit that referenced this pull request Feb 2, 2024
…1181)

Context: dotnet/android#8543
Context: dotnet/android#8625
Context: dotnet/android#8681
Context: #1168
Context: def5bc0
Context: 005c914

dotnet/android#8543 tested PR #1168, was Totally Green™ --
finding no issues -- and so we merged PR #1168 into 005c914.

Enter dotnet/android#8625, which bumps xamarin-android to
use def5bc0, which includes 005c914.  dotnet/android#8625
contains *failing unit tests* (?!), including
`Java.InteropTests.InvokeVirtualFromConstructorTests()`:

	Java.Lang.LinkageError : net.dot.jni.test.CallVirtualFromConstructorDerived
	----> System.NotSupportedException : Could not find System.Type corresponding to Java type JniTypeSignature(TypeName=net/dot/jni/test/CallVirtualFromConstructorDerived ArrayRank=0 Keyword=False) .
	   at Java.Interop.JniEnvironment.StaticMethods.GetStaticMethodID(JniObjectReference , String , String )
	   at Java.Interop.JniType.GetStaticMethod(String , String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.GetMethodInfo(String , String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.GetMethodInfo(String )
	   at Java.Interop.JniPeerMembers.JniStaticMethods.InvokeObjectMethod(String , JniArgumentValue* )
	   at Java.InteropTests.CallVirtualFromConstructorDerived.NewInstance(Int32 value)
	   at Java.InteropTests.InvokeVirtualFromConstructorTests.ActivationConstructor()
	   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
	   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object , BindingFlags )
	  --- End of managed Java.Lang.LinkageError stack trace ---
	java.lang.NoClassDefFoundError: net.dot.jni.test.CallVirtualFromConstructorDerived
		at crc643df67da7b13bb6b1.TestInstrumentation_1.n_onStart(Native Method)
		at crc643df67da7b13bb6b1.TestInstrumentation_1.onStart(TestInstrumentation_1.java:35)
		at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2189)
	Caused by: android.runtime.JavaProxyThrowable: [System.NotSupportedException]: Could not find System.Type corresponding to Java type JniTypeSignature(TypeName=net/dot/jni/test/CallVirtualFromConstructorDerived ArrayRank=0 Keyword=False) .
		at Java.Interop.ManagedPeer.GetTypeFromSignature(Unknown Source:0)
		at Java.Interop.ManagedPeer.RegisterNativeMembers(Unknown Source:0)
		at net.dot.jni.ManagedPeer.registerNativeMembers(Native Method)
		at net.dot.jni.test.CallVirtualFromConstructorDerived.<clinit>(CallVirtualFromConstructorDerived.java:12)
		... 3 more

	--NotSupportedException
	   at Java.Interop.ManagedPeer.GetTypeFromSignature(JniTypeManager , JniTypeSignature , String )
	   at Java.Interop.ManagedPeer.RegisterNativeMembers(IntPtr jnienv, IntPtr klass, IntPtr n_nativeClass, IntPtr n_methods)

:shocked-pikachu-face: (But dotnet/android#8543 was green!)

The problem is twofold:

 1. 005c914 now requires the presence of typemap entries from e.g.
    `net.dot.jni.test.CallVirtualFromConstructorDerived` to
    `Java.InteropTests.CallVirtualFromConstructorDerived`.

 2. `Java.Interop.Tools.JavaCallableWrappers` et al doesn't create
    typemap entries for `Java.Interop.JavaObject` subclasses which
    have `[JniTypeSignature]`.

Consequently, our units tests fail (and apparently weren't *run* on
dotnet/android#8543?!  Still not sure what happened.)

Update typemap generation by adding a new `TypeDefinition.HasJavaPeer()`
extension method to replace all the `.IsSubclassOf("Java.Lang.Object")`
and similar checks, extending it to also check for
`Java.Interop.JavaObject` and `Java.Interop.JavaException` base types.
(Continuing to use base type checks is done instead of just relying
on implementation of `Java.Interop.IJavaPeerable` as a performance
optimization, as there could be *lots* of interface types to check.)

Additionally, @jonathanpeppers -- while trying to investigate all
this -- ran across a build failure:

	obj\Debug\net9.0-android\android\src\java\lang\Object.java(7,15): javac.exe error JAVAC0000:  error: cyclic inheritance involving Object

This suggests that `Java.Interop.Tools.JavaCallableWrappers` was
encountering `Java.Interop.JavaObject` -- or some other type which
has `[JniTypeSignature("java/lang/Object")]` -- which is why
`java/lang/Object.java` was being generated.

Audit all `[JniTypeSignature]` attributes, and add
`GenerateJavaPeer=false` to all types which should *not* hava a
Java Callable Wrapper generated for them.  This includes nearly
everything within `Java.Interop-Tests.dll`.  (We want the typemaps!
We *don't* want generated Java source, as we have hand-written Java
peer types for those tests.)

Add `[JniTypeSignature]` to `GenericHolder<T>`.  This type mapping
isn't *actually* required, but it *is* used in `JavaVMFixture`, and
it confuses people (me!) if things are inconsistent.  Additionally,
remove `tests/` from the Java-side name, for consistency.

~~ Avoid multiple java/lang/Object bindings ~~

A funny thing happened when in dotnet/android#8681 -- which
tested this commit -- when using an intermediate version of this
commit: unit tests started crashing!

	E monodroid-assembly: typemap: unable to load assembly 'Java.Interop-Tests' when looking up managed type corresponding to Java type 'java/lang/Object'

What appears to be happening is an Unfortunate Interaction™:

 1. `Java.Interop-Tests.dll` contained *multiple bindings* for
    `java/lang/Object`. e.g.

        [JniTypeSignature ("java/lang/Object", GenerateJavaPeer=false)]
        partial class JavaDisposedObject : JavaObject {
        }

 2. The typemap generator has no functionality to "prioritize" one
    binding vs. another; it's random.  As such, there is nothing to
    cause `Java.Lang.Object, Mono.Android` to be used as the
    preferred binding for `java/lang/Object`.

This meant that when we hit the typemap codepath in .NET Android,
we looked for the C# type that corresponded to `java/lang/Object`,
found *some random type* from `Java.Interop-Tests`, and…

…and then we hit another oddity: that codepath only supported looking
for C# types in assemblies which had already been loaded.  This was
occurring during startup, so `Java.Interop-Tests` had not yet been
loaded yet, so it errored out, returned `nullptr`, and later Android
just aborts things:

	F droid.NET_Test: runtime.cc:638] JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 0x79

Just…eep!

This didn't happen before because `Java.Interop.JavaObject` subclasses
*didn't* participate in typemap generation.  This commit *adds* that
support, introducing this unforeseen interaction.

Fix this by *removing* most "alternate bindings" for `java/lang/Object`:

	- [JniTypeSignature ("java/lang/Object", GenerateJavaPeer=false)]
	+ [JniTypeSignature (JniTypeName)]
	  partial class JavaDisposedObject : JavaObject {
	+     internal const string JniTypeName = "net/dot/jni/test/JavaDisposedObject";
	  }

This implicitly requires that we now have a Java Callable Wrapper
for this type, so update `Java.Interop-Tests.csproj` to run `jcw-gen`
as part of the build process.  This ensures that we create the
JCW for e.g. `JavaDisposedObject`.

Update `JavaVMFixture` to add the required typemap entries.

---

Aside: this project includes [T4 Text Templates][0].  To regenerate
the output files *without involving Visual Studio*, you can install
the [`dotnet-t4`][1] tool:

	$ dotnet tool install --global dotnet-t4

then run it separately for each `.tt` file:

	$HOME/.dotnet/tools/t4 -o src/Java.Interop/Java.Interop/JavaPrimitiveArrays.cs \
	  src/Java.Interop/Java.Interop/JavaPrimitiveArrays.tt

[0]: https://learn.microsoft.com/visualstudio/modeling/code-generation-and-t4-text-templates?view=vs-2022
[1]: https://www.nuget.org/packages/dotnet-t4/
jonpryor added a commit to dotnet/android that referenced this pull request Feb 2, 2024
Context: dotnet/java-interop#1165
Context: dotnet/java-interop@005c914
Context: #8543
Context: dotnet/java-interop@07c7300
Context: #8625
Context: xamarin/monodroid@e3e4f12
Context: xamarin/monodroid@a04b73b
Context: efbec22

Changes: dotnet/java-interop@8b85462...07c7300

  * dotnet/java-interop@07c73009: [Java.Interop] Typemap support for JavaObject & `[JniTypeSignature]` (dotnet/java-interop#1181)
  * dotnet/java-interop@d529f3be: Bump to xamarin/xamarin-android-tools/main@ed102fc (dotnet/java-interop#1182)
  * dotnet/java-interop@def5bc0d: [ci] Add API Scan job (dotnet/java-interop#1178)
  * dotnet/java-interop@d5afa0af: [invocation-overhead] Add generated source files (dotnet/java-interop#1175)
  * dotnet/java-interop@473ef74c: Bump to xamarin/xamarin-android-tools/main@4889bf0 (dotnet/java-interop#1172)
  * dotnet/java-interop@005c9141: [Java.Interop] Avoid `Type.GetType()` in `ManagedPeer` (dotnet/java-interop#1168)
  * dotnet/java-interop@0f1efebd: [Java.Interop] Use PublicApiAnalyzers to ensure we do not break API (dotnet/java-interop#1170)

(From the "infinite scream" department…)

It started with a desire to remove some linker warnings
(dotnet/java-interop#1165):

	external/Java.Interop/src/Java.Interop/Java.Interop/ManagedPeer.cs(93,19,93,112):
	warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String, Boolean)'.
	It's not possible to guarantee the availability of the target type.

dotnet/java-interop@005c9141 attempted to fix this by requiring the
use of "typemaps" mapping Java type signatures to managed types,
replacing e.g.:

	Type            type            = Type.GetType ("Example.Type, AssemblyName", throwOnError: true)!;
	Type[]          parameterTypes  = GetParameterTypes ("System.Int32:System.Int32");
	ConstructorInfo ctor            = type.GetConstructor (ptypes);
	// ctor=Example.Type(int, int) constructor

with (not exactly, but for expository purposes):

	Type            type            = GetTypeFromSignature("crc64…/Type");
	Type[]          parameterTypes  = GetConstructorCandidateParameterTypes ("(II)V");
	ConstructorInfo ctor            = type.GetConstructor (ptypes);
	// ctor=Example.Type(int, int) constructor
	
among other changes.

This was a *significant* change that would alter *Java.Interop*
semantics but *not* .NET Android semantics -- .NET Android uses
`Java.Interop.TypeManager.n_Activate()` (in this repo) for Java-side
"activation" scenarios, not `Java.Interop.ManagedPeer` -- so in an
abundance of caution we did a manual integration test in
#8543 to make sure nothing broke before
merging it.

Something was apparently "off" in that integration.  (We're still not
sure what was off, or why it was completely green.)

Ever since dotnet/java-interop@005c9141 was merged, every attempt to
bump xamarin/Java.Interop has failed, in a number of ways described
below.  However, instead of reverting dotnet/java-interop@005c9141
we took this as an opportunity to understand *how and why* things
were failing, as apparently we had encountered some *long-standing*
corner cases in How Things Work.

The oversights and failures include:

 1. In order to make the Java.Interop unit tests work in .NET Android,
    the (largely hand-written) Java.Interop test types *also* need to
    participate with .NET Android typemap support, so that there is a
    typemap entry mapping `net/dot/jni/test/GenericHolder` to
    `Java.InteropTests.GenericHolder<T>` and vice-versa.

    dotnet/java-interop@07c73009 updates
    `Java.Interop.Tools.JavaCallableWrappers` to support creating
    typemap entries for `Java.Interop.JavaObject` subclasses,
    introducing a new `TypeDefinition.HasJavaPeer()` extension method.

 2. (1) meant that, for the first time ever, types in
    `Java.Interop-Tests` participated in .NET Android type mapping.
    This *sounds* fine, except that `Java.Interop-Tests` contains
    "competing bindings" for `java.lang.Object`:

        [JniTypeSignature ("java/lang/Object", GenerateJavaPeer=false)]
        partial class JavaLangRemappingTestObject : JavaObject {
        }

 3. (2) means that, for the first time ever, we *could* have the
    typemap entry for `java/lang/Object` map to
    `Java.InteropTests.JavaLangRemappingTestObject, Java.Interop-Tests`,
    *not* `Java.Lang.Object, Mono.Android`.

    Arguably a bug, arguably "meh", but this setup triggered some
    never previously encountered error conditions:

 4. `EmbeddedAssemblies::typemap_java_to_managed()` within
    `libmonodroid.so` returns a `System.Type` that corresponds to a
    JNI type.  `typemap_java_to_managed()` has a bug/corner case
    wherein it will only provide `Type` instances from assemblies
    which have already been loaded.

    Early in startup, `Java.Interop-Tests` hasn't been loaded yet, so
    when `java/lang/Object` was mapped to
    `Java.InteropTests.JavaLangRemappingTestObject, Java.Interop-Tests`,
    `typemap_java_to_managed()` would return `null`.

    This is a bug/corner case, which is being investigated in
    #8625.

 5. Calls to `Java.Lang.Object.GetObject<T>()` call
    `Java.Interop.TypeManager.CreateInstance()`, which loops through
    the type and all base types to find a known binding/wrapper.
    Because of (3)+(4), if (when) we try to find the wrapper for
    `java/lang/Object`, we would find *no* mapping.

    This would cause an `JNI DETECTED ERROR IN APPLICATION` *crash*.

    This was due to a "use after free" bug.

    See the "TypeManager.CreateInstance() Use After Free Bug" section.

 6. Once (5) is fixed we encounter our next issue: the
    `Java.InteropTests.JnienvTest.NewOpenGenericTypeThrows()` unit
    test started failing because
    `crc641855b07eca6dcc03.GenericHolder_1` couldn't be found.

    This was caused by a bug in `acw-map.txt` parsing within `<R8/>`.

    See the "`<R8/>` and `acw-map.txt` parsing.`" section.

 7. Once (6) was fixed, (3) caused a *new* set of failures:
    multiple tests started failing because `java/lang/Object` was
    being mapped to the wrong managed type.

    (3) becomes less "meh" and more "definitely a bug".

    See the "Correct `java/lang/Object` mappings" section.

*Now* things should work reliably.


~~ TypeManager.CreateInstance() Use After Free Bug ~~

On 2011-Oct-19, xamarin/monodroid@e3e4f123d8 introduced a
use-after-free bug within `TypeManager.CreateInstance()`:

	JNIEnv.DeleteRef (handle, transfer);
	throw new NotSupportedException (
	        FormattableString.Invariant ($"Internal error finding wrapper class for '{JNIEnv.GetClassNameFromInstance (handle)}'. (Where is the Java.Lang.Object wrapper?!)"),
	        CreateJavaLocationException ());

`handle` *cannot be used* after `JNIEnv.DeleteRef(handle)`.
Failure to do so results in a `JNI DETECTED ERROR IN APPLICATION`
crash; with `adb shell setprop debug.mono.log lref+` set, we see:

	I monodroid-lref: +l+ lrefc 1 handle 0x71/L from thread '(null)'(1)
	D monodroid-gref:    at Android.Runtime.AndroidObjectReferenceManager.CreatedLocalReference(JniObjectReference , Int32& )
	D monodroid-gref:    at Java.Interop.JniRuntime.JniObjectReferenceManager.CreatedLocalReference(JniEnvironmentInfo , JniObjectReference )
	D monodroid-gref:    at Java.Interop.JniEnvironment.LogCreateLocalRef(JniObjectReference )
	D monodroid-gref:    at Java.Interop.JniEnvironment.LogCreateLocalRef(IntPtr )
	D monodroid-gref:    at Java.Interop.JniEnvironment.InstanceMethods.CallObjectMethod(JniObjectReference , JniMethodInfo )
	D monodroid-gref:    …
	…
	I monodroid-lref: -l- lrefc 0 handle 0x71/L from thread '(null)'(1)
	D monodroid-gref:    at Android.Runtime.AndroidObjectReferenceManager.DeleteLocalReference(JniObjectReference& , Int32& )
	D monodroid-gref:    at Java.Interop.JniRuntime.JniObjectReferenceManager.DeleteLocalReference(JniEnvironmentInfo , JniObjectReference& )
	D monodroid-gref:    at Java.Interop.JniObjectReference.Dispose(JniObjectReference& reference)
	D monodroid-gref:    at Android.Runtime.JNIEnv.DeleteLocalRef(IntPtr )
	D monodroid-gref:    at Android.Runtime.JNIEnv.DeleteRef(IntPtr , JniHandleOwnership )
	D monodroid-gref:    at Java.Interop.TypeManager.CreateInstance(IntPtr , JniHandleOwnership , Type )
	D monodroid-gref:    at Java.Lang.Object.GetObject(IntPtr , JniHandleOwnership , Type )
	D monodroid-gref:    at Java.Lang.Object._GetObject[IIterator](IntPtr , JniHandleOwnership )
	D monodroid-gref:    at Java.Lang.Object.GetObject[IIterator](IntPtr handle, JniHandleOwnership transfer)
	D monodroid-gref:    …
	D monodroid-gref:
	E droid.NET_Test: JNI ERROR (app bug): accessed stale Local 0x71  (index 7 in a table of size 7)
	F droid.NET_Test: java_vm_ext.cc:570] JNI DETECTED ERROR IN APPLICATION: use of deleted local reference 0x71
	…
	F droid.NET_Test: runtime.cc:630]   native: #13 pc 00000000003ce865  /apex/com.android.runtime/lib64/libart.so (art::(anonymous namespace)::CheckJNI::GetObjectClass(_JNIEnv*, _jobject*)+837)

The immediate fix is Don't Do That™; use a temporary:

	class_name = JNIEnv.GetClassNameFromInstance (handle);
	JNIEnv.DeleteRef (handle, transfer);
	throw new NotSupportedException (
	        FormattableString.Invariant ($"Internal error finding wrapper class for '{class_name}'. (Where is the Java.Lang.Object wrapper?!)"),
	        CreateJavaLocationException ());

Unfortunately, *just* fixing the "use-after-free" bug is insufficient;
if we throw that `NotSupportedException`, things *will* break
elsewhere.  We'll just have an "elegant unhandled exception" app crash
instead of a "THE WORLD IS ENDING" failed assertion crash.

We could go with the simple fix for the crash, but this means that in
order to integrate dotnet/java-interop@005c9141 &
dotnet/java-interop@07c73009 we'd have to figure out how to *ensure*
that `java/lang/Object` is bound as `Java.Lang.Object, Mono.Android`,
not `Java.InteropTests.JavaLangRemappingTestObject, Java.Interop-Tests`.
(We actually need to do this *anyway*; see the
"Correct `java/lang/Object` mappings" section.  At the time we I was
trying to *avoid* special-casing `Mono.Android.dll`…)

There is a*slightly* more complicated approach which fixes (5)
while supporting (4) `typemap_java_to_managed()` returning null;
consider the `-l-` callstack:

	at Android.Runtime.JNIEnv.DeleteRef(IntPtr , JniHandleOwnership )
	at Java.Interop.TypeManager.CreateInstance(IntPtr , JniHandleOwnership , Type )
	at Java.Lang.Object.GetObject(IntPtr , JniHandleOwnership , Type )
	at Java.Lang.Object._GetObject[IIterator](IntPtr , JniHandleOwnership )
	at Java.Lang.Object.GetObject[IIterator](IntPtr handle, JniHandleOwnership transfer)
	at Android.Runtime.JavaSet.Iterator()

This is part of a generic `Object.GetObject<IIterator>()` invocation!
Additionally, because `IIterator` is an interface, in *normal* use
the `type` variable within `TypeManager.CreateInstance()` would be
`Java.Lang.Object, Mono.Android` and then *immediately discarded*
because `Java.Lang.Object` cannot be assigned to `IIterator`.

Moving the type compatibility check to *before* the
`type == null` check fixes *an* issue with `typemap_java_to_managed()`
returning null.


~~ `<R8/>` and `acw-map.txt` parsing.` ~~

There are many ways for Android+Java code to refer to managed types.

For example, consider the following View subclass:

	namespace Example {
	  partial class MyCoolView : Android.Views.View {
	    // …
	  }
	}

Within layout `.axml` files, you can mention an `Android.Views.View`
subclass by:

  * Using the .NET Full Class Name as an element name.

        <Example.MyCoolView />

  * Using the .NET Full Class Name with a *lowercased* namespace
    name as the element name.

        <example.MyCoolView />

  * Use the Java-side name directly.

        <crc64….NiftyView />

Within Fragments, you can also use the *assembly-qualified name*:

	<fragment class="Example.MyCoolView, AssemblyName" />

At build time, all instances of the .NET type names will be
*replaced* with the Java type names before the Android toolchain
processes the files.

The association between .NET type names and Java names is stored
within `$(IntermediateOutputPath)acw-map.txt`, which was introduced
in xamarin/monodroid@a04b73b3.

*Normally* `acw-map.txt` contains three entries:

 1. The fully-qualified .NET type name
 2. The .NET type name, no assembly
 3. (2) with a lowercased namespace name, *or* the `[Register]`
    value, if provided.

For example:

	Mono.Android_Test.Library.CustomTextView, Mono.Android-Test.Library.NET;crc6456ab8145c81c4100.CustomTextView
	Mono.Android_Test.Library.CustomTextView;crc6456ab8145c81c4100.CustomTextView   
	mono.android_test.library.CustomTextView;crc6456ab8145c81c4100.CustomTextView   
	Java.InteropTests.GenericHolder`1, Java.Interop-Tests;net.dot.jni.test.tests.GenericHolder
	Java.InteropTests.GenericHolder`1;net.dot.jni.test.tests.GenericHolder          
	net.dot.jni.test.tests.GenericHolder;net.dot.jni.test.tests.GenericHolder    

However, when warning XA4214 is emitted (efbec22), there is a
"collision" on the .NET side (but *not* the Java side); (2) and (3)
are potentially *ambiguous*, so one .NET type is arbitrarily chosen.
(Collisions on the Java side result in XA4215 *errors*.)

The first line is still possible, because of assembly qualification.

Enter ``Java.InteropTests.GenericHolder`1``: this type is present in
*both* `Java.Interop-Tests.dll` *and* `Mono.Android-Tests.dll`.
dotnet/java-interop@07c73009, this was "fine" because the
`GenericHolder<T>` within `Java.Interop-Tests.dll` did not participate
in typemap generation.  Now it does, resulting in the XA4214 warning.
XA4214 *also* means that instead of three lines, it's *one* line:

	Java.InteropTests.GenericHolder`1, Mono.Android.NET-Tests;crc641855b07eca6dcc03.GenericHolder_1

Enter `<R8/>`, which parses `acw-map.txt` to create a
`proguard_project_primary.cfg` file.  `<R8/>` did it's *own* parsing
of `acw-map.txt`, parsing only *one of every three lines*, on the
assumption that *all* entries took three lines.

This breaks in the presence of XA4214, because some entries only take
one line, not three lines.  This in turn meant that
`proguard_project_primary.cfg` could *miss* types, which could mean
that `r8` would *remove* the unspecified types, resulting in
`ClassNotFoundException` at runtime:

	Java.Lang.ClassNotFoundException : crc641855b07eca6dcc03.GenericHolder_1
	----> Java.Lang.ClassNotFoundException : Didn't find class "crc641855b07eca6dcc03.GenericHolder_1" on path: DexPathList[[zip file "/data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/base.apk", zip file "/data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/split_config.x86_64.apk", zip file "/data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/split_config.xxhdpi.apk"],nativeLibraryDirectories=[/data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/lib/x86_64, /system/fake-libs64, /data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/base.apk!/lib/x86_64, /data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/split_config.x86_64.apk!/lib/x86_64, /data/app/Mono.Android.NET_Tests-2stBqO43ov5F6bHfYemJHQ==/split_config.xxhdpi.apk!/lib/x86_64, /system/lib64, /system/product/lib64]]
	   at Java.Interop.JniEnvironment.StaticMethods.CallStaticObjectMethod(JniObjectReference , JniMethodInfo , JniArgumentValue* )
	   at Android.Runtime.JNIEnv.FindClass(String )

Update `<R8/>` to instead use `MonoAndroidHelper.LoadMapFile()`,
which reads all lines within `acw-map.txt`.  This results in a
`proguard_project_primary.cfg` file which properly contains a `-keep`
entry for XA4214-related types, such as
`crc641855b07eca6dcc03.GenericHolder_1`.


~~ Correct `java/lang/Object` mappings ~~`

Previous valiant efforts to allow `java/lang/Object` to be mapped to
"anything", not just `Java.Lang.Object, Mono.Android`, eventually
resulted in lots of unit test failures, e.g.:

`Android.RuntimeTests.XmlReaderPullParserTest.ToLocalJniHandle()`:

	System.NotSupportedException : Unable to activate instance of type Java.InteropTests.JavaLangRemappingTestObject from native handle 0x19 (key_handle 0x2408476).
	----> System.MissingMethodException : No constructor found for Java.InteropTests.JavaLangRemappingTestObject::.ctor(System.IntPtr, Android.Runtime.JniHandleOwnership)
	----> Java.Interop.JavaLocationException : Exception_WasThrown, Java.Interop.JavaLocationException
	   at Java.Interop.TypeManager.CreateInstance(IntPtr , JniHandleOwnership , Type )
	   at Java.Interop.TypeManager.CreateInstance(IntPtr , JniHandleOwnership )
	   at Android.Runtime.XmlResourceParserReader.FromNative(IntPtr , JniHandleOwnership )
	   at Android.Runtime.XmlResourceParserReader.FromJniHandle(IntPtr handle, JniHandleOwnership transfer)
	   at Android.Content.Res.Resources.GetXml(Int32 )
	   at Android.RuntimeTests.XmlReaderPullParserTest.ToLocalJniHandle()
	   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
	   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object , BindingFlags )
	--MissingMethodException
	   at Java.Interop.TypeManager.CreateProxy(Type , IntPtr , JniHandleOwnership )
	   at Java.Interop.TypeManager.CreateInstance(IntPtr , JniHandleOwnership , Type )

With a partially heavy heart, we need to special-case typemap entries
by processing `Mono.Android.dll` *first*, so that it gets first dibs
at bindings for `java/lang/Object` and other types.

Update `NativeTypeMappingData` to process types from `Mono.Android`
before processing any other module.

Note that the special-casing needs to happen in `NativeTypeMappingData`
because typemaps were formerly processed in *sorted module order*, in
which the sort order is based on the *byte representation* of the
module's MVID (a GUID).  Additionally, *linking changes the MVID*,
which means module order is *effectively random*.  Consequently,
trying to special case typemap ordering anywhere else is ineffective.


~~ Other ~~

Update `JavaCompileToolTask` to log the contents of its response file.

Update LLVM-IR -related types within
`src/Xamarin.Android.Build.Tasks/Utilities` to use `TaskLoggingHelper`
for logging purposes, *not* `Action<string>`.  Update related types
to accept `TaskLoggingHelper`, so that we can more easily add
diagnostic messages to these types in the future.
@github-actions github-actions bot locked and limited conversation to collaborators Apr 12, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Discussion: Ways to *actually* fix trimmer warnings around Type.GetType()?
4 participants