You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #8457 fixed the common #8152 crash by making UIViewController subclass discovery non-realizing (SentrySubClassFinder now reads __objc_classlist instead of calling NSClassFromString on every class). But the discovered subclasses are still swizzled, and swizzling realizes a class. A Swift UIViewController subclass gated to a newer OS that stores a property of a gated newer-framework type (e.g. an @available(iOS 17, *) VC holding a RoomPlan.CapturedStructure?) is discovered safely, then crashes at swizzle time during Swift type-metadata completion (EXC_BAD_ACCESS; confirmed on the iOS 16.4 simulator).
Workaround: add the class name to options.swizzleClassNameExcludes — the finder skips it before it ever reaches the swizzler, so it is never realized. The iOS-Swift sample applies this for its regression fixtures.
Real fix (designed, not implemented): defer per-subclass swizzling to first instantiation, so a never-instantiated gated VC is never realized. Full design below.
Also tracked here: Finding 2 — raw __objc_classlist entries are unremapped and can double-swizzle a remapped ObjC future class (narrow; resolved for free by the deferred-swizzle design's Option 1).
Finding 4 — full analysis & repro
What: discovery no longer realizes classes, but the selected UIViewController subclasses are still swizzled, and swizzling realizes them. A Swift VC subclass gated to a newer OS with a stored property of a gated newer-framework type (e.g. @available(iOS 17) VC { var x: CapturedStructure? }) crashes at swizzle time (EXC_BAD_ACCESS in Swift metadata completion). Confirmed on the iOS 16.4 simulator. Crash frames: swizzleViewLayoutSubViews: → realizeClassMaybeSwiftMaybeRelock → swift_getSingletonMetadata → type metadata completion function → 0x0.
Why swizzling cannot avoid realization (answered definitively: no). Three realization triggers in the swizzle path, in order — SentrySwizzle.m dedup [swizzledClasses containsObject:] (messages the class → lookUpImpOrForward → realizeClassMaybeSwiftMaybeRelock; matches the confirmed crash stack), class_getInstanceMethod (SentrySwizzle.mswizzle()), and class_replaceMethod. Swizzling mutates the runtime-allocated method list (class_rw_t), which only exists after realization — a hard ObjC runtime constraint, not fixable inside our swizzling logic.
Why no cheap guard (don't re-spike these; both empirically rejected): skipping not-yet-realized classes skips ~100% of VCs at SDK start (none are realized yet → no instrumentation); probing swift_checkMetadataState itself drives initialization and crashes the same way. No crash-free per-class signal exists at SDK start.
Repro / acceptance gate: the iOS-Swift sample compiles in gated fixtures (SubClassFinderRegressionViewController.swift: an iOS-17-gated VC holding RoomPlan.CapturedStructure?, and an iOS-26-gated VC holding FoundationModels.LanguageModelSession?). The sample neutralizes them via swizzleClassNameExcludes (the documented workaround). To re-arm the crash: remove those excludes from the iOS-Swift AppDelegate and run SubClassFinderRegressionUITests on the iOS 16.4 simulator — the app crashes at launch before the fix and must pass after. This is the acceptance gate for any fix.
The mechanism is also documented as a KNOWN LIMITATION comment at the actOnSubclassesOf call site in SentryUIViewControllerSwizzling.swift.
Deferred fix design — swizzle at first instantiation (designed 2026-07-23, not implemented)
Timing decision (dispatch_async, below) is made; the scope decision (Option 1 vs 2) is still open.
History: this approach was tried and reverted in 2021 (#1355 / #1361)
Pre-#1361 (git show 75e72817a~1:Sources/Sentry/SentryUIViewControllerSwizziling.m), the SDK swizzled base UIViewControllerinitWithCoder: + initWithNibName:bundle: and, inside the replacement, ran swizzleViewControllerSubClass:[self class]beforeSentrySWCallOriginal. On iOS 15 that crashed apps using Swift convenience initializers: NSInternalInconsistencyException: UIViewController is missing its initial trait collection populated during initialization. The old code messaged self and mutated the class mid-init, before UIKit's designated init ran. Root cause was never definitively pinned (#1361 commit message says "It seems like…"), so the new design must be defensively different, not just tweaked. Second constraint: #1366 — subclass lifecycle swizzling must run on the main thread.
Funnel design
Swizzle base UIViewControllerinitWithNibName:bundle: + initWithCoder: (its two designated inits; -init routes through the former; all subclass designated inits reach one via super). The base class implements both, so class_replaceMethodreplaces, never adds — the "adds override to a class that doesn't implement it" concern from #1361 doesn't apply at the funnel level. SentrySwizzleModeOncePerClassAndSuperclasses with static keys (re-start() safe). Implemented in SentryUIViewControllerSwizzlingHelper.m with a handler block set from Swift; handler cleared in +stop.
Replacement order is load-bearing (all three points differ from the 2021 code):
First statement: SentrySWCallOriginal(...). Never touch self before it.
Use object_getClass(result) on the returned object — a C call, not a message; handles init-family self-replacement and nil returns.
dispatch_async onto the main queue to run the existing filter + swizzle path (shouldSwizzleViewController → SentryUIViewControllerSwizzlingHelper swizzleViewControllerSubClass: in SentryUIViewControllerSwizzling.swift). Decided: async over sync-after-init — the class mutation happens fully outside any init frame, maximally distant from the #1355 shape. Accepted cost: a VC inited and pushed in the same runloop tick may lose its first instance's spans (subsequent instances covered). Also satisfies #1366 for off-main inits.
Fast path: lock-guarded NSMutableSet of processed classes (accepted AND rejected) in SentryUIViewControllerSwizzling, checked before any string filtering; mark before the async hop so it can't double-process. Steady-state per-init cost: one lock + one set lookup. SentrySwizzle's own OncePerClass dedup stays as second line of defense.
Why it's safe: the handler only ever sees classes with a live instance → fully realized. Gated classes are never instantiated on unsupported OSes → never swizzled → no crash; full coverage otherwise. Bonus: object_getClass(instance) is always the live, remapped class pointer — the funnel is immune to Finding 2 by construction.
Tracker semantics confirmed safe: spans are created lazily per instance at loadView/viewDidLoad (startRootSpanFor: in SentryDefaultUIViewControllerPerformanceTracker.m); appear/disappear hooks pass through when no span exists. A class swizzled between init and view loading gets full instrumentation. swizzleLoadView:'s IMP-comparison skip behaves identically at first-instantiation time.
stop()/uninstall: funnel replacements no-op through to the original when the static weak _tracker is nil (existing helper pattern); handler block cleared in +stop. Test-only +unswizzle can restore both base init IMPs (static keys make that possible, unlike the per-subclass lifecycle swizzles).
ARC note:imp_implementationWithBlock doesn't get init-family ns_returns_retained semantics; the plain pass-through is balanced via the objc_autoreleaseReturnValue handshake and shipped in exactly this shape pre-2021. Optional hardening: annotated function-pointer cast (ns_consumed self, ns_returns_retained) — verify clang accepts it on the block before keeping.
Pre-SDK-start instances stay covered by the existing swizzleRootViewControllerAndDescendant + UIScene.willConnectNotification path.
Scope decision (OPEN — pick one when implementing)
Keeping eager discovery active is not an option in either variant — realizing gated classes at SDK start is the crash.
Option 1 — full replacement (recommended): delete SentrySubClassFinder.swift, the SentryImageClassProvider/SentryDefaultImageClassProvider pair (verify no other consumers; SentryDependencyContainer.imageClassProvider exists only to feed the finder), the image loop in start(), swizzleUIViewControllersOfClassesInImageOf, and SentrySubClassFinderTests. Resolves the image-unload race, Finding 2, and Finding 4 in one move. Known small coverage regression to flag in the PR: a VC inited before SDK start but not in the root hierarchy at start is no longer instrumented (matches pre-2021 behavior).
Option 2 — minimal reroute: stop invoking the finder from start() and add the funnel; leave the finder files, DI plumbing, and SentrySubClassFinderTests as dead-but-compiling code; delete in a follow-up PR. Smaller diff.
Test plan (condensed)
Funnel swizzles exactly once (IMP of viewDidLoad changes after first init, no rework on second).
Filters applied: swizzleClassNameExcludes and not-in-app classes stay unswizzled.
Never-instantiated class is never swizzled — the unit-level regression test for the eager path's removal (a truly @available-gated crasher can't run in a unit test on a current sim).
Main-thread guarantee via TestSentryDispatchQueueWrapper for off-main inits.
stop() semantics: post-stop inits don't swizzle; no spans.
Breakage inventory: TestSubClassFinder-based tests in SentryUIViewControllerSwizzlingTests.swift break under Option 1; instantiation-based tests should pass via the funnel (verify, don't assume).
Acceptance gate unchanged:SubClassFinderRegressionUITests on the iOS 16.4 sim with the sample's swizzleClassNameExcludes workaround removed (crashes before the fix, must pass after).
Risks
#1355 root cause was never pinned; the async design removes every documented trigger, but validate the #1355 repro shape (Swift convenience-init storyboard VC) on an iOS 15/16 sim before merge.
Third-party init swizzlers (RxSwift, Firebase) stacking on the same base selectors — composes the same way as pre-2021; not exhaustively testable.
CI availability of the iOS 16.4 runtime for the acceptance UITest.
Finding 2 — raw \__objc_classlist entries are unremapped
What:classes(inSection:size:) in SentryDefaultImageClassProvider.swift returns the raw compiler-emitted class pointers without objc4's remapClass, and SentrySubClassFinder carries them to the swizzler. For a class objc4 remaps (a resolved future class, or a weak-linked class it maps to nil), the raw entry differs from the live class, which can bypass SentrySwizzle's class-identity dedup and double-swizzle. Reachable in practice only for an Objective-C future VC subclass — narrow.
Test gap: name-set equivalence is insufficient; a real regression test needs an Objective-C bundle + objc_getFutureClass to exercise remapping and pointer identity.
Resolved for free by the deferred-swizzle design's Option 1 (object_getClass(instance) is always the live, remapped pointer).
Summary
PR #8457 fixed the common #8152 crash by making
UIViewControllersubclass discovery non-realizing (SentrySubClassFindernow reads__objc_classlistinstead of callingNSClassFromStringon every class). But the discovered subclasses are still swizzled, and swizzling realizes a class. A SwiftUIViewControllersubclass gated to a newer OS that stores a property of a gated newer-framework type (e.g. an@available(iOS 17, *)VC holding aRoomPlan.CapturedStructure?) is discovered safely, then crashes at swizzle time during Swift type-metadata completion (EXC_BAD_ACCESS; confirmed on the iOS 16.4 simulator).Workaround: add the class name to
options.swizzleClassNameExcludes— the finder skips it before it ever reaches the swizzler, so it is never realized. The iOS-Swift sample applies this for its regression fixtures.Real fix (designed, not implemented): defer per-subclass swizzling to first instantiation, so a never-instantiated gated VC is never realized. Full design below.
Also tracked here: Finding 2 — raw
__objc_classlistentries are unremapped and can double-swizzle a remapped ObjC future class (narrow; resolved for free by the deferred-swizzle design's Option 1).Finding 4 — full analysis & repro
UIViewControllersubclasses are still swizzled, and swizzling realizes them. A Swift VC subclass gated to a newer OS with a stored property of a gated newer-framework type (e.g.@available(iOS 17) VC { var x: CapturedStructure? }) crashes at swizzle time (EXC_BAD_ACCESSin Swift metadata completion). Confirmed on the iOS 16.4 simulator. Crash frames:swizzleViewLayoutSubViews:→realizeClassMaybeSwiftMaybeRelock→swift_getSingletonMetadata→ type metadata completion function →0x0.SentrySwizzle.mdedup[swizzledClasses containsObject:](messages the class →lookUpImpOrForward→realizeClassMaybeSwiftMaybeRelock; matches the confirmed crash stack),class_getInstanceMethod(SentrySwizzle.mswizzle()), andclass_replaceMethod. Swizzling mutates the runtime-allocated method list (class_rw_t), which only exists after realization — a hard ObjC runtime constraint, not fixable inside our swizzling logic.swift_checkMetadataStateitself drives initialization and crashes the same way. No crash-free per-class signal exists at SDK start.SubClassFinderRegressionViewController.swift: an iOS-17-gated VC holdingRoomPlan.CapturedStructure?, and an iOS-26-gated VC holdingFoundationModels.LanguageModelSession?). The sample neutralizes them viaswizzleClassNameExcludes(the documented workaround). To re-arm the crash: remove those excludes from the iOS-SwiftAppDelegateand runSubClassFinderRegressionUITestson the iOS 16.4 simulator — the app crashes at launch before the fix and must pass after. This is the acceptance gate for any fix.actOnSubclassesOfcall site inSentryUIViewControllerSwizzling.swift.Deferred fix design — swizzle at first instantiation (designed 2026-07-23, not implemented)
Timing decision (
dispatch_async, below) is made; the scope decision (Option 1 vs 2) is still open.History: this approach was tried and reverted in 2021 (#1355 / #1361)
Pre-#1361 (
git show 75e72817a~1:Sources/Sentry/SentryUIViewControllerSwizziling.m), the SDK swizzled baseUIViewControllerinitWithCoder:+initWithNibName:bundle:and, inside the replacement, ranswizzleViewControllerSubClass:[self class]beforeSentrySWCallOriginal. On iOS 15 that crashed apps using Swift convenience initializers:NSInternalInconsistencyException: UIViewController is missing its initial trait collection populated during initialization. The old code messagedselfand mutated the class mid-init, before UIKit's designated init ran. Root cause was never definitively pinned (#1361 commit message says "It seems like…"), so the new design must be defensively different, not just tweaked. Second constraint: #1366 — subclass lifecycle swizzling must run on the main thread.Funnel design
UIViewControllerinitWithNibName:bundle:+initWithCoder:(its two designated inits;-initroutes through the former; all subclass designated inits reach one via super). The base class implements both, soclass_replaceMethodreplaces, never adds — the "adds override to a class that doesn't implement it" concern from #1361 doesn't apply at the funnel level.SentrySwizzleModeOncePerClassAndSuperclasseswith static keys (re-start()safe). Implemented inSentryUIViewControllerSwizzlingHelper.mwith a handler block set from Swift; handler cleared in+stop.SentrySWCallOriginal(...). Never touchselfbefore it.object_getClass(result)on the returned object — a C call, not a message; handles init-family self-replacement and nil returns.dispatch_asynconto the main queue to run the existing filter + swizzle path (shouldSwizzleViewController→SentryUIViewControllerSwizzlingHelper swizzleViewControllerSubClass:inSentryUIViewControllerSwizzling.swift). Decided: async over sync-after-init — the class mutation happens fully outside any init frame, maximally distant from the #1355 shape. Accepted cost: a VC inited and pushed in the same runloop tick may lose its first instance's spans (subsequent instances covered). Also satisfies #1366 for off-main inits.NSMutableSetof processed classes (accepted AND rejected) inSentryUIViewControllerSwizzling, checked before any string filtering; mark before the async hop so it can't double-process. Steady-state per-init cost: one lock + one set lookup.SentrySwizzle's ownOncePerClassdedup stays as second line of defense.object_getClass(instance)is always the live, remapped class pointer — the funnel is immune to Finding 2 by construction.loadView/viewDidLoad(startRootSpanFor:inSentryDefaultUIViewControllerPerformanceTracker.m); appear/disappear hooks pass through when no span exists. A class swizzled between init and view loading gets full instrumentation.swizzleLoadView:'s IMP-comparison skip behaves identically at first-instantiation time._trackeris nil (existing helper pattern); handler block cleared in+stop. Test-only+unswizzlecan restore both base init IMPs (static keys make that possible, unlike the per-subclass lifecycle swizzles).imp_implementationWithBlockdoesn't get init-familyns_returns_retainedsemantics; the plain pass-through is balanced via theobjc_autoreleaseReturnValuehandshake and shipped in exactly this shape pre-2021. Optional hardening: annotated function-pointer cast (ns_consumedself,ns_returns_retained) — verify clang accepts it on the block before keeping.swizzleRootViewControllerAndDescendant+UIScene.willConnectNotificationpath.Scope decision (OPEN — pick one when implementing)
Keeping eager discovery active is not an option in either variant — realizing gated classes at SDK start is the crash.
SentrySubClassFinder.swift, theSentryImageClassProvider/SentryDefaultImageClassProviderpair (verify no other consumers;SentryDependencyContainer.imageClassProviderexists only to feed the finder), the image loop instart(),swizzleUIViewControllersOfClassesInImageOf, andSentrySubClassFinderTests. Resolves the image-unload race, Finding 2, and Finding 4 in one move. Known small coverage regression to flag in the PR: a VC inited before SDK start but not in the root hierarchy at start is no longer instrumented (matches pre-2021 behavior).start()and add the funnel; leave the finder files, DI plumbing, andSentrySubClassFinderTestsas dead-but-compiling code; delete in a follow-up PR. Smaller diff.Test plan (condensed)
viewDidLoadchanges after first init, no rework on second).swizzleClassNameExcludesand not-in-app classes stay unswizzled.@available-gated crasher can't run in a unit test on a current sim).TestSentryDispatchQueueWrapperfor off-main inits.stop()semantics: post-stop inits don't swizzle; no spans.initWithCoder:path (storyboard /NSKeyedUnarchiver).TestSubClassFinder-based tests inSentryUIViewControllerSwizzlingTests.swiftbreak under Option 1; instantiation-based tests should pass via the funnel (verify, don't assume).SubClassFinderRegressionUITestson the iOS 16.4 sim with the sample'sswizzleClassNameExcludesworkaround removed (crashes before the fix, must pass after).Risks
Finding 2 — raw \__objc_classlist entries are unremapped
classes(inSection:size:)inSentryDefaultImageClassProvider.swiftreturns the raw compiler-emitted class pointers without objc4'sremapClass, andSentrySubClassFindercarries them to the swizzler. For a class objc4 remaps (a resolved future class, or a weak-linked class it maps to nil), the raw entry differs from the live class, which can bypassSentrySwizzle's class-identity dedup and double-swizzle. Reachable in practice only for an Objective-C future VC subclass — narrow.NSClassFromString) realizes the class and can pick a same-named class from another image — the exact behavior fix: prevent SubClassFinder availability crash #8457 removed. A viable fix applies aremapClass-equivalent per entry (skipping nil) transiently, keeping the non-realizing path.objc_getFutureClassto exercise remapping and pointer identity.object_getClass(instance)is always the live, remapped pointer).References