From f3514613686650b07973e33252aefb45d3e7ef86 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:46:39 -0700 Subject: [PATCH 1/9] Skip the resurrection tracking handle for RCWs without a finalizer Every RCW allocates two weak GC handles for its proxy: a plain weak one, and a second 'WeakTrackResurrection' one that exists only so the NativeObjectWrapper is cleaned up after the proxy's finalizer has run (the proxy's finalizer may access the native object). If the proxy's type declares no finalizer, it can never observe the native object once it becomes unreachable, and it can never be resurrected, so the second handle would always be cleared at the same time as the first. Detect that case from the MethodTable and skip allocating it. This is worth doing because allocating, clearing and freeing GC handles is a substantial part of the cost of every RCW, and resurrection tracking handles are more expensive for the GC to process than plain weak ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilerServices/RuntimeHelpers.CoreCLR.cs | 8 ++++++++ .../CompilerServices/RuntimeHelpers.NativeAot.cs | 8 ++++++++ .../System/Runtime/InteropServices/ComWrappers.cs | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs index 0b268cc6e12bee..9d73f0d86c68e0 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs @@ -449,6 +449,14 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } + // Returns true iff the type of the object declares a finalizer. + // Callers are required to keep obj alive + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static unsafe bool ObjectHasFinalizer(object obj) + { + return GetMethodTable(obj)->HasFinalizer; + } + /// /// Boxes a given value using an input to determine its type. /// diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs index 487c460e1c1b23..4f392bf2bac552 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs @@ -213,6 +213,14 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } + // Returns true iff the type of the object declares a finalizer. + // Callers are required to keep obj alive + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static unsafe bool ObjectHasFinalizer(object obj) + { + return GetMethodTable(obj)->IsFinalizable; + } + public static void PrepareMethod(RuntimeMethodHandle method) { if (method.Value == IntPtr.Zero) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index 84c316b880390a..e6b3041cac1cfa 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -601,7 +601,20 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper // due to it can access the native object in the finalizer. At the same time, // we want other callers which are using ProxyHandle such as the reference tracker runtime // to see the object as not alive once it is eligible for finalization. - _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + // + // If the RCW has no finalizer, it can never observe the native object past the point + // where it becomes unreachable, and it can never be resurrected. The extra handle would + // therefore always be cleared at the same time as the one above, so we skip allocating it. + // This matters because allocating, clearing and freeing GC handles is a substantial part + // of the cost of every RCW, and resurrection tracking handles are also more expensive for + // the GC to process than plain weak handles. + if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) + { + _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + } + + // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive + GC.KeepAlive(comProxy); // If this is an aggregation scenario and the identity object // is a managed object wrapper, we need to call Release() to From 13b5c159da8c8651904b547a2e3e9050238a33dc Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:52:17 -0700 Subject: [PATCH 2/9] Use WeakGCHandle for the resurrection tracking handle The handle always tracks the proxy object, so a strongly typed weak handle expresses that directly: it allocates through GCHandle.InternalAlloc without revalidating the handle type, and skips the cast when reading the target. Only this handle is converted. The '_proxyHandle' field and the handles in 'GCHandleSet' are mirrored in native code (see 'NativeObjectWrapperObject' in interoplibinterface_comwrappers.h, read via 'GetProxyHandle') and flow through the on-stack COM struct used for reference tracker callbacks, so converting those is a wider change that needs to be validated against the native side. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Runtime/InteropServices/ComWrappers.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index e6b3041cac1cfa..736b79161f72d4 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -549,7 +549,7 @@ internal unsafe class NativeObjectWrapper private IntPtr _externalComObject; private IntPtr _inner; private GCHandle _proxyHandle; - private GCHandle _proxyHandleTrackingResurrection; + private WeakGCHandle _proxyHandleTrackingResurrection; private readonly bool _aggregatedManagedObjectWrapper; private readonly bool _uniqueInstance; @@ -610,7 +610,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper // the GC to process than plain weak handles. if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) { - _proxyHandleTrackingResurrection = GCHandle.Alloc(comProxy, GCHandleType.WeakTrackResurrection); + _proxyHandleTrackingResurrection = new WeakGCHandle(comProxy, trackResurrection: true); } // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive @@ -649,7 +649,7 @@ public virtual void Release() if (_proxyHandleTrackingResurrection.IsAllocated) { - _proxyHandleTrackingResurrection.Free(); + _proxyHandleTrackingResurrection.Dispose(); } // If the inner was supplied, we need to release our reference. @@ -664,7 +664,7 @@ public virtual void Release() ~NativeObjectWrapper() { - if (_proxyHandleTrackingResurrection.IsAllocated && _proxyHandleTrackingResurrection.Target != null) + if (_proxyHandleTrackingResurrection.IsAllocated && _proxyHandleTrackingResurrection.TryGetTarget(out _)) { // The RCW object has not been fully collected, so it still // can make calls on the native object in its finalizer. From 0bfbcc76417da3fbe0a57418dd27afe8a11e6314 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 00:57:13 -0700 Subject: [PATCH 3/9] Use WeakGCHandle for the RCW proxy handle Both handles tracking the proxy object are now strongly typed weak handles. They allocate through GCHandle.InternalAlloc without revalidating the handle type, and skip the cast when reading the target. The layout is unchanged, so the native mirror of 'NativeObjectWrapper' still matches: 'GCHandle' only alters the stored value for pinned handles, so a weak handle holds the raw handle in both representations. This also applies to the on-stack COM struct used for reference tracker callbacks, which passes the handle straight through to native code. The redundant 'IsAllocated' checks around the disposal calls are also dropped, as 'WeakGCHandle.Dispose' already handles a default handle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrackerObjectManager.NativeAot.cs | 8 +++--- .../Runtime/InteropServices/ComWrappers.cs | 26 +++++++------------ .../InteropServices/TrackerObjectManager.cs | 2 +- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs index 7bc461cef8d54c..db592b2f76606c 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs @@ -186,7 +186,7 @@ internal static void DetachNonPromotedObjects() ReferenceTrackerNativeObjectWrapper? nativeObjectWrapper = Unsafe.As(weakNativeObjectWrapperHandle.Target); if (nativeObjectWrapper != null && nativeObjectWrapper.TrackerObject != IntPtr.Zero && - !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.Target)) + !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null)) { // Notify the wrapper it was not promoted and is being collected. BeforeWrapperFinalized(nativeObjectWrapper.TrackerObject); @@ -205,9 +205,9 @@ internal static unsafe class FindReferenceTargetsCallback internal ref struct Instance { private readonly IntPtr _vtable; // First field is IUnknown based vtable. - public GCHandle RootObject; + public WeakGCHandle RootObject; - public Instance(GCHandle handle) + public Instance(WeakGCHandle handle) { _vtable = (IntPtr)Unsafe.AsPointer(in FindReferenceTargetsCallback.Vftbl); RootObject = handle; @@ -240,7 +240,7 @@ private static unsafe int IFindReferenceTargetsCallback_FoundTrackerTarget(IntPt return HResults.E_POINTER; } - object sourceObject = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.Target!; + _ = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.TryGetTarget(out object? sourceObject); if (!TryGetObject(referenceTrackerTarget, out object? targetObject)) { diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index 736b79161f72d4..66641092cd2a20 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -548,7 +548,7 @@ internal unsafe class NativeObjectWrapper private ComWrappers _comWrappers; private IntPtr _externalComObject; private IntPtr _inner; - private GCHandle _proxyHandle; + private WeakGCHandle _proxyHandle; private WeakGCHandle _proxyHandleTrackingResurrection; private readonly bool _aggregatedManagedObjectWrapper; private readonly bool _uniqueInstance; @@ -594,7 +594,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper _inner = inner; _comWrappers = comWrappers; _uniqueInstance = flags.HasFlag(CreateObjectFlags.UniqueInstance); - _proxyHandle = GCHandle.Alloc(comProxy, GCHandleType.Weak); + _proxyHandle = new WeakGCHandle(comProxy); // We have a separate handle tracking resurrection as we want to make sure // we clean up the NativeObjectWrapper only after the RCW has been finalized @@ -630,7 +630,7 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper internal IntPtr ExternalComObject => _externalComObject; internal ComWrappers ComWrappers => _comWrappers; - internal GCHandle ProxyHandle => _proxyHandle; + internal WeakGCHandle ProxyHandle => _proxyHandle; internal bool IsUniqueInstance => _uniqueInstance; internal bool IsAggregatedWithManagedObjectWrapper => _aggregatedManagedObjectWrapper; @@ -642,15 +642,8 @@ public virtual void Release() _comWrappers = null!; } - if (_proxyHandle.IsAllocated) - { - _proxyHandle.Free(); - } - - if (_proxyHandleTrackingResurrection.IsAllocated) - { - _proxyHandleTrackingResurrection.Dispose(); - } + _proxyHandle.Dispose(); + _proxyHandleTrackingResurrection.Dispose(); // If the inner was supplied, we need to release our reference. if (_inner != IntPtr.Zero) @@ -1274,7 +1267,7 @@ private void RegisterWrapperForObject(NativeObjectWrapper wrapper, object comPro // for the same COM instance, but in that case we'll be passed the same NativeObjectWrapper instance // for both threads. In that case, it doesn't matter which thread adds the entry to the NativeObjectWrapper table // as the entry is always the same pair. - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); + Debug.Assert(wrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && proxyTarget == comProxy); Debug.Assert(wrapper.IsUniqueInstance || _rcwCache.FindProxyForComInstance(wrapper.ExternalComObject) == comProxy); // Add the input wrapper bound to the COM proxy, if there isn't one already. If another thread raced @@ -1444,7 +1437,7 @@ public Bucket() _lock.EnterWriteLock(); try { - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); + Debug.Assert(wrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && proxyTarget == comProxy); ref WeakGCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); if (!exists) { @@ -1460,10 +1453,9 @@ public Bucket() } else { - object? existingProxy = cachedWrapper.ProxyHandle.Target; // The target NativeObjectWrapper was not collected, but we need to make sure // that the proxy object is still alive. - if (existingProxy is not null) + if (cachedWrapper.ProxyHandle.TryGetTarget(out object? existingProxy)) { // The existing proxy object is still alive, we will use that. return (cachedWrapper, existingProxy); @@ -1495,7 +1487,7 @@ public Bucket() return null; } if (existingHandle.TryGetTarget(out NativeObjectWrapper? cachedWrapper) - && cachedWrapper.ProxyHandle.Target is object cachedProxy) + && cachedWrapper.ProxyHandle.TryGetTarget(out object? cachedProxy)) { // The target exists and is still alive. Return it. return cachedProxy; diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs index e63c6900ac5b6e..a4fee975aa8338 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs @@ -86,7 +86,7 @@ internal static void ReleaseExternalObjectsFromCurrentThread() { wrappersToRemove.Add(nativeObjectWrapper); - object? target = nativeObjectWrapper.ProxyHandle.Target; + object? target = nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null; if (target != null) { objects.Add(target); From 76362eed3c28f41c728bc5bc15d9494a72fc8879 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 8 Aug 2026 01:02:00 -0700 Subject: [PATCH 4/9] Branch directly on TryGetTarget when collecting tracked proxies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/InteropServices/TrackerObjectManager.NativeAot.cs | 3 ++- .../src/System/Runtime/InteropServices/TrackerObjectManager.cs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs index db592b2f76606c..e24968b025bdd3 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs @@ -186,7 +186,8 @@ internal static void DetachNonPromotedObjects() ReferenceTrackerNativeObjectWrapper? nativeObjectWrapper = Unsafe.As(weakNativeObjectWrapperHandle.Target); if (nativeObjectWrapper != null && nativeObjectWrapper.TrackerObject != IntPtr.Zero && - !RuntimeImports.RhIsPromoted(nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null)) + nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && + !RuntimeImports.RhIsPromoted(proxyTarget)) { // Notify the wrapper it was not promoted and is being collected. BeforeWrapperFinalized(nativeObjectWrapper.TrackerObject); diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs index a4fee975aa8338..8de9a4d60bd455 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.cs @@ -86,8 +86,7 @@ internal static void ReleaseExternalObjectsFromCurrentThread() { wrappersToRemove.Add(nativeObjectWrapper); - object? target = nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) ? proxyTarget : null; - if (target != null) + if (nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? target)) { objects.Add(target); } From 9252562bc75224c24e6c82803749e2bd5559237f Mon Sep 17 00:00:00 2001 From: Austin Wise Date: Sun, 9 Aug 2026 09:13:03 -0700 Subject: [PATCH 5/9] Create test for resurrection of RCW --- .../Interop/COM/ComWrappers/API/Program.cs | 81 ++++++++++++++++++- src/tests/Interop/COM/ComWrappers/Common.cs | 28 +++++-- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/tests/Interop/COM/ComWrappers/API/Program.cs b/src/tests/Interop/COM/ComWrappers/API/Program.cs index 6377fcb2e7c351..233a476c4d747f 100644 --- a/src/tests/Interop/COM/ComWrappers/API/Program.cs +++ b/src/tests/Interop/COM/ComWrappers/API/Program.cs @@ -33,6 +33,8 @@ static TestComWrappers() fpWrappedQueryInterface = MockReferenceTrackerRuntime.WrapQueryInterface(fpQueryInterface); } + public bool UseManualReleaseITestObjectWrapper { get; init; } + protected unsafe override ComInterfaceEntry* ComputeVtables(object obj, CreateComInterfaceFlags flags, out int count) { ComInterfaceEntry* entryRaw = null; @@ -133,7 +135,14 @@ protected override object CreateObject(IntPtr externalComObject, CreateObjectFla hr = Marshal.QueryInterface(externalComObject, typeof(ITest).GUID, out iTest); if (hr == 0) { - return new ITestObjectWrapper(iTest); + if (UseManualReleaseITestObjectWrapper) + { + return new ManualReleaseITestObjectWrapper(iTest); + } + else + { + return new ITestObjectWrapper(iTest); + } } Assert.Fail("The COM object should support ITrackerObject or ITest for all tests in this test suite."); @@ -418,9 +427,9 @@ unsafe static void CallSetValue(TestComWrappers wrappers, Test testInstance, int [MethodImpl(MethodImplOptions.NoInlining)] [ActiveIssue("Not supported on Mono", TestRuntimes.Mono)] [Fact] - public void ValidateResurrection() + public void ValidateManagedObjectWrapperResurrection() { - Console.WriteLine($"Running {nameof(ValidateResurrection)}..."); + Console.WriteLine($"Running {nameof(ValidateManagedObjectWrapperResurrection)}..."); var wrappers = new TestComWrappers(); @@ -621,6 +630,72 @@ public void ValidateMappingAPIs() Marshal.Release(unmanagedObjIUnknown); } + class Resurrecter() + { + public ManualReleaseITestObjectWrapper? UnmanagedWrapper; + + ~Resurrecter() + { + if (UnmanagedWrapper != null) + { + GC.ReRegisterForFinalize(this); + } + } + } + + + [MethodImpl(MethodImplOptions.NoInlining)] + [ActiveIssue("Not supported on Mono", TestRuntimes.Mono)] + [Fact] + public void ValidateNativeObjectWrapperResurrection() + { + Console.WriteLine($"Running {nameof(ValidateNativeObjectWrapperResurrection)}..."); + + var cw = new TestComWrappers() + { + UseManualReleaseITestObjectWrapper = true, + }; + + WeakGCHandle resurrecter; + nint unmanagedObj = AllocateWrapper(cw, out resurrecter); + Assert.Equal(0, Marshal.QueryInterface(unmanagedObj, IUnknownVtbl.IID_IUnknown, out IntPtr unmanagedObjIUnknown)); + ForceGC(); + AssertNativeObjectWrapperAlive(cw, resurrecter, unmanagedObjIUnknown); + + resurrecter.Dispose(); + Marshal.Release(unmanagedObjIUnknown); + Assert.Equal(0, Marshal.Release(unmanagedObj)); + + [MethodImpl(MethodImplOptions.NoInlining)] + static nint AllocateWrapper(ComWrappers cw, out WeakGCHandle handle) + { + Test test = new(); + nint comWrapper = cw.GetOrCreateComInterfaceForObject(test, CreateComInterfaceFlags.None); + Assert.NotEqual(IntPtr.Zero, comWrapper); + + var unmanagedWrapper = (ManualReleaseITestObjectWrapper)cw.GetOrCreateObjectForComInstance(comWrapper, CreateObjectFlags.UniqueInstance); + Resurrecter resurrecter = new() + { + UnmanagedWrapper = unmanagedWrapper, + }; + handle = new WeakGCHandle(resurrecter, true); + return comWrapper; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void AssertNativeObjectWrapperAlive(ComWrappers cw, WeakGCHandle handle, IntPtr unmanagedObj) + { + Assert.True(handle.TryGetTarget(out Resurrecter resurrecter)); + ManualReleaseITestObjectWrapper? unmanagedWrapper = resurrecter.UnmanagedWrapper; + Assert.NotNull(resurrecter); + Assert.True(ComWrappers.TryGetComInstance(unmanagedWrapper, out IntPtr unmanagedObjOther)); + Assert.Equal(unmanagedObj, unmanagedObjOther); + resurrecter.UnmanagedWrapper = null; + Marshal.Release(unmanagedObjOther); + unmanagedWrapper.FinalRelease(); + } + } + [MethodImpl(MethodImplOptions.NoInlining)] [ActiveIssue("Not supported on Mono", TestRuntimes.Mono)] [Fact] diff --git a/src/tests/Interop/COM/ComWrappers/Common.cs b/src/tests/Interop/COM/ComWrappers/Common.cs index ca32876a04a2ba..03d98749076873 100644 --- a/src/tests/Interop/COM/ComWrappers/Common.cs +++ b/src/tests/Interop/COM/ComWrappers/Common.cs @@ -94,13 +94,13 @@ public static int SetValueInternal(IntPtr dispatchPtr, int i) } } - public class ITestObjectWrapper : ITest + public class ITestObjectWrapperBase : ITest { private readonly ITestVtbl._SetValue _setValue; - private readonly IntPtr _ptr; - private bool _released; + protected readonly IntPtr _ptr; + protected bool _released; - public ITestObjectWrapper(IntPtr ptr) + public ITestObjectWrapperBase(IntPtr ptr) { _ptr = ptr; VtblPtr inst = Marshal.PtrToStructure(ptr); @@ -117,6 +117,24 @@ public int FinalRelease() return count; } + public void SetValue(int i) => _setValue(_ptr, i); + } + + public class ManualReleaseITestObjectWrapper : ITestObjectWrapperBase + { + public ManualReleaseITestObjectWrapper(IntPtr ptr) + : base(ptr) + { + } + } + + public class ITestObjectWrapper : ITestObjectWrapperBase + { + public ITestObjectWrapper(IntPtr ptr) + : base(ptr) + { + } + ~ITestObjectWrapper() { if (_ptr != IntPtr.Zero && !_released) @@ -124,8 +142,6 @@ public int FinalRelease() Marshal.Release(_ptr); } } - - public void SetValue(int i) => _setValue(_ptr, i); } // From 869fe824e709d92774483a6e11653a14ca7f772d Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 15 Aug 2026 01:17:11 -0700 Subject: [PATCH 6/9] Track resurrection on the proxy handle instead of skipping it Skipping the resurrection tracking handle for an RCW whose type declares no finalizer was wrong. Such an RCW never becomes eligible for finalization on its own account, but something else's finalizer can hold on to it and resurrect it, and then this wrapper would already have torn down state the RCW still needs. The two handles only disagree while the RCW is unreachable but not yet collected. An RCW that declares no finalizer only reaches that state by way of someone else holding it, and in that case reporting it as alive is both what keeps this wrapper from releasing too early and the honest answer, as it may be about to become reachable again. So rather than dropping the second handle, let the proxy handle track resurrection and drop the separate one, which costs the same single handle per RCW as before while behaving correctly. An RCW that does declare a finalizer does reach that state by itself, and there the two meanings genuinely differ, so it keeps both handles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/InteropServices/ComWrappers.cs | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index 66641092cd2a20..d1af8161987998 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -594,21 +594,28 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper _inner = inner; _comWrappers = comWrappers; _uniqueInstance = flags.HasFlag(CreateObjectFlags.UniqueInstance); - _proxyHandle = new WeakGCHandle(comProxy); - // We have a separate handle tracking resurrection as we want to make sure - // we clean up the NativeObjectWrapper only after the RCW has been finalized - // due to it can access the native object in the finalizer. At the same time, - // we want other callers which are using ProxyHandle such as the reference tracker runtime - // to see the object as not alive once it is eligible for finalization. + // The wrapper's finalizer must not release anything while the RCW is still able to observe the + // native object, which is why a handle that tracks resurrection is needed: unlike a plain weak + // handle, it stays set until the RCW has actually been collected, rather than merely becoming + // unreachable. Callers such as the reference tracker runtime want the opposite, and need to see + // the RCW as gone as soon as it is eligible for finalization, which is what 'ProxyHandle' is for. // - // If the RCW has no finalizer, it can never observe the native object past the point - // where it becomes unreachable, and it can never be resurrected. The extra handle would - // therefore always be cleared at the same time as the one above, so we skip allocating it. - // This matters because allocating, clearing and freeing GC handles is a substantial part - // of the cost of every RCW, and resurrection tracking handles are also more expensive for - // the GC to process than plain weak handles. - if (RuntimeHelpers.ObjectHasFinalizer(comProxy)) + // Those two only disagree while the RCW is unreachable but not yet collected. An RCW whose type + // declares no finalizer is never in that state on its own account, so a single handle can serve + // both purposes, halving the handles every such RCW costs. It can still be put in that state by + // something else's finalizer holding on to it, and then resurrecting it, and in that case having + // the one handle track resurrection is what keeps this wrapper from tearing down state the + // resurrected RCW still needs. Reporting such an RCW as alive is also the honest answer, as it + // may well be about to become reachable again. + // + // An RCW that does declare a finalizer does reach that state on its own, and there the two + // meanings genuinely differ, so it pays for both handles. + bool proxyHasFinalizer = RuntimeHelpers.ObjectHasFinalizer(comProxy); + + _proxyHandle = new WeakGCHandle(comProxy, trackResurrection: !proxyHasFinalizer); + + if (proxyHasFinalizer) { _proxyHandleTrackingResurrection = new WeakGCHandle(comProxy, trackResurrection: true); } @@ -657,7 +664,15 @@ public virtual void Release() ~NativeObjectWrapper() { - if (_proxyHandleTrackingResurrection.IsAllocated && _proxyHandleTrackingResurrection.TryGetTarget(out _)) + // When the RCW declares no finalizer, no second handle was allocated and the proxy handle is + // the one tracking resurrection, so it answers this question just as well. Neither is allocated + // once this wrapper has been released, which happens eagerly when one loses a registration race, + // and then there is nothing left to keep alive for. + WeakGCHandle resurrectionHandle = _proxyHandleTrackingResurrection.IsAllocated + ? _proxyHandleTrackingResurrection + : _proxyHandle; + + if (resurrectionHandle.IsAllocated && resurrectionHandle.TryGetTarget(out _)) { // The RCW object has not been fully collected, so it still // can make calls on the native object in its finalizer. From d9e08e64c8149602ff4049da414b6ae1df37ed15 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 15 Aug 2026 15:37:13 -0700 Subject: [PATCH 7/9] Address review feedback on the RCW handle changes - Say that ObjectHasFinalizer covers an inherited finalizer too, rather than only one the type declares, which is what the underlying flag means on both runtimes. - Drop AggressiveInlining from it. The JIT inlines it into the wrapper constructor either way, so the attribute wasn't buying anything. - Assert that the reference tracker callback's root object is still set, and make the nullability explicit at the point it is passed on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompilerServices/RuntimeHelpers.CoreCLR.cs | 4 ++-- .../CompilerServices/RuntimeHelpers.NativeAot.cs | 4 ++-- .../TrackerObjectManager.NativeAot.cs | 6 +++++- .../System/Runtime/InteropServices/ComWrappers.cs | 12 ++++++------ 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs index 9d73f0d86c68e0..78d05e0d2b96b8 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs @@ -449,9 +449,9 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } - // Returns true iff the type of the object declares a finalizer. + // Returns true iff the type of the object requires finalization, + // which includes a finalizer inherited from a base type. // Callers are required to keep obj alive - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static unsafe bool ObjectHasFinalizer(object obj) { return GetMethodTable(obj)->HasFinalizer; diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs index 4f392bf2bac552..3b5a2c10f52912 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs @@ -213,9 +213,9 @@ internal static unsafe bool ObjectHasComponentSize(object obj) return GetMethodTable(obj)->HasComponentSize; } - // Returns true iff the type of the object declares a finalizer. + // Returns true iff the type of the object requires finalization, + // which includes a finalizer inherited from a base type. // Callers are required to keep obj alive - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static unsafe bool ObjectHasFinalizer(object obj) { return GetMethodTable(obj)->IsFinalizable; diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs index e24968b025bdd3..f6fa0b09c2aaf3 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/InteropServices/TrackerObjectManager.NativeAot.cs @@ -243,6 +243,10 @@ private static unsafe int IFindReferenceTargetsCallback_FoundTrackerTarget(IntPt _ = ((FindReferenceTargetsCallback.Instance*)pThis)->RootObject.TryGetTarget(out object? sourceObject); + // The callback is only ever set up with the handle of an RCW that was alive at the time, and + // that RCW keeps its wrapper alive, so the handle is expected to still have its target here + Debug.Assert(sourceObject is not null); + if (!TryGetObject(referenceTrackerTarget, out object? targetObject)) { return HResults.S_FALSE; @@ -254,7 +258,7 @@ private static unsafe int IFindReferenceTargetsCallback_FoundTrackerTarget(IntPt } // Notify the runtime a reference path was found. - return TrackerObjectManager.AddReferencePath(sourceObject, targetObject) ? HResults.S_OK : HResults.S_FALSE; + return TrackerObjectManager.AddReferencePath(sourceObject!, targetObject) ? HResults.S_OK : HResults.S_FALSE; } internal struct ReferenceTargetsVftbl diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index d1af8161987998..ee53ff88172324 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -601,16 +601,16 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper // unreachable. Callers such as the reference tracker runtime want the opposite, and need to see // the RCW as gone as soon as it is eligible for finalization, which is what 'ProxyHandle' is for. // - // Those two only disagree while the RCW is unreachable but not yet collected. An RCW whose type - // declares no finalizer is never in that state on its own account, so a single handle can serve - // both purposes, halving the handles every such RCW costs. It can still be put in that state by + // Those two only disagree while the RCW is unreachable but not yet collected. An RCW that has + // no finalizer is never in that state on its own account, so a single handle can serve both + // purposes, halving the handles every such RCW costs. It can still be put in that state by // something else's finalizer holding on to it, and then resurrecting it, and in that case having // the one handle track resurrection is what keeps this wrapper from tearing down state the // resurrected RCW still needs. Reporting such an RCW as alive is also the honest answer, as it // may well be about to become reachable again. // - // An RCW that does declare a finalizer does reach that state on its own, and there the two - // meanings genuinely differ, so it pays for both handles. + // An RCW that does have a finalizer, whether its own or an inherited one, does reach that state + // on its own, and there the two meanings genuinely differ, so it pays for both handles. bool proxyHasFinalizer = RuntimeHelpers.ObjectHasFinalizer(comProxy); _proxyHandle = new WeakGCHandle(comProxy, trackResurrection: !proxyHasFinalizer); @@ -664,7 +664,7 @@ public virtual void Release() ~NativeObjectWrapper() { - // When the RCW declares no finalizer, no second handle was allocated and the proxy handle is + // When the RCW has no finalizer, no second handle was allocated and the proxy handle is // the one tracking resurrection, so it answers this question just as well. Neither is allocated // once this wrapper has been released, which happens eagerly when one loses a registration race, // and then there is nothing left to keep alive for. From 884ee492c5edca4ecd79222ec2a8f1f2dfe7f660 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 15 Aug 2026 17:04:46 -0700 Subject: [PATCH 8/9] Keep the object alive inside ObjectHasFinalizer Reading the MethodTable requires the object to stay alive, and having every caller remember that is easy to get wrong. Do it in the helper instead, the way GetMultiDimensionalArrayRank already does, and drop the call at the one use site. The generated code for the wrapper constructor is unchanged: same 353 bytes, same 102 instructions, and an identical instruction stream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs | 5 +++-- .../Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs | 5 +++-- .../src/System/Runtime/InteropServices/ComWrappers.cs | 3 --- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs index 78d05e0d2b96b8..5a94a338c06588 100644 --- a/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs +++ b/src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs @@ -451,10 +451,11 @@ internal static unsafe bool ObjectHasComponentSize(object obj) // Returns true iff the type of the object requires finalization, // which includes a finalizer inherited from a base type. - // Callers are required to keep obj alive internal static unsafe bool ObjectHasFinalizer(object obj) { - return GetMethodTable(obj)->HasFinalizer; + bool hasFinalizer = GetMethodTable(obj)->HasFinalizer; + GC.KeepAlive(obj); // Keep MethodTable alive + return hasFinalizer; } /// diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs index 3b5a2c10f52912..03f3a3a8748c02 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs @@ -215,10 +215,11 @@ internal static unsafe bool ObjectHasComponentSize(object obj) // Returns true iff the type of the object requires finalization, // which includes a finalizer inherited from a base type. - // Callers are required to keep obj alive internal static unsafe bool ObjectHasFinalizer(object obj) { - return GetMethodTable(obj)->IsFinalizable; + bool hasFinalizer = GetMethodTable(obj)->IsFinalizable; + GC.KeepAlive(obj); // Keep MethodTable alive + return hasFinalizer; } public static void PrepareMethod(RuntimeMethodHandle method) diff --git a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs index ee53ff88172324..8362b47016ee07 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Runtime/InteropServices/ComWrappers.cs @@ -620,9 +620,6 @@ protected NativeObjectWrapper(IntPtr externalComObject, IntPtr inner, ComWrapper _proxyHandleTrackingResurrection = new WeakGCHandle(comProxy, trackResurrection: true); } - // 'ObjectHasFinalizer' reads the MethodTable, which requires the object to be kept alive - GC.KeepAlive(comProxy); - // If this is an aggregation scenario and the identity object // is a managed object wrapper, we need to call Release() to // indicate this external object isn't rooted. In the event the From 2f4d531cd5e99eba8962cc8a5bc83a7607e856ce Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Sat, 15 Aug 2026 18:36:14 -0700 Subject: [PATCH 9/9] Drop the KeepAlive from the NativeAOT ObjectHasFinalizer NativeAOT has no collectible types, so the MethodTable can't go away while the call is in progress and there is nothing to keep alive. This matches ObjectHasComponentSize right above it, which doesn't have one either. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs index 03f3a3a8748c02..93d0d56a4f94b0 100644 --- a/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs +++ b/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.NativeAot.cs @@ -217,9 +217,7 @@ internal static unsafe bool ObjectHasComponentSize(object obj) // which includes a finalizer inherited from a base type. internal static unsafe bool ObjectHasFinalizer(object obj) { - bool hasFinalizer = GetMethodTable(obj)->IsFinalizable; - GC.KeepAlive(obj); // Keep MethodTable alive - return hasFinalizer; + return GetMethodTable(obj)->IsFinalizable; } public static void PrepareMethod(RuntimeMethodHandle method)