22
33#if SENTRY_HAS_UIKIT
44
5+ # import " SentryInternalDefines.h"
56# import " SentrySwift.h"
67# import " SentrySwizzle.h"
78# import < UIKit/UIKit.h>
1011@implementation SentryUIViewControllerSwizzlingHelper
1112
1213static __weak SentryUIViewControllerPerformanceTracker *_tracker = nil ;
14+
15+ // Weak, like _tracker: a strong reference here would outlive the caller that installed the funnel.
16+ static __weak id <SentryUIViewControllerInitSwizzlingDelegate> _initSwizzlingDelegate = nil ;
17+
1318# if SENTRY_TEST || SENTRY_TEST_CI
1419static BOOL swizzlingIsActive = FALSE ;
1520# endif
@@ -21,6 +26,9 @@ @implementation SentryUIViewControllerSwizzlingHelper
2126
2227+ (void )swizzleUIViewControllerWithTracker : (SentryUIViewControllerPerformanceTracker *)tracker
2328{
29+ SENTRY_ASSERT ([NSThread isMainThread ],
30+ @" swizzleUIViewControllerWithTracker: must be called on the main thread." );
31+
2432 _tracker = tracker;
2533# if SENTRY_TEST || SENTRY_TEST_CI
2634 swizzlingIsActive = TRUE ;
@@ -39,8 +47,96 @@ + (void)swizzleUIViewControllerWithTracker:(SentryUIViewControllerPerformanceTra
3947 SentrySwizzleModeOncePerClassAndSuperclasses, (void *)selector);
4048}
4149
50+ /* *
51+ * Swizzles the two base @c UIViewController designated initializers and notifies @c delegate after
52+ * each one, so the caller can defer its own swizzling to first instantiation.
53+ *
54+ * Every @c UIViewController is created through one of the two: a subclass's designated init calls
55+ * super, a convenience init routes through a designated init, and @c -init routes through
56+ * @c initWithNibName:bundle:.
57+ *
58+ * The initializers are swizzled only on the base class, so that part REPLACES UIKit's own
59+ * implementation rather than adding a method. That does not mean the funnel only mutates the base
60+ * class: the handler runs @c swizzleViewControllerSubClass: on the concrete subclass, and
61+ * @c class_replaceMethod ADDS a lifecycle method when the subclass doesn't implement one — while
62+ * still inside the outermost initializer frame. That is the same mechanism GH-1361 blamed for the
63+ * GH-1355 convenience-initializer crash, so this funnel does not eliminate the condition. We tried
64+ * to reproduce GH-1355 on simulators and on real iOS 15 devices in SauceLabs (#8667) and could not.
65+ * The option therefore ships opt-in and off by default, so we can validate the approach in the wild
66+ * — see PR #8687's description.
67+ *
68+ * Ordering inside the replacement is load-bearing:
69+ * 1. Call the original initializer FIRST, and never touch @c self before it. The pre-GH-1361
70+ * code messaged @c self and mutated the class before the original ran.
71+ * 2. Read the concrete class from the RETURNED object via @c object_getClass, a C runtime call
72+ * rather than a message. This handles an init returning a different instance, or nil.
73+ * 3. Invoke the handler synchronously, so lifecycle methods are swizzled before the instance
74+ * can reach its first @c viewDidLoad.
75+ * 4. Return the result verbatim, adding no retain, so ARC's return handshake stays balanced.
76+ *
77+ * Step 3 must not hop through @c dispatch_async(dispatch_get_main_queue(), …) to escape the
78+ * initializer frame. UIKit calls initializers on the main thread, so a dispatch from the main
79+ * thread cannot run until the current run loop turn finishes — by which point the caller already
80+ * holds a fully initialized instance and may have driven it into @c viewDidLoad, or released it.
81+ * That opens a window where a live view controller is uninstrumented, and it reorders the swizzle
82+ * against that instance's own first lifecycle callbacks, so the first appearance of a screen is
83+ * silently missed. Swizzling synchronously keeps the mutation ordered against the very first
84+ * callback. It is also why everything here is main-thread-only and unlocked: the delegate hand-off
85+ * happens on whichever thread ran the initializer, and swizzling off the main thread would race
86+ * both this file's static state and the ObjC runtime mutations in @c swizzleViewControllerSubClass:
87+ * (background swizzling already caused GH-1366). The delegate therefore ignores initializers that
88+ * run on a background thread rather than locking.
89+ *
90+ * We use the ObjC @c SentrySwizzleInstanceMethod macro rather than the typed Swift API that
91+ * develop-docs/SWIZZLING.md prefers (@c SentryTypedSwizzle, #8524): its object-returning overloads
92+ * model +0 autoreleased returns, while an initializer returns +1, which Swift cannot express
93+ * through an @c \@convention(block) object return without passing @c Unmanaged across the
94+ * boundary. @c SentryNSDataSwizzlingHelper.m uses this same macro path for
95+ * @c -[NSData initWithContentsOfFile:options:error:], another +1 initializer. The retain
96+ * handshake is covered by @c testInitFunnel_whenViewControllersInstantiated_doesNotOverRetainThem.
97+ *
98+ * @warning Experimental and opt-in, disabled by default. See GH-8548.
99+ */
100+ + (void )swizzleUIViewControllerInitsWithDelegate :
101+ (id <SentryUIViewControllerInitSwizzlingDelegate>)delegate
102+ {
103+ SENTRY_ASSERT ([NSThread isMainThread ],
104+ @" swizzleUIViewControllerInitsWithDelegate: must be called on the main thread." );
105+
106+ _initSwizzlingDelegate = delegate;
107+
108+ SEL nibSelector = NSSelectorFromString (@" initWithNibName:bundle:" );
109+ SentrySwizzleInstanceMethod (UIViewController.class , nibSelector, SentrySWReturnType (id ),
110+ SentrySWArguments (NSString * nibName, NSBundle * bundle), SentrySWReplacement ({
111+ id <SentryUIViewControllerInitSwizzlingDelegate> delegate = _initSwizzlingDelegate;
112+ id result = SentrySWCallOriginal (nibName, bundle);
113+ Class resultClass = object_getClass (result);
114+ if (resultClass != Nil ) {
115+ [delegate viewControllerInitialized: resultClass];
116+ }
117+ return result;
118+ }),
119+ SentrySwizzleModeOncePerClassAndSuperclasses, (void *)nibSelector);
120+
121+ SEL coderSelector = NSSelectorFromString (@" initWithCoder:" );
122+ SentrySwizzleInstanceMethod (UIViewController.class , coderSelector, SentrySWReturnType (id ),
123+ SentrySWArguments (NSCoder * coder), SentrySWReplacement ({
124+ id <SentryUIViewControllerInitSwizzlingDelegate> delegate = _initSwizzlingDelegate;
125+ id result = SentrySWCallOriginal (coder);
126+ Class resultClass = object_getClass (result);
127+ if (resultClass != Nil ) {
128+ [delegate viewControllerInitialized: resultClass];
129+ }
130+ return result;
131+ }),
132+ SentrySwizzleModeOncePerClassAndSuperclasses, (void *)coderSelector);
133+ }
134+
42135+ (void )swizzleViewControllerSubClass : (Class )class
43136{
137+ SENTRY_ASSERT ([NSThread isMainThread ],
138+ @" swizzleViewControllerSubClass: must be called on the main thread." );
139+
44140 // This are the five main functions related to UI creation in a view controller.
45141 // We are swizzling it to track anything that happens inside one of this functions.
46142 [self swizzleViewLayoutSubViews: class];
@@ -174,7 +270,10 @@ + (void)swizzleViewLayoutSubViews:(Class)class
174270
175271+ (void )stop
176272{
273+ SENTRY_ASSERT ([NSThread isMainThread ], @" stop must be called on the main thread." );
274+
177275 _tracker = nil ;
276+ _initSwizzlingDelegate = nil ;
178277# if SENTRY_TEST || SENTRY_TEST_CI
179278 [self unswizzle ];
180279# endif
@@ -183,16 +282,25 @@ + (void)stop
183282# if SENTRY_TEST || SENTRY_TEST_CI
184283+ (void )unswizzle
185284{
285+ SENTRY_ASSERT ([NSThread isMainThread ], @" unswizzle must be called on the main thread." );
286+
186287 swizzlingIsActive = FALSE ;
187288
188289 // Unswizzling is only supported in test targets as it is considered unsafe for production.
189- // Only unswizzle the base UIViewController.loadView since that's the only method swizzled
190- // on the base class. Other lifecycle methods are swizzled per-subclass and we don't track
191- // which subclasses were swizzled, so we can't safely unswizzle them.
192- // The stop method sets _tracker = nil which makes all swizzled methods no-ops anyway.
290+ // Restores everything swizzled on the base UIViewController: loadView and the two init funnel
291+ // initializers. Leaving the funnel installed would leak it into every later test suite in the
292+ // same run, because a live base-class IMP outlives the handler that stop clears. Lifecycle
293+ // methods are swizzled per-subclass and we don't track which subclasses were swizzled, but
294+ // those are harmless because stop sets _tracker to nil, making them pass-throughs.
193295 SEL loadViewSelector = NSSelectorFromString (@" loadView" );
194296 SentryUnswizzleInstanceMethod (
195297 UIViewController.class , loadViewSelector, (void *)loadViewSelector);
298+
299+ SEL nibSelector = NSSelectorFromString (@" initWithNibName:bundle:" );
300+ SentryUnswizzleInstanceMethod (UIViewController.class , nibSelector, (void *)nibSelector);
301+
302+ SEL coderSelector = NSSelectorFromString (@" initWithCoder:" );
303+ SentryUnswizzleInstanceMethod (UIViewController.class , coderSelector, (void *)coderSelector);
196304}
197305
198306+ (BOOL )swizzlingActive
0 commit comments