Skip to content
Draft
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b60f3c5
fix: prevent SubClassFinder availability crash (#8152)
philipphofmann Jul 16, 2026
81b9ddb
fix: guard classes(forImage:) to iOS/tvOS/visionOS
philipphofmann Jul 17, 2026
3d5efa0
ditch changes
philipphofmann Jul 17, 2026
62b002c
fix: gate classes(forImage:) to 64-bit archs
philipphofmann Jul 21, 2026
b4755ae
fix: address SubClassFinder review feedback
philipphofmann Jul 21, 2026
9154217
ref: tidy classes(forImage:) comments and image match
philipphofmann Jul 21, 2026
2c936e5
ref: hoist magic check out of rebind closure
philipphofmann Jul 21, 2026
261743f
docs: note why image count is read once
philipphofmann Jul 21, 2026
a0fc0db
test: drop unused SwiftUI import in SubClassFinder tests
philipphofmann Jul 21, 2026
521b591
ref: log skipped images in classes(forImage:)
philipphofmann Jul 21, 2026
3a199bb
test: rename diff test and apply arrange-act-assert
philipphofmann Jul 21, 2026
b83614b
docs: record arm64e device validation in handoff
philipphofmann Jul 21, 2026
1fcd2e5
docs: clarify why background class inspection is safe
philipphofmann Jul 21, 2026
953fc8a
ref: read classlist as AnyClass?, drop unsafeBitCast
philipphofmann Jul 21, 2026
c4c9502
test: cover null entry in classlist section parsing
philipphofmann Jul 21, 2026
38a6aad
ref: pass class pointers directly to swizzle block
philipphofmann Jul 21, 2026
89f5a62
Merge remote-tracking branch 'origin/main' into fix/subclassfinder-av…
philipphofmann Jul 21, 2026
6e8284f
docs: sync handoff with current state
philipphofmann Jul 21, 2026
198a727
docs: record Finding 3 verdict for PR 8457
philipphofmann Jul 21, 2026
8b6cf2b
docs: mark SubClassFinder Finding 2 as open
philipphofmann Jul 21, 2026
45a729b
docs: correct classes(forImage:) dyld unload-race comment
philipphofmann Jul 21, 2026
70c49fd
handoff
philipphofmann Jul 22, 2026
36be499
Merge branch 'main' into fix/subclassfinder-availability-crash
philipphofmann Jul 23, 2026
7f03dac
changelog
philipphofmann Jul 23, 2026
2aa8fa3
ref: gate SubClassFinder to 64-bit archs
philipphofmann Jul 23, 2026
db5e8a8
docs: clarify SubClassFinder image-unload limitation
philipphofmann Jul 23, 2026
3a3abd4
ref: extract SentryImageClassProvider from wrapper
philipphofmann Jul 23, 2026
dc1572a
docs: sync handoff and review with provider extraction
philipphofmann Jul 23, 2026
af5d804
docs: explain swizzling @available-gated view controllers
philipphofmann Jul 23, 2026
085798a
docs: track gated-class behavior and provider extraction
philipphofmann Jul 23, 2026
7326782
test: add SubClassFinder #8152 regression fixture
philipphofmann Jul 23, 2026
65d8331
docs: correct gated-VC swizzle-safety claim
philipphofmann Jul 23, 2026
406b1e6
Merge remote-tracking branch 'origin/main' into fix/subclassfinder-av…
philipphofmann Jul 23, 2026
9088a72
docs: trim SubClassFinder tracking docs to open work
philipphofmann Jul 23, 2026
66f2ada
docs: record deferred-swizzle design for Finding 4
philipphofmann Jul 23, 2026
814bcda
test: exclude gated crasher fixtures from swizzling
philipphofmann Jul 24, 2026
eff8fe4
docs: move deferred findings to GH-8548
philipphofmann Jul 24, 2026
8a0b3f5
docs: point changelog at residual-crash issue
philipphofmann Jul 24, 2026
e4515ed
Merge branch 'main' into fix/subclassfinder-availability-crash
philipphofmann Jul 24, 2026
d8d7a46
Merge branch 'main' into fix/subclassfinder-availability-crash
philipphofmann Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- Only expose `experimental.dataCollection` APIs in SDK V10 (#8435)

### Fixes

- Prevent crash on SDK start when the app binary contains classes that reference `@available`-gated APIs. `SentrySubClassFinder` now detects `UIViewController` subclasses without realizing unrelated classes, so it no longer triggers the Swift runtime crash for classes like SwiftUI gesture coordinators or `RoomPlan`/`ActivityKit` wrappers (#8457)

## 9.22.0

### Features
Expand Down
263 changes: 263 additions & 0 deletions HANDOFF-subclassfinder-fix.md

Large diffs are not rendered by default.

353 changes: 353 additions & 0 deletions REVIEW-PR-8457.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -40,64 +40,63 @@ class SentrySubClassFinder: NSObject {
return
}

var count: UInt32 = 0
guard let classes = self.objcRuntimeWrapper.copyClassNamesForImage(cImageName, &count) else {
return
}
// Get the image's classes from its objc class list. This gives us the classes without
// realizing them. We must not use NSClassFromString to find the subclasses, because it
// realizes the class, and realizing a class whose Swift metadata references an
// `@available`-gated newer-framework type crashes on older OS versions
// (https://github.com/getsentry/sentry-cocoa/issues/8152,
// https://github.com/swiftlang/swift/issues/72657). Walking the superclass chain with
// `class_getSuperclass` below doesn't realize any class, so it can't trigger that crash.
let classes = self.objcRuntimeWrapper.classes(forImage: cImageName)

SentrySDKLog.debug("Found \(classes.count) number of classes in image: \(imageName).")

// We inspect the classes on this background thread but only with `class_getSuperclass`
// (in `isClass`) and `class_getName`. Neither sends an Objective-C message to the class,
// so neither triggers its `+initialize`, which only runs on the first message send. This
// matters because UIViewControllers assume they run on the main thread, so we must not
// trigger their `+initialize` here. Storing the unrealized class pointers doesn't message
// them either; the swizzling block on the main thread sends the first message, where
// that is safe. We must not round-trip through NSClassFromString on the main thread
// either: it realizes the class, and realizing a class whose Swift metadata references
// an `@available`-gated newer-framework type crashes on older OS versions
// (https://github.com/getsentry/sentry-cocoa/issues/8152,
// https://github.com/swiftlang/swift/issues/72657) β€” the same reason we read the class
// list from the image instead (see above). It could also resolve a same-named class
// from a different image.
var classesToSwizzle: [AnyClass] = []
for cls in classes {
guard self.isClass(cls, subClassOf: viewControllerClass) else {
continue
}

SentrySDKLog.debug("Found \(count) number of classes in image: \(imageName).")

// Storing the actual classes in an NSArray would call initializer of the class, which we
// must avoid as we are on a background thread here and dealing with UIViewControllers,
// which assume they are running on the main thread. Therefore, we store the class name
// instead so we can search for the subclasses on a background thread. We can't use
// NSObject:isSubclassOfClass as not all classes in the runtime in classes inherit from
// NSObject and a call to isSubclassOfClass would call the initializer of the class, which
// we can't allow because of the problem with UIViewControllers mentioned above.
//
// Turns out the approach to search all the view controllers inside the app binary image is
// fast and we don't need to include this restriction that will cause confusion.
// In a project with 1000 classes (a big project), it took only ~3ms to check all classes.
var classesToSwizzle: [String] = []

for i in 0..<Int(count) {
let classNamePtr = classes[i]
let className = String(cString: classNamePtr)
let className = String(cString: class_getName(cls))

// It is vital to avoid swizzling the excluded classes because we had crashes for
// specific classes, such as https://github.com/getsentry/sentry-cocoa/issues/3798.
let shouldExcludeClassFromSwizzling = SentrySwizzleClassNameExclude.shouldExcludeClass(
className: className,
swizzleClassNameExcludes: self.swizzleClassNameExcludes
)

// It is vital to avoid calling NSClassFromString for the excluded classes because we
// had crashes for specific classes when calling NSClassFromString, such as
// https://github.com/getsentry/sentry-cocoa/issues/3798.
if shouldExcludeClassFromSwizzling {
continue
}

guard let cls = NSClassFromString(className) else { continue }
if self.isClass(cls, subClassOf: viewControllerClass) {
classesToSwizzle.append(className)
}
classesToSwizzle.append(cls)
}

free(classes)

if classesToSwizzle.isEmpty {
SentrySDKLog.debug("No UIViewController subclasses to swizzle in image: \(imageName).")
return
}

self.dispatchQueue.dispatchAsyncOnMainQueueIfNotMainThread {
for className in classesToSwizzle {
if let cls = NSClassFromString(className) {
block(cls)
}
for cls in classesToSwizzle {
block(cls)
}

SentrySDKLog.debug(
"The following UIViewControllers for image: \(imageName) will generate automatic transactions: \(classesToSwizzle.joined(separator: ", "))"
"The following UIViewControllers for image: \(imageName) will generate automatic transactions: \(classesToSwizzle.map { String(cString: class_getName($0)) }.joined(separator: ", "))"
)
}
}
Expand All @@ -107,7 +106,7 @@ class SentrySubClassFinder: NSObject {
guard childClass != nil, childClass != parentClass else {
return false
}

var currentClass: AnyClass? = childClass

// Using a do while loop, like pointed out in Cocoa with Love
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ class SentryUIViewControllerSwizzling {
// not to lose auto-generated transactions for the initial view controller. As we use
// SentrySwizzleModeOncePerClassAndSuperclasses, we don't have to worry about swizzling
// twice. We could also use objc_getClassList to lookup sub classes of UIViewController, but
// the lookup can take around 60ms, which is not acceptable.
// the lookup can take around 60ms, which is not acceptable. Furthermore, objc_getClassList
// realizes every class in the process.
if !swizzleRootViewControllerFromUIApplication(app) {
SentrySDKLog.debug("Failed to find root UIViewController from UIApplicationDelegate. Trying to use UISceneWillConnectNotification notification.")

Expand Down
95 changes: 94 additions & 1 deletion Sources/Swift/Helper/SentryDefaultObjCRuntimeWrapper.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// swiftlint:disable missing_docs
import Foundation
import MachO
import ObjectiveC.runtime

@objc @_spi(Private)
Expand All @@ -8,10 +9,102 @@ public final class SentryDefaultObjCRuntimeWrapper: NSObject, SentryObjCRuntimeW
public func copyClassNamesForImage(_ image: UnsafePointer<CChar>, _ outCount: UnsafeMutablePointer<UInt32>?) -> UnsafeMutablePointer<UnsafePointer<CChar>>? {
return objc_copyClassNamesForImage(image, outCount)
}

@_spi(Private)
public func classGetImageName(_ cls: AnyClass) -> UnsafePointer<CChar>? {
return class_getImageName(cls)
}

// Call this off the main thread; the sole caller runs it on a background queue. (These `_dyld_*`
// calls take the dyld loader read lock that image load/unload contends, and the superclass walk
// over every class takes a few ms β€” neither belongs on the main thread.)
//
// KNOWN OPEN ISSUE (Finding 1 in REVIEW-PR-8457.md; see HANDOFF-subclassfinder-fix.md): iterating
// the dyld image list is NOT thread-safe. `<mach-o/dyld.h>` states: "Another thread can add or
// remove an image during the iteration." If the target image is unloaded between the name match
// and the `getsectiondata` dereference below, the header/section pointer dangles and
// dereferencing it can crash. This is a P0 that must be resolved before merge (fix direction:
// coordinate the read with the loader, e.g. via SentryBinaryImageCache). Not reachable in the
// default config (only the never-unloadable main executable is enumerated); reachable when the
// enumerated image is dynamically unloadable.
//
// Only supported on iOS, tvOS, and visionOS. It reads `mach_header_64` via `getsectiondata`, so
// we gate it to 64-bit architectures: every slice these platforms ship (`arm64`/`arm64e`
// devices, `arm64`/`x86_64` simulators), excluding the 32-bit watchOS device slices
// (`arm64_32`, `armv7k`) where the 64-bit header layout doesn't apply.
#if (os(iOS) || os(tvOS) || os(visionOS)) && (arch(arm64) || arch(x86_64))
@_spi(Private)
public func classes(forImage image: UnsafePointer<CChar>) -> [AnyClass] {
Comment thread
philipphofmann marked this conversation as resolved.
Outdated
// We read `_dyld_image_count` once instead of per iteration (unlike the CxaThrowSwapper): we
// search for one already-loaded image, so images added while iterating aren't our target. An
// index that becomes out of range after an unload returns nil below, but an in-range index
// whose image was unmapped yields a dangling header β€” see the KNOWN OPEN ISSUE above.
for index in 0..<_dyld_image_count() {
guard let cName = _dyld_get_image_name(index), strcmp(cName, image) == 0 else {
continue
}
guard let header = _dyld_get_image_header(index) else {
Comment thread
NinjaLikesCheez marked this conversation as resolved.
Outdated
SentrySDKLog.warning("No header for image: \(String(cString: image)). Skipping class list.")
return []
}

// Only treat the header as a `mach_header_64` if it actually is one. dyld hands us thin,
// native-arch, in-memory slices, so this holds in practice; the check is defensive. It
// also rejects a FAT header (`FAT_MAGIC`), which would otherwise be misread, so we skip
// such an image instead of going off into the weeds. `magic` is the first field of both
// `mach_header` and `mach_header_64`, so we can read it before rebinding.
guard header.pointee.magic == MH_MAGIC_64 else {
SentrySDKLog.warning("Header for image: \(String(cString: image)) isn't a mach_header_64. Skipping class list.")
return []
}

// Read the class pointers from the image's `__objc_classlist` section (`__DATA_CONST` on
// modern binaries, `__DATA` on older ones). dyld binds these pointers at load time, but
// the classes aren't realized, so callers can inspect them without running class
// initialization, unlike `NSClassFromString`.
//
// These are the raw, compiler-emitted pointers; we do NOT run objc4's `remapClass` over
// them, so for a class objc4 remaps (a resolved future class from `objc_getFutureClass`,
// or a weak-linked class with a missing superclass that it maps to nil) the entry here can
// differ from the live runtime class or be a disavowed struct.
//
// KNOWN OPEN ISSUE (Finding 2 in REVIEW-PR-8457.md; see HANDOFF-subclassfinder-fix.md):
// an Objective-C future class can have a view-controller superclass, pass
// `SentrySubClassFinder`'s `class_getSuperclass` filter, and reach the swizzler as a raw
// pointer that differs from the live class β€” bypassing `SentrySwizzle`'s class-identity
// dedup. objc4 only forbids a future class from being *completed by a Swift class*, not
// from having an ObjC view-controller superclass, so this is not merely theoretical.
// This must be resolved before merge, and the fix must not reintroduce the GH-8152
// realization crash: re-resolving by name via `NSClassFromString` realizes the class and
// can pick a same-named class from another image, which is exactly what this change removed.
var size: UInt = 0
let section = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { header in
Comment thread
NinjaLikesCheez marked this conversation as resolved.
Outdated
Comment thread
NinjaLikesCheez marked this conversation as resolved.
Outdated
getsectiondata(header, "__DATA_CONST", "__objc_classlist", &size)
?? getsectiondata(header, "__DATA", "__objc_classlist", &size)
}
guard let section else {
SentrySDKLog.debug("No __objc_classlist section for image: \(String(cString: image)).")
return []
}

return Self.classes(inSection: section, size: size)
}

SentrySDKLog.debug("Image not found in loaded images: \(String(cString: image)).")
return []
}

// Internal so tests can exercise the null-entry edge case, which a real dyld-loaded
// `__objc_classlist` section can't be made to contain.
static func classes(inSection section: UnsafeMutablePointer<UInt8>, size: UInt) -> [AnyClass] {
// The section holds ObjC `Class _Nullable` pointers, so `AnyClass?` is their honest
// Swift type: reading through it needs no unsafeBitCast, and a null entry (malformed
// section) becomes `nil` and is skipped instead of producing an invalid `AnyClass`.
let count = Int(size) / MemoryLayout<AnyClass?>.stride
return section.withMemoryRebound(to: AnyClass?.self, capacity: count) { classes in
UnsafeBufferPointer(start: classes, count: count).compactMap { $0 }
}
}
#endif
}
// swiftlint:enable missing_docs
15 changes: 15 additions & 0 deletions Sources/Swift/Helper/SentryObjCRuntimeWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,20 @@ public protocol SentryObjCRuntimeWrapper {
func copyClassNamesForImage(_ image: UnsafePointer<CChar>, _ outCount: UnsafeMutablePointer<UInt32>?) -> UnsafeMutablePointer<UnsafePointer<CChar>>?
@objc(class_getImageName:)
func classGetImageName(_ cls: AnyClass) -> UnsafePointer<CChar>?
/// IMPORTANT: Call this off the main thread. `_dyld_get_image_header` and `_dyld_get_image_name`
/// acquire the dyld loader read lock that every image load/unload contends, so calling it on the
/// main thread risks blocking it.
///
/// Returns the classes defined in the given image by reading its `__objc_classlist` section.
/// The classes are not realized, so callers can inspect them (e.g. walk their superclass chain)
/// without triggering class initialization.
///
/// Only supported on iOS, tvOS, and visionOS. It relies on `getsectiondata` with `mach_header_64`,
/// so it's gated to 64-bit architectures and excludes the 32-bit watchOS device slices
/// (`arm64_32`, `armv7k`).
#if (os(iOS) || os(tvOS) || os(visionOS)) && (arch(arm64) || arch(x86_64))
@objc(classesForImage:)
func classes(forImage image: UnsafePointer<CChar>) -> [AnyClass]
#endif
}
// swiftlint:enable missing_docs
6 changes: 6 additions & 0 deletions Tests/SentryTests/Helper/SentryTestObjCRuntimeWrapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
@property (nullable, nonatomic, copy) NSArray<NSString *> *_Nullable (^classesNames)
(NSArray<NSString *> *_Nullable);

/**
* Overrides the classes returned for an image. Receives the classes the real wrapper found and
* returns the classes to use. When @c nil, the real implementation is used unchanged.
*/
@property (nullable, nonatomic, copy) NSArray<Class> *_Nonnull (^classes)(NSArray<Class> *_Nonnull);

@property (nullable, nonatomic) const char *imageName;

@end
13 changes: 13 additions & 0 deletions Tests/SentryTests/Helper/SentryTestObjCRuntimeWrapper.m
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,17 @@ - (const char *)class_getImageName:(Class)cls
return self.imageName;
}

#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
- (NSArray<Class> *)classesForImage:(const char *)image
{
NSArray<Class> *result = [self.objcRuntimeWrapper classesForImage:image];

if (self.classes != nil) {
result = self.classes(result);
}

return result;
}
#endif

@end
Loading
Loading