Skip to content

Swizzling @available-gated UIViewController subclasses still crashes below their gate (residual #8152) #8548

Description

@philipphofmann

Summary

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:realizeClassMaybeSwiftMaybeRelockswift_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 → lookUpImpOrForwardrealizeClassMaybeSwiftMaybeRelock; matches the confirmed crash stack), class_getInstanceMethod (SentrySwizzle.m swizzle()), 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.
  • Underlying Swift/ObjC runtime bug: swift_getSingletonMetadata crashes when called via Bundle.main.classNamed() swiftlang/swift#72657.
  • 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 UIViewController initWithCoder: + initWithNibName:bundle: and, inside the replacement, ran swizzleViewControllerSubClass:[self class] before SentrySWCallOriginal. 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 UIViewController initWithNibName: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_replaceMethod replaces, 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):
    1. First statement: SentrySWCallOriginal(...). Never touch self before it.
    2. Use object_getClass(result) on the returned object — a C call, not a message; handles init-family self-replacement and nil returns.
    3. dispatch_async onto the main queue to run the existing filter + swizzle path (shouldSwizzleViewControllerSentryUIViewControllerSwizzlingHelper 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.
  • initWithCoder: path (storyboard / NSKeyedUnarchiver).
  • 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.
  • Constraint on any fix: must NOT reintroduce the SentrySubClassFinder crashes on OS-gated UIViewController subclasses #8152 realization crash. Re-resolving by name (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 a remapClass-equivalent per entry (skipping nil) transiently, keeping the non-realizing path.
  • 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).

References

Metadata

Metadata

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions