Environment
- SkeletonView 1.31.0 (SPM)
- iOS 26.6, Xcode 26
- Seen in production (Crashlytics),
EXC_BAD_ACCESS (KERN_PROTECTION_FAILURE) — stack overflow
What happens
showSkeleton exchanges traitCollectionDidChange(_:) ↔ skeletonTraitCollectionDidChange(_:) on UIView; hideSkeleton exchanges them back.
If a third party hooks traitCollectionDidChange(_:) on a UIView subclass while the skeleton is active (in our case the system InvertColorsManager accessibility bundle, when Smart Invert is enabled),
the hook saves the skeleton implementation as the "original". After hideSkeleton restores the exchange, the selector skeletonTraitCollectionDidChange: points to the skeleton implementation again, so it calls
itself until the stack overflows.
Production stack (main thread):
0 UIView.skeletonTraitCollectionDidChange(_:) (UIView+Swizzling.swift:27)
1 UIView.skeletonTraitCollectionDidChange(_:) (UIView+Swizzling.swift:27)
...
10 UIView.skeletonTraitCollectionDidChange(_:) (UIView+Swizzling.swift:27)
11 InvertColorsManager
12 UIKitCore -[UIView _traitCollectionDidChangeInternal:]
...
18 UIKitCore -[UIView(Internal) _didMoveFromWindow:toWindow:]
Steps to reproduce
- Show a skeleton on any view (
showSkeleton()), wait for the async swizzle.
- On a
UIView subclass, class_addMethod a hook for traitCollectionDidChange(_:) that calls the inherited IMP captured at that moment.
- Hide the skeleton (
hideSkeleton()), wait for the async un-swizzle.
- Add an instance of that subclass to a window (or change its traits) → infinite recursion.
final class HookedView: UIView {}
let selector = #selector(UIView.traitCollectionDidChange(_:))
typealias Fn = @convention(c) (AnyObject, Selector, UITraitCollection?) -> Void
let inherited = class_getInstanceMethod(HookedView.self, selector)!
let saved = unsafeBitCast(method_getImplementation(inherited), to: Fn.self) // skeleton IMP while skeleton is active
let hook: @convention(block) (AnyObject, UITraitCollection?) -> Void = { saved($0, selector, $1) }
class_addMethod(HookedView.self, selector, imp_implementationWithBlock(hook), method_getTypeEncoding(inherited))
Suggested fix
Swizzle once and never un-swizzle (remove unSwizzleLayoutSubviews() / unSwizzleTraitCollectionDidChange() from recursiveHideSkeleton). The swizzled methods already guard on sk.isSkeletonActive, so views
without a skeleton just fall through to the original implementation.
Workaround we use
Wrap the IMP of skeletonTraitCollectionDidChange: at app launch (before any skeleton is shown) and route a re-entrant call for the same view directly to the original traitCollectionDidChange IMP.
Environment
EXC_BAD_ACCESS (KERN_PROTECTION_FAILURE)— stack overflowWhat happens
showSkeletonexchangestraitCollectionDidChange(_:)↔skeletonTraitCollectionDidChange(_:)onUIView;hideSkeletonexchanges them back.If a third party hooks
traitCollectionDidChange(_:)on aUIViewsubclass while the skeleton is active (in our case the systemInvertColorsManageraccessibility bundle, when Smart Invert is enabled),the hook saves the skeleton implementation as the "original". After
hideSkeletonrestores the exchange, the selectorskeletonTraitCollectionDidChange:points to the skeleton implementation again, so it callsitself until the stack overflows.
Production stack (main thread):
Steps to reproduce
showSkeleton()), wait for the async swizzle.UIViewsubclass,class_addMethoda hook fortraitCollectionDidChange(_:)that calls the inherited IMP captured at that moment.hideSkeleton()), wait for the async un-swizzle.Suggested fix
Swizzle once and never un-swizzle (remove
unSwizzleLayoutSubviews()/unSwizzleTraitCollectionDidChange()fromrecursiveHideSkeleton). The swizzled methods already guard onsk.isSkeletonActive, so viewswithout a skeleton just fall through to the original implementation.
Workaround we use
Wrap the IMP of
skeletonTraitCollectionDidChange:at app launch (before any skeleton is shown) and route a re-entrant call for the same view directly to the originaltraitCollectionDidChangeIMP.