Skip to content

Infinite recursion in skeletonTraitCollectionDidChange after hideSkeleton when another component hooks traitCollectionDidChange #600

Description

@Lm004yky

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

  1. Show a skeleton on any view (showSkeleton()), wait for the async swizzle.
  2. On a UIView subclass, class_addMethod a hook for traitCollectionDidChange(_:) that calls the inherited IMP captured at that moment.
  3. Hide the skeleton (hideSkeleton()), wait for the async un-swizzle.
  4. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions