Skip to content

Commit

Permalink
[Java.Interop] Add JavaPeerableRegistrationScope
Browse files Browse the repository at this point in the history
Fixes: #4

Context: #426
Context: a666a6f

Alternate names?

  * JavaScope
  * JavaPeerableCleanupPool
  * JavaPeerCleanup
  * JavaReferenceCleanup
  * JniPeerRegistrationScope

Issue #426 is an idea to implement a *non*-GC-Bridged
`JniRuntime.JniValueManager` type, primarily for use with .NET Core.
This was begun in a666a6f.

What's missing is the answer to a question: what to do about
`JniRuntime.JniValueManager.CollectPeers()`?  With a Mono-style
GC bridge, `CollectPeers()` is `GC.Collect()`.  In a666a6f with
.NET Core, `CollectPeers()` calls `IJavaPeerable.Dispose()` on all
registered instances, which is "extreme".

@jonpryor thought that if there were a *scope-based* way to
selectively control which instances were disposed, that might be
"better" and more understandable.  Plus, this is issue #4!

Which brings us to the background for Issue #4, which touches upon
[bugzilla 25443][0] and [Google issue 37034307][1]: Java.Interop
attempts to provide referential identity for Java objects: if two
separate Java methods invocations return the same Java instance, then
the bindings of those methods should also return the same instance.

This is "fine" on the surface, but has a few related issues:

 1. JNI Global References are a limited resource: Android only allows
    ~52000 JNI Global References to exist, which sounds like a lot,
    but might not be.

 2. Because of (1), it is not uncommon to want to use `using` blocks
    to invoke `IJavaPeerable.Dispose()` to release
    JNI Global References.

 3. However, because of object identity, you could unexpectedly
    introduce *cross-thread sharing* of `IJavaPeerable` instances.
    This might not be at all obvious; for example, in the Android 5
    timeframe, [`Typeface.create()`][2] wouldn't necessarily return
    new Java instances, but may instead return cached instances.

Meaning that this:

	var t1 = new Thread(() => {
	    using var typeface = Typeface.Create("Family", …);
	    // use typeface…
	});
	var t2 = new Thread(() => {
	    using var typeface = Typeface.Create("Family", …);
	    // use typeface…
	});
	t1.Start();
	t2.Start();
	t1.Join();
	t2.Join();

could plausibly result in `ObjectDisposedException`s (or worse), as
the threads could be unexpectedly sharing the same bound instance.

Which *really* means that you can't reliably call `Dispose()`, unless
you *know* you created that instance:

	using var safe      = new Java.Lang.Double(42.0);       // I created this, therefore I control all access and can Dispose() it
	using var unsafe    = Java.Lang.Double.ValueOf(42.0);   // I have no idea who else may be using this instance!

Attempt to address both of these scenarios -- a modicum of .NET Core
support, and additional sanity around JNI Global Reference lifetimes --
by introducing a new `JavaPeerableRegistrationScope` type, which
introduces a scope-based mechanism to control when `IJavaPeerable`
instances are cleaned up:

        public enum JavaPeerableRegistrationScopeCleanup {
            RegisterWithManager,
            Dispose,
            Release,
        }

        public ref struct JavaPeerableRegistrationScope {
            public JavaPeerableRegistrationScope(JavaPeerableRegistrationScopeCleanup cleanup);
            public void Dispose();
        }

`JavaPeerableRegistrationScope` is a [`ref struct`][3], which means
it can only be allocated on the runtime stack, ensuring that cleanup
semantics are *scope* semantics.

TODO: is that actually a good idea?

If a `JavaPeerableRegistrationScope` is created using
`JavaPeerableRegistrationScopeCleanup.RegisterWithManager`, existing
behavior is followed.  This is useful for nested scopes, should
instances need to be registered with `JniRuntime.ValueManager`.

If a `JavaPeerableRegistrationScope` is created using
`JavaPeerableRegistrationScopeCleanup.Dispose` or
`JavaPeerableRegistrationScopeCleanup.Release`, then:

 1. `IJavaPeerable` instances created within the scope are
    "thread-local": they can be *used* by other threads, but
    `JniRuntime.JniValueManager.PeekPeer()` will only find the
    value on the creating thread.

 2. At the end of a `using` block / when
   `JavaScope.Dispose()` is called, all collected instances will be
   `Dispose()`d (with `.Dispose`) or released (with `.Release`), and
   left to the GC to eventually finalize.

For example:

	using (new JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup.Dispose)) {
	    var singleton = JavaSingleton.Singleton;
	    // use singleton

	    // If other threads attempt to access `JavaSingleton.Singleton`,
	    // they'll create their own peer wrapper
	}
	// `singleton.Dispose()` is called at the end of the `using` block

However, if e.g. the singleton instance is already accessed, then it
won't be added to the registration scope and won't be disposed:

	var singleton = JavaSingleton.Singleton;
	// singleton is registered with the ValueManager

	// later on the same thread or some other threa…
	using (new JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup.Dispose)) {
	    var anotherSingleton = JavaSingleton.Singleton;
	    // use anotherSingleton…
	}

then `anotherSingleton` will *not* disposed, as it already existed.
*Only newly created instances* are added to the current scope.

By default, only `JavaPeerableRegistrationScopeCleanup.RegisterWithManager`
is supported.  To support the other cleanup modes,
`JniRuntime.JniValueManager.SupportsPeerableRegistrationScopes` must
return `true`, which in turn requires that:

  * `JniRuntime.JniValueManager.AddPeer()` calls
    `TryAddPeerToRegistrationScope()`,
  * `JniRuntime.JniValueManager.RemovePeer()` calls
    `TryRemovePeerFromRegistrationScopes()`
  * `JniRuntime.JniValueManager.PeekPeer()` calls
    `TryPeekPeerFromRegistrationScopes()`.

See `ManagedValueManager` for an example implementation.

Finally, add the following methods to `JniRuntime.JniValueManager`
to help further assist peer management:

	partial class JniRuntime.JniValueManager {
	    public virtual bool CanCollectPeers { get; }
	    public virtual bool CanReleasePeers { get; }
	    public virtual void ReleasePeers ();
	}

TODO: docs?

TODO: *nested* scopes, and "bound" vs. "unbound" instance construction
around `JniValueManager.GetValue<T>()` or `.CreateValue<T>()`,
and *why* they should be treated differently.

TODO: Should `CreateValue<T>()` be *removed*?  name implies it always
"creates" a new value, but implementation will return existing instances,
so `GetValue<T>()` alone may be better.  One related difference is that`
`CreateValue<T>()` uses `PeekBoxedObject()`, while `GetValue<T>()` doesn't.
*Should* it?

[0]: https://web.archive.org/web/20211106214514/https://bugzilla.xamarin.com/25/25443/bug.html#c63
[1]: https://issuetracker.google.com/issues/37034307
[2]: https://developer.android.com/reference/android/graphics/Typeface#create(java.lang.String,%20int)
[3]: https://docs.microsoft.com/dotnet/csharp/language-reference/builtin-types/struct#ref-struct
  • Loading branch information
jonpryor committed Aug 2, 2024
1 parent 7a058c0 commit 4f0ccbb
Show file tree
Hide file tree
Showing 11 changed files with 842 additions and 22 deletions.
32 changes: 32 additions & 0 deletions src/Java.Interop/Java.Interop/JavaPeerableRegistrationScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace Java.Interop {
public enum JavaPeerableRegistrationScopeCleanup {
RegisterWithManager,
Dispose,
Release,
}

public ref struct JavaPeerableRegistrationScope {
PeerableCollection? scope;
bool disposed;

public JavaPeerableRegistrationScope (JavaPeerableRegistrationScopeCleanup cleanup)
{
scope = JniEnvironment.CurrentInfo.BeginRegistrationScope (cleanup);
disposed = false;
}

public void Dispose ()
{
if (disposed) {
return;
}
disposed = true;
JniEnvironment.CurrentInfo.EndRegistrationScope (scope);
scope = null;
}
}
}
20 changes: 4 additions & 16 deletions src/Java.Interop/Java.Interop/JavaProxyObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ sealed class JavaProxyObject : JavaObject, IEquatable<JavaProxyObject>
internal const string JniTypeName = "net/dot/jni/internal/JavaProxyObject";

static readonly JniPeerMembers _members = new JniPeerMembers (JniTypeName, typeof (JavaProxyObject));
static readonly ConditionalWeakTable<object, JavaProxyObject> CachedValues = new ConditionalWeakTable<object, JavaProxyObject> ();

[JniAddNativeMethodRegistrationAttribute]
static void RegisterNativeMembers (JniNativeMethodRegistrationArguments args)
Expand All @@ -29,10 +28,8 @@ public override JniPeerMembers JniPeerMembers {
}
}

JavaProxyObject (object value)
internal JavaProxyObject (object value)
{
if (value == null)
throw new ArgumentNullException (nameof (value));
Value = value;
}

Expand All @@ -57,19 +54,10 @@ public override bool Equals (object? obj)
return Value.ToString ();
}

[return: NotNullIfNotNull ("object")]
public static JavaProxyObject? GetProxy (object value)
protected override void Dispose (bool disposing)
{
if (value == null)
return null;

lock (CachedValues) {
if (CachedValues.TryGetValue (value, out var proxy))
return proxy;
proxy = new JavaProxyObject (value);
CachedValues.Add (value, proxy);
return proxy;
}
base.Dispose (disposing);
JniEnvironment.Runtime.ValueManager.RemoveProxy (Value);
}

// TODO: Keep in sync with the code generated by ExportedMemberBuilder
Expand Down
121 changes: 121 additions & 0 deletions src/Java.Interop/Java.Interop/JniEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;

namespace Java.Interop {
Expand Down Expand Up @@ -209,6 +212,8 @@ sealed class JniEnvironmentInfo : IDisposable {
bool disposed;
JniRuntime? runtime;

List<PeerableCollection?>? scopes;

public int LocalReferenceCount {get; internal set;}
public bool WithinNewObjectScope {get; set;}
public JniRuntime Runtime {
Expand Down Expand Up @@ -243,6 +248,11 @@ public bool IsValid {
get {return Runtime != null && environmentPointer != IntPtr.Zero;}
}

public List<PeerableCollection?>?
Scopes => scopes;
public PeerableCollection? CurrentScope =>
scopes == null ? null : scopes [scopes.Count-1];

public JniEnvironmentInfo ()
{
Runtime = JniRuntime.CurrentRuntime;
Expand Down Expand Up @@ -293,6 +303,42 @@ public void Dispose ()
disposed = true;
}

public PeerableCollection? BeginRegistrationScope (JavaPeerableRegistrationScopeCleanup cleanup)
{
if (cleanup != JavaPeerableRegistrationScopeCleanup.RegisterWithManager &&
!Runtime.ValueManager.SupportsPeerableRegistrationScopes) {
throw new NotSupportedException ("Peerable registration scopes are not supported by this runtime.");
}
scopes ??= new List<PeerableCollection?> ();
if (cleanup == JavaPeerableRegistrationScopeCleanup.RegisterWithManager) {
scopes.Add (null);
return null;
}
var scope = new PeerableCollection (cleanup);
scopes.Add (scope);
return scope;
}

public void EndRegistrationScope (PeerableCollection? scope)
{
Debug.Assert (scopes != null);
if (scopes == null) {
return;
}

for (int i = scopes.Count; i > 0; --i) {
var s = scopes [i - 1];
if (s == scope) {
scopes.RemoveAt (i - 1);
break;
}
}
if (scopes.Count == 0) {
scopes = null;
}
scope?.DisposeScope ();
}

#if FEATURE_JNIENVIRONMENT_SAFEHANDLES
internal List<List<JniLocalReference>> LocalReferences = new List<List<JniLocalReference>> () {
new List<JniLocalReference> (),
Expand All @@ -309,5 +355,80 @@ static unsafe JniEnvironmentInvoker CreateInvoker (IntPtr handle)
}
#endif // !FEATURE_JNIENVIRONMENT_JI_PINVOKES
}

sealed class PeerableCollection : KeyedCollection<int, IJavaPeerable> {
public JavaPeerableRegistrationScopeCleanup Cleanup { get; }

public PeerableCollection (JavaPeerableRegistrationScopeCleanup cleanup)
{
Cleanup = cleanup;
}

protected override int GetKeyForItem (IJavaPeerable item) => item.JniIdentityHashCode;

public IJavaPeerable? GetPeerable (JniObjectReference reference, int identityHashCode)
{
if (!reference.IsValid) {
return null;
}
if (TryGetValue (identityHashCode, out var p) &&
JniEnvironment.Types.IsSameObject (reference, p.PeerReference)) {
return p;
}
return null;
}

public void DisposeScope ()
{
Console.Error.WriteLine ($"# jonp: DisposeScope: {Cleanup}");
Debug.Assert (Cleanup != JavaPeerableRegistrationScopeCleanup.RegisterWithManager);
switch (Cleanup) {
case JavaPeerableRegistrationScopeCleanup.Dispose:
List<Exception>? exceptions = null;
foreach (var p in this) {
DisposePeer (p, ref exceptions);
}
Clear ();
if (exceptions != null) {
throw new AggregateException ("Exceptions while disposing peers.", exceptions);
}
break;
case JavaPeerableRegistrationScopeCleanup.Release:
Clear ();
break;
case JavaPeerableRegistrationScopeCleanup.RegisterWithManager:
default:
throw new NotSupportedException ($"Unsupported scope cleanup value: {Cleanup}");
}

[SuppressMessage ("Design", "CA1031:Do not catch general exception types",
Justification = "Exceptions are bundled into an AggregateException and rethrown")]
static void DisposePeer (IJavaPeerable peer, ref List<Exception>? exceptions)
{
try {
Console.Error.WriteLine ($"# jonp: DisposeScope: disposing of: {peer} {peer.PeerReference}");
peer.Dispose ();
} catch (Exception e) {
exceptions ??= new ();
exceptions.Add (e);
Trace.WriteLine (e);
}
}
}

public override string ToString ()
{
var c = (Collection<IJavaPeerable>) this;
var s = new StringBuilder ();
s.Append ("PeerableCollection[").Append (Count).Append ("](");
for (int i = 0; i < Count; ++i ) {
s.AppendLine ();
var e = c [i];
s.Append ($" [{i}] hash={e.JniIdentityHashCode} ref={e.PeerReference} type={e.GetType ().ToString ()} value=`{e.ToString ()}`");
}
s.Append (")");
return s.ToString ();
}
}
}

Loading

0 comments on commit 4f0ccbb

Please sign in to comment.