fix: prevent SubClassFinder availability crash#8457
Conversation
getsectiondata with mach_header_64 doesn't compile on 32-bit watchOS device slices (arm64_32, armv7k). Match the only caller, SentrySubClassFinder, which is iOS/tvOS/visionOS only.
Instructions and example for changelogPlease add an entry to Example: ## Unreleased
### Fixes
- prevent SubClassFinder availability crash ([#8457](https://github.com/getsentry/sentry-cocoa/pull/8457))If none of the above apply, you can opt out of this check by adding |
| // slices (`arm64_32`, `armv7k`), so we don't build it there. | ||
| #if os(iOS) || os(tvOS) || os(visionOS) | ||
| @_spi(Private) | ||
| public func classes(forImage image: UnsafePointer<CChar>) -> [AnyClass] { |
There was a problem hiding this comment.
@NinjaLikesCheez and @itaybre, I think you have more knowledge about getting info from image headers. Claude came up with this approach, and I would love to get your input before finalizing this PR.
| // Only supported on iOS, tvOS, and visionOS, matching `SentrySubClassFinder`, its only caller. | ||
| // It reads `mach_header_64` via `getsectiondata`, which isn't available on 32-bit watchOS device | ||
| // slices (`arm64_32`, `armv7k`), so we don't build it there. | ||
| #if os(iOS) || os(tvOS) || os(visionOS) |
There was a problem hiding this comment.
m: probably worth throwing a arch(arm64) || arch(arm64e) in this conditional since you bind to 64-bit mach header
| guard let cName = _dyld_get_image_name(index), String(cString: cName) == imageName else { | ||
| continue | ||
| } | ||
| guard let header = _dyld_get_image_header(index) else { |
There was a problem hiding this comment.
You could unsafeBitcast here to mach_header_64 to save the rebind.
| // the classes aren't realized, so callers can inspect them without running class | ||
| // initialization. This is why `SentrySubClassFinder` uses this instead of NSClassFromString. | ||
| var size: UInt = 0 | ||
| let section = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { header in |
There was a problem hiding this comment.
I'm unsure if would ever run into this situation, but is it ever possible to encounter a FAT binary? From an App Store distribution - no, but is it possible to ship an arm64 & arm64e binary in an enterprise build?
There was a problem hiding this comment.
Yes, while unlikely, there is no technical limitation for that
But I believe the header layout is the same
There was a problem hiding this comment.
In that case we should definitely at least check the magic number before rebinding memory otherwise we will go off in the weeds, but support for FAT is not too much more code, (here's an example). Each fat_arch gives you the offset and size of the slice, regular mach-o parsing can happen from there.
from mach-o/fat.h:
#define FAT_MAGIC 0xcafebabe
#define FAT_CIGAM 0xbebafeca /* NXSwapLong(FAT_MAGIC) */
struct fat_header {
uint32_t magic; /* FAT_MAGIC or FAT_MAGIC_64 */
uint32_t nfat_arch; /* number of structs that follow */
};
struct fat_arch {
int32_t cputype; /* cpu specifier (int) */
int32_t cpusubtype; /* machine specifier (int) */
uint32_t offset; /* file offset to this object file */
uint32_t size; /* size of this object file */
uint32_t align; /* alignment as a power of 2 */
};
/*
* The support for the 64-bit fat file format described here is a work in
* progress and not yet fully supported in all the Apple Developer Tools.
*
* When a slice is greater than 4mb or an offset to a slice is greater than 4mb
* then the 64-bit fat file format is used.
*/
#define FAT_MAGIC_64 0xcafebabf
#define FAT_CIGAM_64 0xbfbafeca /* NXSwapLong(FAT_MAGIC_64) */
struct fat_arch_64 {
int32_t cputype; /* cpu specifier (int) */
int32_t cpusubtype; /* machine specifier (int) */
uint64_t offset; /* file offset to this object file */
uint64_t size; /* size of this object file */
uint32_t align; /* alignment as a power of 2 */
uint32_t reserved; /* reserved */
};| // the classes aren't realized, so callers can inspect them without running class | ||
| // initialization. This is why `SentrySubClassFinder` uses this instead of NSClassFromString. | ||
| var size: UInt = 0 | ||
| let section = header.withMemoryRebound(to: mach_header_64.self, capacity: 1) { header in |
There was a problem hiding this comment.
l: Do we want to verify the magic matches MH_MAGIC_64 just in case?
|
|
||
| let count = Int(size) / MemoryLayout<UnsafeRawPointer>.size | ||
| return section.withMemoryRebound(to: UnsafeRawPointer.self, capacity: count) { classes in | ||
| (0..<count).map { unsafeBitCast(classes[$0], to: AnyClass.self) } |
There was a problem hiding this comment.
m: I don't expect any issue here, but just double check this works on arm64e due to pointer authentication
Fixes #8152
Problem
On SDK start, UIViewController performance tracing enumerates every class in the app image to find
UIViewControllersubclasses. The old code calledNSClassFromStringon each class, which realizes it. Realizing a Swift class whose metadata references an@available-gated newer-framework type forces Swift metadata completion and crashes withEXC_BAD_ACCESSon OS versions below that framework's availability (real devices only, e.g. iOS 17.x).Real-world crashers are not view controllers — SwiftUI gesture
Coordinators (iOS 18+UIGestureRecognizerRepresentable),RoomPlan/ActivityKitwrappers, etc.This is an underlying Swift/ObjC runtime bug (swiftlang/swift#72657), not our bug. Even once it's fixed upstream, it will take years before every affected OS version ages out — so the SDK needs to avoid triggering it regardless.
Approach
Discover the classes without realizing them, then keep the existing swizzling untouched:
__objc_classlistMach-O section (SentryDefaultObjCRuntimeWrapper.classes(forImage:)) instead ofobjc_copyClassNamesForImage+NSClassFromString. These pointers are dyld-bound but not realized.class_getSuperclass-based subclass walk (already realization-free).NSClassFromStringnow runs only at swizzle time, on the main thread, for confirmed view controllers — the same swizzling path as before.The swizzling logic is unchanged. Only class discovery changed.
Why not
objc_getClassList?It calls
realizeAllClasses()— realizing every class in the process, which is exactly the crash, with a larger blast radius (all frameworks, not just the app image).Status / open questions for reviewers
This is a draft for direction feedback — a few things still to validate before merge:
SentryBinaryImageCache? It already tracks loaded images (itsaddressis the in-memorymach_header), so we might skip the_dyld_image_count()scan. Needs care re: async population at startup + it's address-indexed, not name-indexed.Repro sample kept in the branch (
Samples/iOS-SwiftRoomPlan scaffolding).