Skip to content

fix: prevent SubClassFinder availability crash#8457

Draft
philipphofmann wants to merge 3 commits into
mainfrom
fix/subclassfinder-availability-crash
Draft

fix: prevent SubClassFinder availability crash#8457
philipphofmann wants to merge 3 commits into
mainfrom
fix/subclassfinder-availability-crash

Conversation

@philipphofmann

@philipphofmann philipphofmann commented Jul 17, 2026

Copy link
Copy Markdown
Member

Fixes #8152

Problem

On SDK start, UIViewController performance tracing enumerates every class in the app image to find UIViewController subclasses. The old code called NSClassFromString on each class, which realizes it. Realizing a Swift class whose metadata references an @available-gated newer-framework type forces Swift metadata completion and crashes with EXC_BAD_ACCESS on 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/ActivityKit wrappers, 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:

  • Enumerate class pointers by reading the image's __objc_classlist Mach-O section (SentryDefaultObjCRuntimeWrapper.classes(forImage:)) instead of objc_copyClassNamesForImage + NSClassFromString. These pointers are dyld-bound but not realized.
  • Reuse the existing class_getSuperclass-based subclass walk (already realization-free).
  • NSClassFromString now 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:

  • Reuse SentryBinaryImageCache? It already tracks loaded images (its address is the in-memory mach_header), so we might skip the _dyld_image_count() scan. Needs care re: async population at startup + it's address-indexed, not name-indexed.
  • Performance of the section read + superclass walk vs the old path (not yet profiled on device).
  • Integration validation that the new enumeration swizzles the same VC set as before (a differential unit test asserts enumeration equivalence; want broader end-to-end coverage).

Repro sample kept in the branch (Samples/iOS-Swift RoomPlan scaffolding).

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.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
Fails
🚫 Please consider adding a changelog entry for the next release.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

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 #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against 3d5efa0

// 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] {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, while unlikely, there is no technical limitation for that
But I believe the header layout is the same

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@itaybre itaybre Jul 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Do we want to verify the magic matches MH_MAGIC_64 just in case?

@NinjaLikesCheez NinjaLikesCheez Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to up that to a h given: #8457 (comment)


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) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: I don't expect any issue here, but just double check this works on arm64e due to pointer authentication

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SentrySubClassFinder crashes on OS-gated UIViewController subclasses

3 participants