[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[WIP] GC bridge integration for CoreCLR - #10185

Closed
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge
Closed

[WIP] GC bridge integration for CoreCLR#10185
simonrozsival wants to merge 17 commits into
dev/peppers/gcbridgefrom
dev/srozsival/gcbridge

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Work in progress.

@simonrozsivalsimonrozsival 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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

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

WaitForGCBridgeProcessing ();

@BrzVladBrzVladJun 12, 2025

Copy link
Copy Markdown
Member

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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (!JniEnvironment.Types.IsSameObject (peer.PeerReference, value.PeerReference))
continue;
if (Replaceable (p)) {
FreeReferenceTrackingHandle (p);

Copy link
Copy Markdown
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
Copy Markdown
MemberAuthor

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 (

boolShouldReplaceMapping(WeakReference<IJavaPeerable>current,JniObjectReferencereference,IJavaPeerablevalue,outIJavaPeerable?target)
{
target=null;
if(current==null)
returntrue;
// Target has been GC'd; see also FIXME, above, in finalizer
if(!current.TryGetTarget(outtarget)||target==null)
returntrue;
// 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)
returntrue;
if(!JniEnvironment.Types.IsSameObject(target.PeerReference,reference))
returnfalse;
// 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)){
returntrue;
}
returnfalse;
}
), 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
Copy Markdown
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
Copy Markdown
MemberAuthor

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
Copy Markdown
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
Copy Markdown
MemberAuthor

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.


public override void FinalizePeer (IJavaPeerable value)
{
WaitForGCBridgeProcessing ();

Copy link
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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
Copy Markdown
Member

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
Copy Markdown
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.

@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 14, 2025
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

do-not-mergePR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@simonrozsival@BrzVlad