Skip to content

Commit 4cdbea6

Browse files
feat: defer UIViewController swizzling to first init (#8687)
Adds the experimental option enableUIViewControllerInitSwizzling (off by default). Instead of scanning the app's binary images at SDK start and swizzling every UIViewController subclass found, the SDK swizzles each subclass the first time an instance of it is created.
1 parent 0dc06da commit 4cdbea6

18 files changed

Lines changed: 914 additions & 35 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Features
6+
7+
- Add experimental option `enableUIViewControllerInitSwizzling` that defers `UIViewController` swizzling to first instantiation instead of eagerly discovering and swizzling all subclasses at SDK start. This avoids realizing `@available`-gated `UIViewController` subclasses on OS versions below their gate, which crashes apps on start (#8687).
8+
39
## 9.25.0
410

511
> [!WARNING]

Samples/SentrySampleShared/SentrySampleShared/SentrySDKOverrides.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ public enum SentrySDKOverrides: String, CaseIterable {
196196

197197
public enum UIViewControllerTracing: String, SentrySDKOverride {
198198
case disable = "--io.sentry.uiviewcontroller-tracing.disable"
199+
case enableInitSwizzling = "--io.sentry.uiviewcontroller-tracing.init-swizzling"
199200
}
200201
case uiViewControllerTracing = "UIViewController Tracing"
201202

@@ -512,7 +513,7 @@ extension SentrySDKOverrides.UIEventTracking {
512513
extension SentrySDKOverrides.UIViewControllerTracing {
513514
public var overrideType: OverrideType {
514515
switch self {
515-
case .disable: return .boolean
516+
case .disable, .enableInitSwizzling: return .boolean
516517
}
517518
}
518519
}
@@ -761,7 +762,12 @@ extension SentrySDKOverrides.UIEventTracking {
761762
}
762763

763764
extension SentrySDKOverrides.UIViewControllerTracing {
764-
public var ignoresDisableEverything: Bool { return false }
765+
public var ignoresDisableEverything: Bool {
766+
switch self {
767+
case .disable: return false
768+
case .enableInitSwizzling: return true
769+
}
770+
}
765771
}
766772

767773
extension SentrySDKOverrides.FileIO {

Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ public struct SentrySDKWrapper {
203203
options.enablePreWarmedAppStartTracing = !isBenchmarking && !SentrySDKOverrides.AppStart.disablePrewarmedTracing.boolValue
204204
options.experimental.enableStandaloneAppStartTracing = SentrySDKOverrides.AppStart.enableStandaloneTracing.boolValue
205205
options.enableUIViewControllerTracing = !SentrySDKOverrides.UIViewControllerTracing.disable.boolValue
206+
options.experimental.enableUIViewControllerInitSwizzling = SentrySDKOverrides.UIViewControllerTracing.enableInitSwizzling.boolValue
206207

207208
// -- Screenshot Options --
208209
options.attachScreenshot = !SentrySDKOverrides.Screenshot.disableAttachment.boolValue

Samples/iOS-Swift/App/Sources/AppDelegate.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
3434

3535
SentrySDKWrapper.spanCaptureHandler = { LaunchVCTransactionCapture.shared.capture($0) }
3636

37-
// Without these excludes the sample cannot launch below iOS 17: the SDK crashes on the
38-
// gated fixtures in SubClassFinderRegressionViewController. Remove them to reproduce
39-
// GH-8152.
40-
SentrySDKWrapper.swizzleClassNameExcludes = [
41-
"GatedIOS17ViewController",
42-
"GatedIOS26OnlyViewController",
43-
"GatedObsoletedOnIOS26ViewController"
44-
]
37+
// Required: this sample has @available-gated UIViewController subclasses that would crash
38+
// at launch without deferred swizzling. Also acts as a safety gate for the feature itself,
39+
// since this sample's UI tests cover automatic transactions/instrumentation. (GH-8548)
40+
var enableInitSwizzling = SentrySDKOverrides.UIViewControllerTracing.enableInitSwizzling
41+
enableInitSwizzling.boolValue = true
4542

4643
SentrySDKWrapper.shared.startSentry()
4744

Sources/Sentry/SentryUIViewControllerSwizzlingHelper.m

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#if SENTRY_HAS_UIKIT
44

5+
# import "SentryInternalDefines.h"
56
# import "SentrySwift.h"
67
# import "SentrySwizzle.h"
78
# import <UIKit/UIKit.h>
@@ -10,6 +11,10 @@
1011
@implementation SentryUIViewControllerSwizzlingHelper
1112

1213
static __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
1419
static 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

Sources/Sentry/include/SentryUIViewControllerSwizzlingHelper.h

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@
55
NS_ASSUME_NONNULL_BEGIN
66

77
/**
8-
* Helper class that performs the actual method swizzling for UIViewController tracking.
9-
* This class is used by the Swift SentryUIViewControllerSwizzling class.
8+
* Performs the method swizzling for UIViewController tracking on behalf of the Swift
9+
* SentryUIViewControllerSwizzling class.
10+
*
11+
* @warning Main thread only, and deliberately unsynchronized. Everything here swizzles
12+
* @c UIViewController lifecycle and initializer methods, which UIKit only calls on the main thread,
13+
* so the shared state below needs no locking. Calling any of this from a background thread races
14+
* that state and the ObjC runtime mutations.
1015
*/
1116
@interface SentryUIViewControllerSwizzlingHelper : NSObject
1217

@@ -23,6 +28,19 @@ NS_ASSUME_NONNULL_BEGIN
2328
*/
2429
+ (void)swizzleViewControllerSubClass:(Class)class;
2530

31+
/**
32+
* Swizzles the base @c UIViewController designated initializers and notifies @c delegate after each
33+
* one, so the caller can defer its own swizzling to first instantiation. Deferring avoids realizing
34+
* @c \@available -gated subclasses below their gate: a class is only reached once an instance
35+
* exists, which means the OS already realized it safely.
36+
*
37+
* @warning Experimental. Only installed when
38+
* @c options.experimental.enableUIViewControllerInitSwizzling is enabled, which is off by default.
39+
* See GH-8548.
40+
*/
41+
+ (void)swizzleUIViewControllerInitsWithDelegate:
42+
(SENTRY_SWIFT_MIGRATION_ID(SentryUIViewControllerInitSwizzlingDelegate))delegate;
43+
2644
+ (void)stop;
2745

2846
# if SENTRY_TEST || SENTRY_TEST_CI

Sources/SentryObjC/Public/SentryObjCExperimentalOptions.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ NS_ASSUME_NONNULL_BEGIN
2121
/// When enabled, the SDK uses a more efficient mechanism for detecting watchdog terminations.
2222
@property (nonatomic) BOOL enableWatchdogTerminationsV2;
2323

24+
/**
25+
* Reduces SDK start overhead by swizzling each @c UIViewController subclass lazily, the first time
26+
* an instance of it is created, instead of eagerly discovering and swizzling every subclass when
27+
* the SDK starts.
28+
*
29+
* By default, the SDK scans loaded binary images for all @c UIViewController subclasses at start
30+
* and swizzles them up front. This realizes every subclass to inspect it, so the cost grows with
31+
* the number of view controllers in the app - including ones it never uses - and it realizes
32+
* @c \@available -gated subclasses that reference newer-framework types, which crashes on OS
33+
* versions below the gate.
34+
*
35+
* With this option, only classes the app actually instantiates are touched: a class that can't
36+
* exist on the current OS is never instantiated, so it's never realized or swizzled. This cuts
37+
* start-up work and avoids the gated-subclass crash while producing the same @c ui.load
38+
* auto-instrumentation transactions.
39+
*
40+
* @warning This is an experimental feature and is therefore disabled by default.
41+
* @see https://github.com/getsentry/sentry-cocoa/issues/8548
42+
*/
43+
@property (nonatomic) BOOL enableUIViewControllerInitSwizzling;
44+
2445
/// Initializes experimental options with default values.
2546
- (instancetype)init;
2647

Sources/SentryObjCCompat/SentryObjCExperimentalOptions.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import Foundation
2626
get { wrapped.enableWatchdogTerminationsV2 }
2727
set { wrapped.enableWatchdogTerminationsV2 = newValue }
2828
}
29+
30+
@objc public var enableUIViewControllerInitSwizzling: Bool {
31+
get { wrapped.enableUIViewControllerInitSwizzling }
32+
set { wrapped.enableUIViewControllerInitSwizzling = newValue }
33+
}
2934
}
3035

3136
// swiftlint:enable missing_docs

0 commit comments

Comments
 (0)