Skip to content

Commit c0f5924

Browse files
rolfbjarneCopilot
andauthored
[runtime] Fix the alloc/init handle-reuse race in object_map (issue #25861) (#26259)
Scenario: An object whose native `init` returns a different pointer than `alloc` (e.g. `CKRecordZoneID`) frees its alloc'd address. If another object (e.g. a `__MonoMac_NSAsyncActionDispatcher`) is allocated at that just-freed address on another thread and registered in `object_map`, the first object's `Handle` setter would then unconditionally `UnregisterNSObject` its stale alloc handle and clobber the second object's registration. A later native->managed marshal of that address then fails ("Could not find an existing managed instance", errors 8027/8034/8035). Fix: * Defer `object_map` registration until after `init` for user types. User types carry their gchandle in a native ivar (set at alloc time), which is self-cleaning when the address is freed/reused, so it's a safe authoritative fallback lookup during `init`. Only the final (post-`init`) handle is added to `object_map`. Direct bindings have no ivar, so they stay eagerly registered and are protected by the ownership-aware unregister below. * Use an ownership-aware `UnregisterNSObject (handle, this)` in the `Handle` setter, which only removes the `object_map` entry if it still refers to `this` (mirroring the check `NativeObjectHasDied` already had). * Add a native->managed ivar fallback to `Runtime.GetNSObject`/`GetNSObject<T>` so a user type whose object_map registration was deferred can still be resolved during `init`. * Gate both behaviors behind a legacy `AppContext` switch (`ObjCRuntime.Runtime.RegisterObjectsBeforeInit`, default off) to restore the previous behavior if any existing binding relied on it. Tests: * AllocInitRaceTest deterministically reproduces the clobber with a tiny custom native allocator (ReuseSlotClassA/ReuseSlotClassB): one class' `init` frees its instance and forces the next allocation to reuse that exact address. ReusedAddressSurvivesAllocInitClobber verifies the reused address still resolves to the correct object; ReusedAddressClobberedWithLegacySwitch documents the pre-fix behavior with the legacy switch on. * InitCallbackProbeTest exercises surfacing `self` to managed code during `init`. * A failed-init + GC guard (InitReturnsNilClass) covers the #23679 shape (a native `init` that raises an Objective-C exception, followed by a forced GC). Fixes #9478. Fixes #23679. Fixes #25861. 🤖 Pull request created by Copilot --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Rolf Bjarne Kvinge <rokvin@microsoft.com>
1 parent a26e2d4 commit c0f5924

21 files changed

Lines changed: 703 additions & 68 deletions

src/Foundation/NSObject2.cs

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ public NSObject ()
329329
{
330330
bool alloced = AllocIfNeeded ();
331331
InitializeObject (alloced);
332+
// This constructor doesn't send 'init', so the handle is final. Complete any
333+
// deferred registration for user types (see #25861); no-op if already registered
334+
// (e.g. direct bindings, which InitializeObject registers eagerly).
335+
if (alloced && !Runtime.RegisterObjectsBeforeInit)
336+
Runtime.RegisterNSObject (this, handle, onlyIfNeeded: true);
332337
}
333338

334339
// This is just here as a constructor chain that can will
@@ -509,20 +514,35 @@ private void InitializeObject (bool alloced)
509514
// and any subclasses in the platform assembly which is not a direct binding have
510515
// to set the correct value in their constructors.
511516
IsDirectBinding = (this.GetType ().Assembly == PlatformAssembly);
512-
Runtime.RegisterNSObject (this, handle);
513517

514518
bool native_ref = (flags & Flags.NativeRef) == Flags.NativeRef;
515-
CreateManagedRef (!alloced || native_ref);
519+
520+
if (!Runtime.TryGetIsUserType (handle, out var isUserType, out var error_message))
521+
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}");
522+
523+
// Issue #25861: when we've just alloc'd a user type, defer adding it to the
524+
// object_map until 'init' has completed. A native 'init' may free this handle
525+
// and return a different one; we don't want a pointer to freed memory lingering
526+
// in the map. User types carry their gchandle in a native ivar (set by
527+
// CreateManagedRef below), which serves as a fallback lookup during 'init', so
528+
// deferring their object_map registration is safe. The final handle is registered
529+
// later (via InitializeHandle for the generated alloc+init constructors, or right
530+
// after this call for the parameterless NSObject constructor which doesn't send
531+
// 'init'). Direct bindings have no ivar, so they must remain registered throughout
532+
// 'init' (e.g. so a native 'init' that surfaces 'self' to managed code resolves to
533+
// the wrapper being constructed) and are registered eagerly here.
534+
if (!alloced || !isUserType || Runtime.RegisterObjectsBeforeInit)
535+
Runtime.RegisterNSObject (this, handle);
536+
537+
CreateManagedRef (isUserType, !alloced || native_ref);
516538
}
517539

518540
[DllImport ("__Internal")]
519541
static extern byte xamarin_set_gchandle_with_flags_safe (IntPtr handle, IntPtr gchandle, XamarinGCHandleFlags gchandle_flags, IntPtr data);
520542

521-
void CreateManagedRef (bool retain)
543+
void CreateManagedRef (bool isUserType, bool retain)
522544
{
523545
HasManagedRef = true;
524-
if (!Runtime.TryGetIsUserType (handle, out var isUserType, out var error_message))
525-
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}");
526546

527547
if (isUserType) {
528548
var gchandle_flags = XamarinGCHandleFlags.HasManagedRef | XamarinGCHandleFlags.InitialSet;
@@ -543,6 +563,37 @@ void CreateManagedRef (bool retain)
543563
DangerousRetain ();
544564
}
545565

566+
// Issue #25861: if 'init' returned a different handle than 'alloc' for a user type,
567+
// the gchandle ivar was set on the (now typically freed) alloc'd handle. Make sure
568+
// the final handle also has a gchandle ivar pointing back at this managed object, so
569+
// it can be resolved native->managed. Does nothing for direct bindings (no ivar) or
570+
// if the ivar is already set.
571+
void EnsureManagedReference (NativeHandle newHandle)
572+
{
573+
if (!Runtime.TryGetIsUserType (newHandle, out var isUserType, out var _) || !isUserType)
574+
return;
575+
if (Runtime.GetGCHandleForObject (newHandle) != IntPtr.Zero)
576+
return;
577+
HasManagedRef = true;
578+
var gchandle_flags = XamarinGCHandleFlags.HasManagedRef | XamarinGCHandleFlags.InitialSet;
579+
var gchandle = GCHandle.Alloc (this, GCHandleType.WeakTrackResurrection);
580+
var h = GCHandle.ToIntPtr (gchandle);
581+
byte rv;
582+
unsafe {
583+
rv = xamarin_set_gchandle_with_flags_safe (newHandle, h, gchandle_flags, (IntPtr) GetData ());
584+
}
585+
if (rv == 0) {
586+
// The ivar slot was already claimed (e.g. another managed wrapper won a race
587+
// to represent this native object). Free the gchandle we allocated. We keep
588+
// HasManagedRef set (it was already set by CreateManagedRef): this object
589+
// still owns the +1 that 'init' transferred to 'newHandle', and that +1 must
590+
// still be released via ReleaseManagedRef on disposal. This mirrors the same
591+
// case in CreateManagedRef.
592+
Runtime.NSLog ($"Tried to create a managed reference from an object that already has a managed reference (type: {GetType ()})");
593+
gchandle.Free ();
594+
}
595+
}
596+
546597
void ReleaseManagedRef ()
547598
{
548599
var handle = this.Handle; // Get a copy of the handle, because it will be cleared out when calling Runtime.NativeObjectHasDied, and we still need the handle later.
@@ -800,8 +851,15 @@ public NativeHandle Handle {
800851
if (handle == value)
801852
return;
802853

803-
if (handle != IntPtr.Zero)
804-
Runtime.UnregisterNSObject (handle);
854+
if (handle != IntPtr.Zero) {
855+
// Issue #25861: use the ownership-aware unregister so we don't remove an
856+
// object_map entry that another object created after reusing this (freed)
857+
// address. The legacy switch restores the previous unconditional removal.
858+
if (Runtime.RegisterObjectsBeforeInit)
859+
Runtime.UnregisterNSObject (handle);
860+
else
861+
Runtime.UnregisterNSObject (handle, this);
862+
}
805863

806864
handle = value;
807865

@@ -843,7 +901,24 @@ protected internal void InitializeHandle (NativeHandle handle, string initSelect
843901
throw new Exception ($"Could not initialize an instance of the type '{GetType ().FullName}': the native '{initSelector}' method returned nil.\n{Constants.SetThrowOnInitFailureToFalse}.");
844902
}
845903

904+
// Transition to the final (post-'init') handle. The Handle setter (ownership-aware)
905+
// unregisters the previous handle if needed and registers the new one.
906+
var previousHandle = this.handle;
846907
this.Handle = handle;
908+
909+
// Issue #25861: registration for user types was deferred in InitializeObject.
910+
if (!Runtime.RegisterObjectsBeforeInit && handle != NativeHandle.Zero) {
911+
if (handle == previousHandle) {
912+
// 'init' returned the same handle, so the setter above was a no-op.
913+
// Complete the deferred registration now (no-op if already registered).
914+
Runtime.RegisterNSObject (this, handle, onlyIfNeeded: true);
915+
} else {
916+
// 'init' returned a different handle; the gchandle ivar was set on the
917+
// previous (now typically freed) handle, so re-establish it on the final
918+
// handle for user types.
919+
EnsureManagedReference (handle);
920+
}
921+
}
847922
}
848923

849924
private bool AllocIfNeeded ()

src/ObjCRuntime/Runtime.cs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,14 @@ public static bool DynamicRegistrationSupported {
290290
[BindingImpl (BindingImplOptions.Optimizable)]
291291
internal static bool UseCFNetworkHandler => AppContext.TryGetSwitch ("System.Net.Http.NativeHandler.UseCFNetworkHandler", out bool isDefault) ? isDefault : false;
292292

293+
// Issue #25861: when false (the default), NSObjects created via alloc/init are
294+
// added to the object_map only after 'init' has completed. This way a native
295+
// 'init' that frees the alloc'd handle (and returns a different one) can't leave
296+
// a stale pointer to freed memory in the map, which could later be clobbered when
297+
// the memory is reused by another object. Set this AppContext switch to true to
298+
// restore the previous behavior (register the object right after 'alloc').
299+
internal static bool RegisterObjectsBeforeInit => AppContext.TryGetSwitch ("ObjCRuntime.Runtime.RegisterObjectsBeforeInit", out var value) && value;
300+
293301
// The linker may turn calls to this property into a constant
294302
/// <summary>Determines whether the debug builds will enforce that calls done to AppKit/UIKit APIs are only issued from the UI thread.</summary>
295303
/// <remarks>
@@ -1204,6 +1212,21 @@ internal static void UnregisterNSObject (IntPtr ptr)
12041212
}
12051213
}
12061214

1215+
// Ownership-aware variant of UnregisterNSObject: only removes the entry if it
1216+
// still refers to `managed` (or is dead). This avoids clobbering an entry that
1217+
// another object created after reusing a freed native pointer (issue #25861).
1218+
internal static void UnregisterNSObject (IntPtr ptr, NSObject managed)
1219+
{
1220+
lock (lock_obj) {
1221+
if (object_map.TryGetValue (ptr, out var value)) {
1222+
if (value.Target is null || object.ReferenceEquals (value.Target, managed)) {
1223+
object_map.Remove (ptr);
1224+
value.Free ();
1225+
}
1226+
}
1227+
}
1228+
}
1229+
12071230
internal static void NativeObjectHasDied (IntPtr ptr, NSObject? managed_obj)
12081231
{
12091232
lock (lock_obj) {
@@ -1224,7 +1247,12 @@ internal static void NativeObjectHasDied (IntPtr ptr, NSObject? managed_obj)
12241247
}
12251248
}
12261249

1227-
internal static void RegisterNSObject (NSObject obj, IntPtr ptr)
1250+
// Completes deferred object_map registration (issue #25861): when 'onlyIfNeeded' is
1251+
// true, registers the object only if the pointer isn't already present, and leaves
1252+
// any existing entry untouched. This avoids redundantly re-registering objects that
1253+
// were registered eagerly (e.g. direct bindings), and avoids clobbering a concurrent
1254+
// registration (e.g. another object reusing a freed native pointer).
1255+
internal static void RegisterNSObject (NSObject obj, IntPtr ptr, bool onlyIfNeeded = false)
12281256
{
12291257
GCHandle handle;
12301258
if (Runtime.IsCoreCLR) {
@@ -1234,8 +1262,17 @@ internal static void RegisterNSObject (NSObject obj, IntPtr ptr)
12341262
}
12351263

12361264
lock (lock_obj) {
1237-
if (object_map.Remove (ptr, out var existing))
1238-
existing.Free ();
1265+
if (onlyIfNeeded) {
1266+
if (object_map.ContainsKey (ptr)) {
1267+
// Already registered; don't touch the existing entry, just free the
1268+
// handle we speculatively allocated.
1269+
handle.Free ();
1270+
return;
1271+
}
1272+
} else {
1273+
if (object_map.Remove (ptr, out var existing))
1274+
existing.Free ();
1275+
}
12391276
object_map [ptr] = handle;
12401277
#pragma warning disable RBI0014
12411278
obj.Handle = ptr;
@@ -1865,6 +1902,12 @@ internal static bool RemoveFromObjectMap (NSObject obj)
18651902

18661903
var o = TryGetNSObject (ptr, evenInFinalizerQueue);
18671904

1905+
// Fallback for issue #25861: a user type still executing its own 'init' isn't in
1906+
// the object_map yet (deferred until 'init' completes), but carries its gchandle
1907+
// in a native ivar. Safe here because GetNSObject is only called for Objective-C
1908+
// objects. See TryGetNSObjectFromIvar for why this can't live in TryGetNSObject.
1909+
o ??= TryGetNSObjectFromIvar (ptr, evenInFinalizerQueue);
1910+
18681911
if (o is not null) {
18691912
if (owns)
18701913
o.DangerousRelease ();
@@ -1921,6 +1964,11 @@ internal static bool RemoveFromObjectMap (NSObject obj)
19211964

19221965
var obj = TryGetNSObject (ptr, evenInFinalizerQueue: evenInFinalizerQueue);
19231966

1967+
// Fallback for issue #25861: resolve a user type still executing its own 'init'
1968+
// (not yet in the object_map) via its native gchandle ivar. See the non-generic
1969+
// GetNSObject for why this is safe here.
1970+
obj ??= TryGetNSObjectFromIvar (ptr, evenInFinalizerQueue);
1971+
19241972
// First check if we got an object of the expected type
19251973
if (obj is T o)
19261974
return o;
@@ -2794,6 +2842,44 @@ static bool GetIsARM64CallingConvention ()
27942842
return GCHandle.FromIntPtr (ptr).Target;
27952843
}
27962844

2845+
// Returns the gchandle stored in the native object's gchandle ivar (user types
2846+
// only; INVALID_GCHANDLE/zero otherwise).
2847+
[DllImport ("__Internal")]
2848+
static extern IntPtr xamarin_get_gchandle (IntPtr obj);
2849+
2850+
internal static IntPtr GetGCHandleForObject (IntPtr ptr)
2851+
{
2852+
return xamarin_get_gchandle (ptr);
2853+
}
2854+
2855+
// Fallback lookup for issue #25861: a user-type object that hasn't been added to
2856+
// the object_map yet (e.g. it's still executing its own 'init') can still be
2857+
// resolved via the gchandle stored in its native ivar. Returns null for direct
2858+
// bindings (which have no ivar) and for objects without a managed reference.
2859+
internal static NSObject? TryGetNSObjectFromIvar (IntPtr ptr)
2860+
{
2861+
var gchandle = xamarin_get_gchandle (ptr);
2862+
if (gchandle == IntPtr.Zero)
2863+
return null;
2864+
return GetGCHandleTarget (gchandle) as NSObject;
2865+
}
2866+
2867+
// Guarded ivar fallback for issue #25861: resolve a user type that's still executing
2868+
// its own 'init' (and so isn't in the object_map yet) via its native gchandle ivar,
2869+
// only returning it if it still owns 'ptr' and isn't queued for finalization (unless
2870+
// asked otherwise). MUST only be called with an Objective-C object pointer: it sends
2871+
// the xamarinGetGCHandle message, which crashes on non-Objective-C native handles.
2872+
// That's why this isn't folded into the general TryGetNSObject (which can be called
2873+
// with arbitrary native handles, e.g. from GetINativeObject); it's only safe from
2874+
// GetNSObject/GetNSObject<T>, which are only ever called for Objective-C objects.
2875+
static NSObject? TryGetNSObjectFromIvar (IntPtr ptr, bool evenInFinalizerQueue)
2876+
{
2877+
var fromIvar = TryGetNSObjectFromIvar (ptr);
2878+
if (fromIvar is not null && fromIvar.Handle == ptr && (evenInFinalizerQueue || !fromIvar.InFinalizerQueue))
2879+
return fromIvar;
2880+
return null;
2881+
}
2882+
27972883
// Allocate a GCHandle and return the IntPtr to it.
27982884
internal static IntPtr AllocGCHandle (object? value)
27992885
{

tests/bindings-test/ApiDefinition.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,4 +886,38 @@ interface Hitchhiker {
886886
[Export ("buildHighway")]
887887
void BuildHighway ();
888888
}
889+
890+
// Helper for the issue #25861 design work: its native 'init' calls a method that
891+
// can be overridden in managed code, to verify the overridden managed method is
892+
// invoked (on the correct instance) while 'init' is still executing.
893+
[BaseType (typeof (NSObject))]
894+
interface InitCallsVirtualMethod {
895+
[Export ("virtualMethodCalledDuringInit:")]
896+
void VirtualMethodCalledDuringInit (int value);
897+
}
898+
899+
// Helper for the issue #25861 design work: a directly-bound native class whose
900+
// native 'init' synchronously surfaces 'self' to managed code via a C callback.
901+
[BaseType (typeof (NSObject))]
902+
interface InitSurfacesSelfToManaged {
903+
}
904+
905+
// Helpers for a deterministic reproduction of the alloc/init handle-reuse race in
906+
// issue #25861 (and #9478): ReuseSlotClassA's 'init' frees its own instance and forces
907+
// the next allocation to reuse that exact address, so a ReuseSlotClassB allocated from
908+
// managed code during that 'init' deterministically lands on the address freed by the
909+
// ReuseSlotClassA instance.
910+
[BaseType (typeof (NSObject))]
911+
interface ReuseSlotClassA {
912+
}
913+
914+
[BaseType (typeof (NSObject))]
915+
interface ReuseSlotClassB {
916+
}
917+
918+
// Helper for issue #23679: a native class whose 'init' fails (releases self and
919+
// returns nil), so constructing the managed wrapper throws.
920+
[BaseType (typeof (NSObject))]
921+
interface InitReturnsNilClass {
922+
}
889923
}

0 commit comments

Comments
 (0)