Skip to content

Commit 3a9d7f8

Browse files
rolfbjarneCopilot
andauthored
[runtime] Fix a race condition when switching between weak and strong GCHandles. Fixes #24702. (#24841)
## Summary Fix a race condition in `xamarin_switch_gchandle` where one thread switching a GCHandle between weak and strong could free a handle that another thread was simultaneously reading. This caused intermittent crashes and "Could not find an existing managed instance" errors at runtime. Fixes #24702. ## Problem `xamarin_switch_gchandle` is called from retain/release to toggle an object's GCHandle between weak (so the GC can collect the managed peer) and strong (so the managed peer stays alive while Objective-C holds a reference). The old implementation performed a non-atomic free-then-replace on the object's `gc_handle` field: 1. Read the old GCHandle and its flags. 2. Allocate a new GCHandle (weak or strong). 3. Free the old GCHandle. 4. Store the new GCHandle on the object. Between steps 3 and 4, a concurrent thread invoking an exported Objective-C method would read the `gc_handle` field, obtain the already-freed handle, and either crash or fail to resolve the managed object — producing errors like: > Failed to marshal the Objective-C object 0x… Could not find an existing > managed instance for this object, nor was it possible to create a new > managed instance. ## Solution: dual-GCHandle scheme Instead of replacing the object's GCHandle in place, the fix introduces a **dual-GCHandle** design: - **The object's `gc_handle` is always a weak handle** and is never modified by `xamarin_switch_gchandle`. Concurrent readers always see a valid handle. - **A separate strong GCHandle** is stored in a global mutex-protected `CFMutableDictionary` (`strong_gchandle_hash`), keyed by the native object pointer. - **Switching to strong**: allocate a strong GCHandle for the managed object and insert it into the hash table. The weak handle remains on the object unchanged. - **Switching to weak**: remove and free the strong GCHandle from the hash table. The weak handle remains on the object unchanged. Because the `gc_handle` field is never written by `xamarin_switch_gchandle`, the race is eliminated: concurrent readers always see a valid weak GCHandle that can resolve the managed object (the strong GCHandle in the hash table keeps it alive and prevents collection). ## Changes ### `runtime/runtime.m` - Add `strong_gchandle_hash` (`CFMutableDictionaryRef`) and its `pthread_mutex_t` lock. - Add `free_strong_gchandle()` — removes and frees the strong handle for a native object from the hash table. - Add `create_strong_gchandle()` — creates a strong handle and inserts it into the hash table (no-op if one already exists, preventing duplicates from concurrent callers). - Rewrite `xamarin_switch_gchandle()` to use the dual-GCHandle scheme instead of free-then-replace. - Update `xamarin_free_gchandle()` to also clear any strong GCHandle from the hash table (`xamarin_free_gchandle()` is called when a native object is about to be freed). - Clean up `strong_gchandle_hash` during runtime shutdown. - Remove `get_gchandle_safe()` (no longer needed; replaced by `get_gchandle_without_flags()`). ### `runtime/xamarin/trampolines.h` - Remove `XamarinGCHandleFlags_WeakGCHandle = 1` (the flag is no longer needed since `gc_handle` is always weak). ### `src/Foundation/NSObject2.cs` - Remove `WeakGCHandle` from the managed `XamarinGCHandleFlags` enum. - Stop setting `WeakGCHandle` in `CreateManagedRef()`. ### `tests/monotouch-test/ObjCRuntime/GCHandleSwitchRaceTest.cs` (new) - Regression test that reproduces the race: one thread rapidly retains/releases an object (toggling the GCHandle) while another thread simultaneously invokes an exported Objective-C method (reading the GCHandle). Without the fix this crashes; with the fix it completes cleanly. ### `tests/dotnet/UnitTests/expected/iOS-MonoVM-*.txt` - Remove `WeakGCHandle` from preserved API expectations. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf92540 commit 3a9d7f8

6 files changed

Lines changed: 176 additions & 57 deletions

File tree

runtime/runtime.m

Lines changed: 93 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@
9090
static pthread_mutex_t framework_peer_release_lock;
9191
static MonoGHashTable *xamarin_wrapper_hash;
9292

93+
// Hash table mapping native object pointers (id) to strong GCHandles.
94+
// Used by the dual-gchandle scheme: the object's gc_handle is always a weak
95+
// handle used for lookups, while this table holds an optional strong handle
96+
// that keeps the managed object alive. See xamarin_switch_gchandle.
97+
static CFMutableDictionaryRef strong_gchandle_hash = NULL;
98+
static pthread_mutex_t strong_gchandle_hash_lock = PTHREAD_MUTEX_INITIALIZER;
99+
93100
static bool initialize_started = FALSE;
94101

95102
#include "delegates.inc"
@@ -412,16 +419,6 @@ -(void) xamarinSetGCHandleFlags: (enum XamarinGCHandleFlags) gchandle_flags;
412419
-(struct NSObjectData*) xamarinGetNSObjectData;
413420
@end
414421

415-
static inline GCHandle
416-
get_gchandle_safe (id self, enum XamarinGCHandleFlags *gchandle_flags)
417-
{
418-
id<XamarinExtendedObject> xself = self;
419-
GCHandle rv = [xself xamarinGetGCHandle];
420-
if (gchandle_flags)
421-
*gchandle_flags = [xself xamarinGetGCHandleFlags];
422-
return rv;
423-
}
424-
425422
static inline bool
426423
set_gchandle (id self, GCHandle gc_handle, enum XamarinGCHandleFlags flags, struct NSObjectData *data)
427424
{
@@ -1567,26 +1564,69 @@ -(struct NSObjectData*) xamarinGetNSObjectData;
15671564
*/
15681565
//#define DEBUG_REF_COUNTING
15691566

1567+
// Free the strong gchandle for a given native object.
1568+
static void
1569+
free_strong_gchandle (id self)
1570+
{
1571+
GCHandle strong_gchandle = INVALID_GCHANDLE;
1572+
pthread_mutex_lock (&strong_gchandle_hash_lock);
1573+
if (strong_gchandle_hash != NULL) {
1574+
const void *value;
1575+
if (CFDictionaryGetValueIfPresent (strong_gchandle_hash, self, &value)) {
1576+
strong_gchandle = (GCHandle) value;
1577+
CFDictionaryRemoveValue (strong_gchandle_hash, self);
1578+
}
1579+
}
1580+
pthread_mutex_unlock (&strong_gchandle_hash_lock);
1581+
if (strong_gchandle != INVALID_GCHANDLE) {
1582+
#if defined(DEBUG_REF_COUNTING)
1583+
PRINT ("Cleared strong gchandle %d for %p\n", strong_gchandle, self);
1584+
#endif
1585+
xamarin_gchandle_free (strong_gchandle);
1586+
} else {
1587+
#if defined(DEBUG_REF_COUNTING)
1588+
PRINT ("Did not clear a strong gchandle for %p, because none was found\n", self);
1589+
#endif
1590+
}
1591+
}
1592+
1593+
// Creates a strong gchandle for the given managed object and associates it
1594+
// with the native object. If a strong gchandle already exists for this
1595+
// native object, the newly created one is freed and the existing one is kept.
1596+
// Returns true if a new strong gchandle was set, false if one already existed.
1597+
static bool
1598+
create_strong_gchandle (id self, MonoObject *managed_object)
1599+
{
1600+
GCHandle new_gchandle = xamarin_gchandle_new (managed_object, FALSE);
1601+
1602+
GCHandle existing = INVALID_GCHANDLE;
1603+
pthread_mutex_lock (&strong_gchandle_hash_lock);
1604+
if (strong_gchandle_hash == NULL)
1605+
strong_gchandle_hash = CFDictionaryCreateMutable (kCFAllocatorDefault, 0, NULL, NULL);
1606+
const void *value;
1607+
if (CFDictionaryGetValueIfPresent (strong_gchandle_hash, self, &value))
1608+
existing = (GCHandle) value;
1609+
if (existing == INVALID_GCHANDLE)
1610+
CFDictionarySetValue (strong_gchandle_hash, self, (const void *) new_gchandle);
1611+
pthread_mutex_unlock (&strong_gchandle_hash_lock);
1612+
1613+
if (existing != INVALID_GCHANDLE) {
1614+
// Another thread already set a strong gchandle, free ours.
1615+
xamarin_gchandle_free (new_gchandle);
1616+
return false;
1617+
}
1618+
return true;
1619+
}
1620+
15701621
void
15711622
xamarin_switch_gchandle (id self, bool to_weak)
15721623
{
1573-
GCHandle new_gchandle;
15741624
GCHandle old_gchandle;
1575-
MonoObject *managed_object;
1576-
enum XamarinGCHandleFlags flags = XamarinGCHandleFlags_None;
1577-
1578-
old_gchandle = get_gchandle_safe (self, &flags);
1579-
if (old_gchandle) {
1580-
bool is_weak = (flags & XamarinGCHandleFlags_WeakGCHandle) == XamarinGCHandleFlags_WeakGCHandle;
1581-
if (to_weak == is_weak) {
1582-
// we already have the GCHandle we need
1583-
#if defined(DEBUG_REF_COUNTING)
1584-
PRINT ("Object %p already has a %s GCHandle = %d\n", self, to_weak ? "weak" : "strong", old_gchandle);
1585-
#endif
1586-
return;
1587-
}
1588-
} else {
1589-
// We don't have a GCHandle. This means there's no managed instance for this
1625+
MonoObject *managed_object = NULL;
1626+
1627+
old_gchandle = get_gchandle_without_flags (self);
1628+
if (!old_gchandle) {
1629+
// We don't have a GCHandle. This means there's no managed instance for this
15901630
// native object.
15911631
// If to_weak is true, then there's obviously nothing to do
15921632
// (why create a managed object which can immediately be freed by the GC?).
@@ -1600,40 +1640,31 @@ -(struct NSObjectData*) xamarinGetNSObjectData;
16001640
return;
16011641
}
16021642

1603-
MONO_THREAD_ATTACH;
1604-
1605-
managed_object = xamarin_gchandle_get_target (old_gchandle);
1643+
// The object's gc_handle is always a weak handle used for lookups. It is
1644+
// never modified here. A separate strong gchandle (stored in a global hash
1645+
// table) is created/freed as needed to keep the managed object alive.
1646+
// Since gc_handle is never modified, there is no race with concurrent
1647+
// readers (fixing https://github.com/dotnet/macios/issues/24702).
16061648

16071649
if (to_weak) {
1608-
new_gchandle = xamarin_gchandle_new_weakref (managed_object, TRUE);
1609-
flags = (enum XamarinGCHandleFlags) (flags | XamarinGCHandleFlags_WeakGCHandle);
1650+
free_strong_gchandle (self);
16101651
} else {
1611-
new_gchandle = xamarin_gchandle_new (managed_object, FALSE);
1612-
flags = (enum XamarinGCHandleFlags) (flags & ~XamarinGCHandleFlags_WeakGCHandle);
1613-
}
1652+
MONO_THREAD_ATTACH;
16141653

1615-
xamarin_gchandle_free (old_gchandle);
1616-
1617-
if (managed_object) {
1618-
// It's possible to not have a managed object if:
1619-
// 1. Objective-C holds a weak reference to the native object (and no other strong references)
1620-
// - in which case the original (old) gchandle would be a weak one.
1621-
// 2. Managed code does not reference the managed object.
1622-
// 3. The GC ran and collected the managed object, but the main thread has not gotten
1623-
// around to release the native object yet.
1624-
// If all these conditions hold, then the original gchandle will point to
1625-
// null, because the target would be collected.
1626-
xamarin_set_nsobject_flags (managed_object, xamarin_get_nsobject_flags (managed_object) | NSObjectFlagsHasManagedRef);
1627-
}
1628-
set_gchandle (self, new_gchandle, flags, NULL);
1629-
1630-
MONO_THREAD_DETACH;
1654+
managed_object = xamarin_gchandle_get_target (old_gchandle);
1655+
if (managed_object) {
1656+
if (create_strong_gchandle (self, managed_object)) {
1657+
xamarin_set_nsobject_flags (managed_object, xamarin_get_nsobject_flags (managed_object) | NSObjectFlagsHasManagedRef);
1658+
}
1659+
}
16311660

1632-
xamarin_mono_object_release (&managed_object);
1661+
MONO_THREAD_DETACH;
1662+
xamarin_mono_object_release (&managed_object);
16331663

16341664
#if defined(DEBUG_REF_COUNTING)
1635-
PRINT ("Switched object %p to %s GCHandle = %d managed object = %p\n", self, to_weak ? "weak" : "strong", new_gchandle, managed_object);
1665+
PRINT ("Object %p switched to strong mode\n", self);
16361666
#endif
1667+
}
16371668
}
16381669

16391670
void
@@ -1651,6 +1682,9 @@ -(struct NSObjectData*) xamarinGetNSObjectData;
16511682
PRINT ("\tNo GCHandle for the object %p\n", self);
16521683
#endif
16531684
}
1685+
1686+
// Also free any strong gchandle from the dual-gchandle scheme.
1687+
free_strong_gchandle (self);
16541688
}
16551689

16561690
void
@@ -1952,6 +1986,13 @@ -(struct NSObjectData*) xamarinGetNSObjectData;
19521986
xamarin_mono_object_release (&xamarin_wrapper_hash);
19531987
pthread_mutex_unlock (&wrapper_hash_lock);
19541988
#endif
1989+
1990+
pthread_mutex_lock (&strong_gchandle_hash_lock);
1991+
if (strong_gchandle_hash != NULL) {
1992+
CFRelease (strong_gchandle_hash);
1993+
strong_gchandle_hash = NULL;
1994+
}
1995+
pthread_mutex_unlock (&strong_gchandle_hash_lock);
19551996
}
19561997

19571998
void

runtime/xamarin/trampolines.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ extern "C" {
1616
// Must be kept in sync with the same enum in NSObject2.cs
1717
enum XamarinGCHandleFlags : uint32_t {
1818
XamarinGCHandleFlags_None = 0,
19-
XamarinGCHandleFlags_WeakGCHandle = 1,
19+
// unused = 1
2020
XamarinGCHandleFlags_HasManagedRef = 2,
2121
XamarinGCHandleFlags_InitialSet = 4,
2222
};

src/Foundation/NSObject2.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ internal enum Flags : uint {
263263
// Must be kept in sync with the same enum in trampolines.h
264264
enum XamarinGCHandleFlags : uint {
265265
None = 0,
266-
WeakGCHandle = 1,
266+
// unused = 1
267267
HasManagedRef = 2,
268268
InitialSet = 4,
269269
}
@@ -500,7 +500,7 @@ void CreateManagedRef (bool retain)
500500
throw new InvalidOperationException ($"Unable to create a managed reference for the pointer {handle} whose managed type is {GetType ().FullName} because it wasn't possible to get the class of the pointer: {error_message}");
501501

502502
if (isUserType) {
503-
var gchandle_flags = XamarinGCHandleFlags.HasManagedRef | XamarinGCHandleFlags.InitialSet | XamarinGCHandleFlags.WeakGCHandle;
503+
var gchandle_flags = XamarinGCHandleFlags.HasManagedRef | XamarinGCHandleFlags.InitialSet;
504504
var gchandle = GCHandle.Alloc (this, GCHandleType.WeakTrackResurrection);
505505
var h = GCHandle.ToIntPtr (gchandle);
506506
byte rv;

tests/dotnet/UnitTests/expected/iOS-MonoVM-interpreter-preservedapis.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags
189189
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::HasManagedRef
190190
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::InitialSet
191191
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::None
192-
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::WeakGCHandle
193192
Microsoft.iOS.dll:Foundation.NSObjectData
194193
Microsoft.iOS.dll:Foundation.NSObjectData* Foundation.NSObject::__data_for_mono
195194
Microsoft.iOS.dll:Foundation.NSObjectData* Foundation.NSObjectDataHandle::Data()

tests/dotnet/UnitTests/expected/iOS-MonoVM-preservedapis.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,6 @@ Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags
173173
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::HasManagedRef
174174
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::InitialSet
175175
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::None
176-
Microsoft.iOS.dll:Foundation.NSObject/XamarinGCHandleFlags Foundation.NSObject/XamarinGCHandleFlags::WeakGCHandle
177176
Microsoft.iOS.dll:Foundation.NSObjectData
178177
Microsoft.iOS.dll:Foundation.NSObjectData* Foundation.NSObject::__data_for_mono
179178
Microsoft.iOS.dll:Foundation.NSObjectData* Foundation.NSObjectDataHandle::Data()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System.Diagnostics;
2+
using System.Threading;
3+
4+
namespace MonoTouchFixtures.ObjCRuntime {
5+
[TestFixture]
6+
[Preserve (AllMembers = true)]
7+
public class GCHandleSwitchRaceTest {
8+
9+
class CustomObject : NSObject {
10+
[Export ("getCounter")]
11+
public int GetCounter ()
12+
{
13+
return 42;
14+
}
15+
}
16+
17+
[Test]
18+
public void RunTest ()
19+
{
20+
// This test verifies a race condition in xamarin_switch_gchandle:
21+
// Thread A switches between weak/strong gchandles (via retain/release),
22+
// while Thread B fetches the gchandle (by calling an exported ObjC method).
23+
// The race is that Thread B can read the old gchandle after Thread A has
24+
// freed it but before Thread A has set the new one.
25+
// Ref: https://github.com/dotnet/macios/issues/24702
26+
var done = new ManualResetEvent (false);
27+
Exception? switchException = null;
28+
Exception? fetchException = null;
29+
30+
var obj = new CustomObject ();
31+
32+
// Thread that switches gchandle between weak and strong repeatedly.
33+
var switchThread = new Thread (() => {
34+
try {
35+
while (!done.WaitOne (0)) {
36+
obj.DangerousRetain ();
37+
obj.DangerousRelease ();
38+
}
39+
} catch (Exception ex) {
40+
switchException = ex;
41+
done.Set ();
42+
}
43+
}) {
44+
IsBackground = true,
45+
Name = "GCHandle Switch Thread",
46+
};
47+
switchThread.Start ();
48+
49+
// Thread that fetches the managed object (reads the gchandle) via an ObjC call.
50+
var fetchThread = new Thread (() => {
51+
try {
52+
while (!done.WaitOne (0)) {
53+
Messaging.int_objc_msgSend (obj.Handle, Selector.GetHandle ("getCounter"));
54+
}
55+
} catch (Exception ex) {
56+
fetchException = ex;
57+
done.Set ();
58+
}
59+
}) {
60+
IsBackground = true,
61+
Name = "GCHandle Fetch Thread",
62+
};
63+
fetchThread.Start ();
64+
65+
// Let the threads race for a few seconds.
66+
Thread.Sleep (TimeSpan.FromSeconds (3));
67+
68+
done.Set ();
69+
70+
// If there's a hang, the bug is there.
71+
if (!switchThread.Join (TimeSpan.FromSeconds (30)))
72+
Assert.Fail ("Switch thread is hung.");
73+
if (!fetchThread.Join (TimeSpan.FromSeconds (30)))
74+
Assert.Fail ("Fetch thread is hung.");
75+
76+
Assert.IsNull (switchException, $"Switch thread failed: {switchException}");
77+
Assert.IsNull (fetchException, $"Fetch thread failed: {fetchException}");
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)