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 d4c2e93237d078..94683cea769356 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/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..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.Target)) + nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && + !RuntimeImports.RhIsPromoted(proxyTarget)) { // Notify the wrapper it was not promoted and is being collected. BeforeWrapperFinalized(nativeObjectWrapper.TrackerObject); @@ -205,9 +206,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 +241,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 551f84fc1ec3e5..51880b5185f619 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 @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Threading; @@ -547,8 +548,8 @@ internal unsafe class NativeObjectWrapper private ComWrappers _comWrappers; private IntPtr _externalComObject; private IntPtr _inner; - private GCHandle _proxyHandle; - private GCHandle _proxyHandleTrackingResurrection; + private WeakGCHandle _proxyHandle; + private WeakGCHandle _proxyHandleTrackingResurrection; private readonly bool _aggregatedManagedObjectWrapper; private readonly bool _uniqueInstance; @@ -593,14 +594,27 @@ 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 // 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 = 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 @@ -616,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; @@ -628,15 +642,8 @@ public virtual void Release() _comWrappers = null!; } - if (_proxyHandle.IsAllocated) - { - _proxyHandle.Free(); - } - - if (_proxyHandleTrackingResurrection.IsAllocated) - { - _proxyHandleTrackingResurrection.Free(); - } + _proxyHandle.Dispose(); + _proxyHandleTrackingResurrection.Dispose(); // If the inner was supplied, we need to release our reference. if (_inner != IntPtr.Zero) @@ -650,7 +657,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. @@ -1260,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 @@ -1296,10 +1303,56 @@ internal void RemoveWrappersFromCache(IEnumerable wrappers) _rcwCache.RemoveAll(wrappers); } - private sealed class RcwCache + /// + /// The cache mapping COM instances to the objects tracking their RCWs. + /// + /// + /// The cache is partitioned into several independent buckets, each with its own lock, so that operations on + /// COM instances that map to different buckets don't contend with one another. Reducing that contention is + /// important because the cache is consulted on essentially every transition from native to managed code, and + /// because the finalizer thread concurrently takes write locks to remove entries for collected RCWs. + /// + private readonly struct RcwCache { - private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); - private readonly Dictionary _cache = []; + private readonly Bucket[] _buckets; + + public RcwCache() + { + // Use as many buckets as there are processors, matching the default concurrency level of + // 'ConcurrentDictionary'. The count is rounded up to a power of two so that the bucket for a + // given COM instance can be selected with a mask rather than a division. + int bucketCount = (int)BitOperations.RoundUpToPowerOf2((uint)Environment.ProcessorCount); + Bucket[] buckets = new Bucket[bucketCount]; + + for (int i = 0; i < buckets.Length; i++) + { + buckets[i] = new Bucket(); + } + + _buckets = buckets; + } + + /// + /// Gets the bucket owning the entries for a given COM instance. + /// + /// The com instance to get the bucket for. + /// The bucket owning the entries for . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private ref readonly Bucket GetBucket(IntPtr comPointer) + { + Bucket[] buckets = _buckets; + + // COM instances are heap allocated, so they're always at least pointer aligned (and 16-byte aligned + // in practice). That means their low bits are constant and can't be used to select a bucket directly. + // Multiplying by a large odd constant (2^64 divided by the golden ratio) mixes every input bit into + // the high half of the product, which is then masked to produce the index. The whole sequence lowers + // to a multiply, a shift and a mask, which is negligible next to the lookup that follows. + ulong hash = (ulong)(nuint)comPointer * 0x9E3779B97F4A7C15; + uint index = (uint)(hash >> 32) & (uint)(buckets.Length - 1); + + // Return the bucket by reference, so that it's addressed in place in the array rather than copied + return ref buckets[index]; + } /// /// Gets the current RCW proxy object for if it exists in the cache or inserts a new entry with . @@ -1310,139 +1363,182 @@ private sealed class RcwCache /// The proxy object currently in the cache for or the proxy object owned by if no entry exists and the corresponding native wrapper. public (NativeObjectWrapper actualWrapper, object actualProxy) GetOrAddProxyForComInstance(IntPtr comPointer, NativeObjectWrapper wrapper, object comProxy) { - _lock.EnterWriteLock(); - try - { - Debug.Assert(wrapper.ProxyHandle.Target == comProxy); - ref GCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); - if (!exists) - { - // Someone else didn't beat us to adding the entry to the cache. - // Add our entry here. - rcwEntry = GCHandle.Alloc(wrapper, GCHandleType.Weak); - } - else if (rcwEntry.Target is not (NativeObjectWrapper cachedWrapper)) - { - Debug.Assert(rcwEntry.IsAllocated); - // The target was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; - } - 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) - { - // The existing proxy object is still alive, we will use that. - return (cachedWrapper, existingProxy); - } + return GetBucket(comPointer).GetOrAddProxyForComInstance(comPointer, wrapper, comProxy); + } - // The proxy object was collected, so we need to update the cache entry. - rcwEntry.Target = wrapper; - } + /// + /// Gets the current RCW proxy object for , if it exists in the cache and is still alive. + /// + /// The com instance we want to get the RCW for. + /// The proxy object currently in the cache for , if any. + public object? FindProxyForComInstance(IntPtr comPointer) + { + return GetBucket(comPointer).FindProxyForComInstance(comPointer); + } - // We either added an entry to the cache or updated an existing entry that was dead. - // Return our target object. - return (wrapper, comProxy); - } - finally + /// + /// Removes the entry associating with , if present. + /// + /// The com instance to remove the entry for. + /// The that is expected to be in the cache. + public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) + { + GetBucket(comPointer).Remove(comPointer, wrapper); + } + + /// + /// Removes the entries for all input objects, if present. + /// + /// The objects to remove the entries for. + public void RemoveAll(IEnumerable wrappers) + { + // The wrappers can span multiple buckets, so they're removed one at a time. This is only used when + // tearing down an apartment, so the extra lock acquisitions don't matter. Note that entries are not + // removed atomically as a batch anymore, but that was never something callers could rely on: the + // cache is only ever observed one entry at a time. + foreach (NativeObjectWrapper wrapper in wrappers) { - _lock.ExitWriteLock(); + IntPtr comPointer = wrapper.ExternalComObject; + + GetBucket(comPointer).Remove(comPointer, wrapper); } } - public object? FindProxyForComInstance(IntPtr comPointer) + /// + /// A single partition of the RCW cache, holding the entries for all COM instances that map to it. + /// + /// + /// This is a struct so that buckets are stored inline in the containing array. That saves a dereference + /// on each lookup, and lets several buckets share a cache line. There is no false sharing to worry about, + /// as the fields are only ever read: all mutable state lives in the referenced lock and dictionary. + /// + private readonly struct Bucket { - _lock.EnterReadLock(); - try + private readonly ReaderWriterLockSlim _lock; + private readonly Dictionary> _cache; + + public Bucket() + { + _lock = new ReaderWriterLockSlim(); + _cache = []; + } + + /// + public (NativeObjectWrapper actualWrapper, object actualProxy) GetOrAddProxyForComInstance(IntPtr comPointer, NativeObjectWrapper wrapper, object comProxy) { - if (!_cache.TryGetValue(comPointer, out GCHandle existingHandle)) + _lock.EnterWriteLock(); + try { - // No entry in the cache. - return null; + Debug.Assert(wrapper.ProxyHandle.TryGetTarget(out object? proxyTarget) && proxyTarget == comProxy); + ref WeakGCHandle rcwEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, comPointer, out bool exists); + if (!exists) + { + // Someone else didn't beat us to adding the entry to the cache. + // Add our entry here. + rcwEntry = new WeakGCHandle(wrapper); + } + else if (!rcwEntry.TryGetTarget(out NativeObjectWrapper? cachedWrapper)) + { + Debug.Assert(rcwEntry.IsAllocated); + // The target was collected, so we need to update the cache entry. + rcwEntry.SetTarget(wrapper); + } + else + { + // The target NativeObjectWrapper was not collected, but we need to make sure + // that the proxy object is still alive. + if (cachedWrapper.ProxyHandle.TryGetTarget(out object? existingProxy)) + { + // The existing proxy object is still alive, we will use that. + return (cachedWrapper, existingProxy); + } + + // The proxy object was collected, so we need to update the cache entry. + rcwEntry.SetTarget(wrapper); + } + + // We either added an entry to the cache or updated an existing entry that was dead. + // Return our target object. + return (wrapper, comProxy); } - if (existingHandle.Target is NativeObjectWrapper { ProxyHandle.Target: object cachedProxy }) + finally { - // The target exists and is still alive. Return it. - return cachedProxy; + _lock.ExitWriteLock(); } - // The target was collected, so we need to remove the entry from the cache. - // We'll do this in a write lock after we exit the read lock. - // We don't use an upgradeable lock here as only one thread can hold an upgradeable lock at a time, - // effectively eliminating the benefit of using a reader-writer lock. - } - finally - { - _lock.ExitReadLock(); } - _lock.EnterWriteLock(); - try + /// + public object? FindProxyForComInstance(IntPtr comPointer) { - // Someone else could have removed the entry or added a new one in the time - // between us releasing the read lock and acquiring the write lock. - if (_cache.TryGetValue(comPointer, out GCHandle existingHandle) - && existingHandle.Target is null) + _lock.EnterReadLock(); + try + { + if (!_cache.TryGetValue(comPointer, out WeakGCHandle existingHandle)) + { + // No entry in the cache. + return null; + } + if (existingHandle.TryGetTarget(out NativeObjectWrapper? cachedWrapper) + && cachedWrapper.ProxyHandle.TryGetTarget(out object? cachedProxy)) + { + // The target exists and is still alive. Return it. + return cachedProxy; + } + // The target was collected, so we need to remove the entry from the cache. + // We'll do this in a write lock after we exit the read lock. + // We don't use an upgradeable lock here as only one thread can hold an upgradeable lock at a time, + // effectively eliminating the benefit of using a reader-writer lock. + } + finally { - // There's still a dead entry in the cache, - // remove it. - _cache.Remove(comPointer); - existingHandle.Free(); + _lock.ExitReadLock(); } - } - finally - { - _lock.ExitWriteLock(); - } - return null; - } + _lock.EnterWriteLock(); + try + { + // Someone else could have removed the entry or added a new one in the time + // between us releasing the read lock and acquiring the write lock. + if (_cache.TryGetValue(comPointer, out WeakGCHandle existingHandle) + && !existingHandle.TryGetTarget(out _)) + { + // There's still a dead entry in the cache, + // remove it. + _cache.Remove(comPointer); + existingHandle.Dispose(); + } + } + finally + { + _lock.ExitWriteLock(); + } - public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) - { - _lock.EnterWriteLock(); - try - { - Remove_Locked(comPointer, wrapper); - } - finally - { - _lock.ExitWriteLock(); + return null; } - } - public void RemoveAll(IEnumerable wrappers) - { - _lock.EnterWriteLock(); - try + /// + public void Remove(IntPtr comPointer, NativeObjectWrapper wrapper) { - foreach (NativeObjectWrapper wrapper in wrappers) + _lock.EnterWriteLock(); + try { - Remove_Locked(wrapper.ExternalComObject, wrapper); + // TryGetOrCreateObjectForComInstanceInternal may have put a new entry into the cache + // in the time between the GC cleared the contents of the GC handle but before the + // NativeObjectWrapper finalizer ran. + // Only remove the entry if the target of the GC handle is the NativeObjectWrapper + // or is null (indicating that the corresponding NativeObjectWrapper has been scheduled for finalization). + if (_cache.TryGetValue(comPointer, out WeakGCHandle cachedRef) + && (!cachedRef.TryGetTarget(out NativeObjectWrapper? cachedWrapper) + || cachedWrapper == wrapper)) + { + _cache.Remove(comPointer); + cachedRef.Dispose(); + } + } + finally + { + _lock.ExitWriteLock(); } - } - finally - { - _lock.ExitWriteLock(); - } - } - - private void Remove_Locked(IntPtr comPointer, NativeObjectWrapper wrapper) - { - Debug.Assert(_lock.IsWriteLockHeld); - // This method is used in a scenario where we already have a lock on the cache, so we can skip acquiring the lock again. - // TryGetOrCreateObjectForComInstanceInternal may have put a new entry into the cache - // in the time between the GC cleared the contents of the GC handle but before the - // NativeObjectWrapper finalizer ran. - // Only remove the entry if the target of the GC handle is the NativeObjectWrapper - // or is null (indicating that the corresponding NativeObjectWrapper has been scheduled for finalization). - if (_cache.TryGetValue(comPointer, out GCHandle cachedRef) - && (wrapper == cachedRef.Target - || cachedRef.Target is null)) - { - _cache.Remove(comPointer); - cachedRef.Free(); } } } 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..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.Target; - if (target != null) + if (nativeObjectWrapper.ProxyHandle.TryGetTarget(out object? target)) { objects.Add(target); } diff --git a/src/tests/Interop/COM/ComWrappers/API/Program.cs b/src/tests/Interop/COM/ComWrappers/API/Program.cs index da49300f166e61..4de682ea3e79f1 100644 --- a/src/tests/Interop/COM/ComWrappers/API/Program.cs +++ b/src/tests/Interop/COM/ComWrappers/API/Program.cs @@ -1083,43 +1083,51 @@ public void ComWrappersNoLockAroundQueryInterface() Console.WriteLine($"Running {nameof(ComWrappersNoLockAroundQueryInterface)}..."); var cw = new RecursiveSimpleComWrappers(); + var managedObject = new RecursiveCrossThreadQI(cw); - IntPtr comObject = cw.GetOrCreateComInterfaceForObject(new RecursiveCrossThreadQI(cw), CreateComInterfaceFlags.None); + IntPtr comObject = cw.GetOrCreateComInterfaceForObject(managedObject, CreateComInterfaceFlags.None); try { + // The nested call has to use this same COM instance. The RCW cache is partitioned into buckets + // keyed off the COM instance, so using a different instance would only exercise the same lock by + // chance, and the test would no longer reliably catch a regression. + managedObject.NestedComObject = comObject; + _ = cw.GetOrCreateObjectForComInstance(comObject, CreateObjectFlags.TrackerObject); } finally { Marshal.Release(comObject); } + + Assert.True(managedObject.NestedCallCompleted); } - private class RecursiveCrossThreadQI(ComWrappers? wrappers) : ICustomQueryInterface + private class RecursiveCrossThreadQI(ComWrappers wrappers) : ICustomQueryInterface { + public IntPtr NestedComObject { get; set; } + + public bool NestedCallCompleted { get; private set; } + CustomQueryInterfaceResult ICustomQueryInterface.GetInterface(ref Guid iid, out IntPtr ppv) { ppv = IntPtr.Zero; - if (iid == ComWrappersHelper.IID_IReferenceTracker && wrappers is not null) + if (iid == ComWrappersHelper.IID_IReferenceTracker) { Console.WriteLine("Attempting to create a new COM object on a different thread."); + IntPtr nestedComObject = NestedComObject; Thread thread = new Thread(() => { - IntPtr comObject = wrappers.GetOrCreateComInterfaceForObject(new RecursiveCrossThreadQI(null), CreateComInterfaceFlags.None); - try - { - // Make sure that ComWrappers isn't locking in GetOrCreateObjectForComInstance - // around the QI call by calling it on a different thread from within a QI call to register a new managed wrapper - // for a COM object representing "this". - _ = wrappers.GetOrCreateObjectForComInstance(comObject, CreateObjectFlags.None); - } - finally - { - Marshal.Release(comObject); - } + // Make sure that ComWrappers isn't locking in GetOrCreateObjectForComInstance + // around the QI call by calling it on a different thread from within a QI call to register a new managed wrapper + // for a COM object representing "this". + _ = wrappers.GetOrCreateObjectForComInstance(nestedComObject, CreateObjectFlags.None); }); thread.Start(); - thread.Join(TimeSpan.FromSeconds(20)); // 20 seconds should be more than long enough for the thread to complete + + // The result is recorded and asserted by the caller, rather than asserted here, as this + // callback is invoked through the COM ABI, which a managed exception can't propagate through. + NestedCallCompleted = thread.Join(TimeSpan.FromSeconds(20)); // 20 seconds should be more than long enough for the thread to complete } return CustomQueryInterfaceResult.Failed;