Skip to content

[WIP] GC bridge integration for CoreCLR #10185

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

Closed
wants to merge 17 commits into from

Conversation

simonrozsival
Copy link
Member

Work in progress.

@simonrozsival simonrozsival added the do-not-merge PR should not be merged. label Jun 11, 2025
if (peer.Target is IDisposable disposable)
disposable.Dispose ();
if (handle.IsAllocated)
(handle.Target as IDisposable)?.Dispose ();
Copy link
Member

Choose a reason for hiding this comment

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

This seems a bit weird, but I guess this method is only called from Tests so we don't care too much ?

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, it's only used in tests. I wonder if we actually need it, and if we could obsolete this as a public API so that people reach out to us if they have actual use-cases for this method. /cc @grendello @jonathanpeppers

}

public override void AddPeer (IJavaPeerable value)
{
if (RegisteredInstances == null)
throw new ObjectDisposedException (nameof (ManagedValueManager));

WaitForGCBridgeProcessing ();
Copy link
Member

@BrzVlad BrzVlad Jun 12, 2025

Choose a reason for hiding this comment

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

Not sure what race this wait is meant to prevent. Even if we do the wait here, the code below could still race with a bridge collection ?

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 it's not doing anything really. I added it a while ago and I just never removed it. Let me get rid of all the calls to that method in ManagedValueManager.

}
}

static unsafe void FreeReferenceTrackingHandle (GCHandle handle)
Copy link
Member

Choose a reason for hiding this comment

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

Ideally, we shouldn't free the handle ourselves, rather let the runtime do it for us, so we don't run into races with the GC.

I expect we might still want to free it early via Dispose. If that is the case, we would need to have certainty that this handle/context is not part of a current bridge GC. This would be the case if the C# object that this handle points to is not dead. So if we get hold of this GCHandle from the IJavaPeerable, then it is ok. If we just traverse the RegisteredInstances and free some handles from there, then this sounds potentially problematic.

Copy link
Member Author

Choose a reason for hiding this comment

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

If that is the case, we would need to have certainty that this handle/context is not part of a current bridge GC.

Right, that's what the naive WaitForBridgeProcessing method would ideally do, but it really cannot. We would need a ReaderWriterLockSlim or something.

So if we get hold of this GCHandle from the IJavaPeerable, then it is ok. If we just traverse the RegisteredInstances and free some handles from there, then this sounds potentially problematic.

This shouldn't be a problem for DisposePeer where we have a live reference to the IJavaPeerable value object. We could maybe use a combination of GC.KeepAlive and GC.SuppressFinalize to ensure it's not collected while we're inside of DisposePeer?

I suppose this could be a real problem in the case of AddPeer when we're replacing some existing handle stored in the dictionary. We should only call FreeReferenceTrackingHandle if that handle has a valid target (my understanding is that accessing the .Target would block until the current GC processing finishes) and if it has a valid target, we can use GC.KeepAlive (target) to ensure that this object isn't collected 🤔

Copy link
Member

Choose a reason for hiding this comment

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

(my understanding is that accessing the .Target would block until the current GC processing finishes)

That is the problem. It doesn't block for GCHandle.Target, only for WeakReference.Target. I think adding blocking to GCHandle as well adds some complexity and we would rather avoid it.

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 see. OK. I will look into a better reader-writer lock mechanism we could use in managed code.

if (p.Target is not IJavaPeerable peer)
continue;
if (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);
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 mean here that if we have a cross GCHandle C#1 -> Java1. And we try to add a new bridge object C#2 -> Java1. Then we attempt to free the first GCHandle and create a new one instead ? I don't fully understand the reasoning behind this behavior. Also, as described in the comment for FreeReferenceTrackingHandle it seems like the first GCHandle could be part of the gcbridge machinery, if C#1 is dead in managed world and we would race with the GC here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Then we attempt to free the first GCHandle and create a new one instead ? I don't fully understand the reasoning behind this behavior.

Based on the existing code in the mono bridge (

bool ShouldReplaceMapping (WeakReference<IJavaPeerable> current, JniObjectReference reference, IJavaPeerable value, out IJavaPeerable? target)
{
target = null;
if (current == null)
return true;
// Target has been GC'd; see also FIXME, above, in finalizer
if (!current.TryGetTarget (out target) || target == null)
return true;
// It's possible that the instance was GC'd, but the finalizer
// hasn't executed yet, so the `instances` entry is stale.
if (!target.PeerReference.IsValid)
return true;
if (!JniEnvironment.Types.IsSameObject (target.PeerReference, reference))
return false;
// JNIEnv.NewObject/JNIEnv.CreateInstance() compatibility.
// When two MCW's are created for one Java instance [0],
// we want the 2nd MCW to replace the 1st, as the 2nd is
// the one the dev created; the 1st is an implicit intermediary.
//
// Meanwhile, a new "replaceable" instance should *not* replace an
// existing "replaceable" instance; see dotnet/android#9862.
//
// [0]: If Java ctor invokes overridden virtual method, we'll
// transition into managed code w/o a registered instance, and
// thus will create an "intermediary" via
// (IntPtr, JniHandleOwnership) .ctor.
if (target.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable) &&
!value.JniManagedPeerState.HasFlag (JniManagedPeerStates.Replaceable)) {
return true;
}
return false;
}
), I believe this is to cover an edge-case where we create a "temporary" .NET object because some object calls a virtual instance method overridden in C# from the Java base class constructor and when we do the marshalling for the Java this reference, we don't have a .NET object corresponding to that reference, so we create a new managed wrapper for the Java this. Later, we want the object that the developer created in thier C# code using new MyObject() to be the actual object stored in the RegisteredInstances dictionary.

I replied to the other comment earlier and I believe you are right and this is a problematic spot and it will need extra care.

if (RegisteredInstances == null)
throw new ObjectDisposedException (nameof (ManagedValueManager));

WaitForGCBridgeProcessing ();
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure if this wait achieves something. In general, for key synchronization pieces with the GC, I think we should add explicit comments regarding what we are trying to achieve, what race we try to prevent. Later, when we are smarter, we could see whether we actually need it or not, or have another solution for these problems.

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 I mentioned in another comment, this was added very early, and I just never revisited it. Removing.

foreach (int i in indexesToRemove) {
// Remove the peer from the list
var handle = peers[i];
FreeReferenceTrackingHandle (handle);
Copy link
Member

Choose a reason for hiding this comment

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

I think here we are only freeing handles that have value as the Target, which, if it was not obtained from the gchandle weak ref, then we know it shouldn't be part of the current bridge. This would mean that this should be safe, in theory.

Copy link
Member Author

Choose a reason for hiding this comment

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

If I remember correctly, this method is called from the base Dispose of all bridge objects. My understanding is that it should just remove dead handles from the RegisteredInstances.

Alternatively, we could go over the whole RegisteredInstances collection during GC processing and drop all previously freed handles. The downside of this would be that this behavior would now diverge from what we do in the mono counterpart of this code.

@@ -181,6 +282,8 @@ public override void RemovePeer (IJavaPeerable value)

public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();
Copy link
Member

Choose a reason for hiding this comment

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

ditto

@BrzVlad
Copy link
Member

BrzVlad commented Jun 12, 2025

There are a lot of waits for bridge processing which I don't think are needed. I think the only place we need to wait for bridge processing is when we obtain a C# object ref from the java object (JniObjectReference?), not exactly sure where this location is. This is because by doing this we could insert into C# world an object that we thought was dead during last GC, end up calling Dispose on it racing with the GC.


void GCBridge::wait_for_bridge_processing () noexcept
{
std::shared_lock<std::shared_mutex> lock (processing_mutex);
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't seem correct. In theory we could obtain this mutex, before the bridge worker thread actually acquires it, doing the BP2 stage. We would need at least an additional variable to mark whether we have a bridge in progress. Probably makes sense to use a condition variable for this.

@BrzVlad
Copy link
Member

BrzVlad commented Jun 12, 2025

The runtime implementation contains implicit wait for bridge processing when obtaining the Target of a WeakReference. If we would use this mechanism when obtaining the C# object from a Java object, then we probably won't need our own implementation in WaitForBridgeProcessing.

}
}

public void Dispose ()
Copy link
Member

Choose a reason for hiding this comment

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

Note that this should be legal to call only if the caller holds a reference to the Target.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
do-not-merge PR should not be merged.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants