fix: skip CameraUI layer subtrees to prevent fatal crash on iOS 26+#240
Merged
abelonogov-ld merged 2 commits intoJul 1, 2026
Merged
Conversation
CameraUI.ModeLoupeLayer is a private CALayer subclass introduced in iOS 26 that does not implement the required init(layer:) initializer. When MaskCollector traverses the view hierarchy during a session replay capture tick and reaches a layer whose sublayers include ModeLoupeLayer, accessing layer.sublayers triggers CA::Layer::presentation_layer() which calls the missing initializer, producing a fatal EXC_BREAKPOINT crash. Two guards are added: - MaskingPolicy.shouldIgnore: returns true for any view whose type name has the "CameraUI" prefix. This stops recursion into the CameraUI subtree at the UIView level before its layer children are visited. - MaskCollector.visit (CALayer-only branch): returns early for any layer whose class name has the "CameraUI" prefix. This is the safety net for CameraUI.ModeLoupeLayer itself, which has no backing UIView and enters the layer-only branch with no prior class name check. Fixes launchdarkly#239
Two rounds of local testing revealed that the first fix (guarding inside the else-branch only) was insufficient: the crash at symbolLocation:1992 came from accessing layer.sublayers on a presentation layer whose direct children include CameraUI.ModeLoupeLayer. The second attempt replaced layer.sublayers with layer.model().sublayers and then mapped each model sublayer to its presentation copy via $0.presentation(). This moved the crash to symbolLocation:2508, meaning the new code ran but iOS 26 still crashed — this time because calling .presentation() on any ancestor of CameraUI.ModeLoupeLayer eagerly builds presentation copies for the entire descendant tree, triggering init(layer:) on the private layer that doesn't implement it. This third fix removes .presentation() from the sublayer traversal entirely. Model layers are passed directly to visit(), which now guards against CameraUI.* at its very first line (before any property access) as belt-and-suspenders. The root traversal is updated in the same way. Tradeoff: mask positions may be slightly off during active CA animations (model vs. presentation coordinates), but correctness under normal non-animating state is fully preserved.
Contributor
|
@nikhil-vonlabs |
abelonogov-ld
approved these changes
Jul 1, 2026
abelonogov-ld
pushed a commit
that referenced
this pull request
Jul 1, 2026
🤖 I have created a release *beep* *boop* --- ## [0.46.2](0.46.1...0.46.2) (2026-07-01) ### Bug Fixes * skip CameraUI layer subtrees to prevent fatal crash on iOS 26+ ([#240](#240)) ([6efb2b5](6efb2b5)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Mechanical version and changelog updates only; functional change is a targeted crash fix already merged separately. > > **Overview** > **Release 0.46.2** — automated Release Please bump with no application code in this PR’s diff. > > Version strings move from **0.46.1** to **0.46.2** in `.release-please-manifest.json`, both CocoaPods specs (`LaunchDarklyObservability`, `LaunchDarklySessionReplay`), `Sources/LaunchDarklyObservability/Version.swift` (`sdkVersion`), and a new **CHANGELOG** section dated 2026-07-01. > > That changelog entry documents the shipped fix from [#240](#240): **skipping CameraUI layer subtrees** during session replay / view capture to avoid a **fatal crash on iOS 26+** when the camera UI is on screen. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0f8fabe. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a fatal
EXC_BREAKPOINTcrash (Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'CameraUI.ModeLoupeLayer') that occurs on iOS 26 when LaunchDarkly Session Replay is enabled and camera UI is visible on screen.Root cause
On iOS 26,
CameraUI.ModeLoupeLayeris a privateCALayersubclass that does not implementinit(layer:)— the Core Animation initializer used to create presentation layer copies. The crash is triggered any time CA is asked to build a presentation copy of this layer, which happens in three distinct ways insideMaskCollector.collectViewMasks:layer.sublayerson a presentation layer — accessing.sublayerson a presentation layer causes Core Animation to callCA::Layer::presentation_layer()on every direct child to produce presentation copies. If any child isCameraUI.ModeLoupeLayer, this immediately crashes..presentation()on an ancestor ofCameraUI.ModeLoupeLayer— on iOS 26, calling.presentation()on a layer that hasCameraUI.ModeLoupeLayeranywhere in its descendant tree eagerly builds presentation copies for the entire subtree, hitting the same crash.A
CameraUI.*layer reachingvisit()with no guard — without an early exit, later property accesses insidevisitcan also trigger presentation layer creation on the unsafe layer.This was verified by iterating three fix attempts, each confirmed to recompile via
LaunchDarklySessionReplay.otimestamp and tested on a physical device running iOS 26 (Xcode 26.x builds):CameraUIguard only in the layer-onlyelsebranchlayer.sublayerson presentation layer with CameraUI child (symbolLocation:1992)layer.model().sublayers+ filter +.map { $0.presentation() ?? $0 }.presentation()on a non-CameraUI ancestor with CameraUI descendant (symbolLocation:2508)layer.model().sublayers+ filter, no.presentation()call; CameraUI guard at top ofvisit()Changes
MaskCollector.swiftCameraUI.*class-name guard as the very first line ofvisit(), before any property access (belt-and-suspenders for any layer that reachesvisit()directly).layer.sublayers(presentation) withlayer.model().sublayersat both the root traversal site and insidevisit()for recursion..map { $0.presentation() ?? $0 }step entirely — model layers are passed directly tovisit(), so.presentation()is never called on any individual sublayer.CameraUI.*layers before passing sublayers tovisit().MaskingPolicy.swiftCameraUI.*prefix check at the top ofshouldIgnore(_:viewType:)to stop recursion into camera UI subtrees at the UIView level as well.Tradeoff
Mask positions are now computed from model-layer coordinates rather than presentation-layer (animated) coordinates. During active CA animations, masks may lag slightly behind animated views. Under normal non-animating state, correctness is fully preserved. This is an acceptable tradeoff versus a fatal crash.
Testing
Validated on a physical device (iOS 26) with session replay
isEnabled: true, release build, by navigating to the camera picker flow that showsCameraUI.ModeLoupeLayer. All three prior crash sites (symbolLocation:1992,2508) are no longer triggered.