Skip to content

fix: skip CameraUI layer subtrees to prevent fatal crash on iOS 26+#240

Merged
abelonogov-ld merged 2 commits into
launchdarkly:mainfrom
nikhil-vonlabs:fix/camera-ui-layer-crash-ios26
Jul 1, 2026
Merged

fix: skip CameraUI layer subtrees to prevent fatal crash on iOS 26+#240
abelonogov-ld merged 2 commits into
launchdarkly:mainfrom
nikhil-vonlabs:fix/camera-ui-layer-crash-ios26

Conversation

@nikhil-vonlabs

@nikhil-vonlabs nikhil-vonlabs commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a fatal EXC_BREAKPOINT crash (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.ModeLoupeLayer is a private CALayer subclass that does not implement init(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 inside MaskCollector.collectViewMasks:

  1. layer.sublayers on a presentation layer — accessing .sublayers on a presentation layer causes Core Animation to call CA::Layer::presentation_layer() on every direct child to produce presentation copies. If any child is CameraUI.ModeLoupeLayer, this immediately crashes.

  2. .presentation() on an ancestor of CameraUI.ModeLoupeLayer — on iOS 26, calling .presentation() on a layer that has CameraUI.ModeLoupeLayer anywhere in its descendant tree eagerly builds presentation copies for the entire subtree, hitting the same crash.

  3. A CameraUI.* layer reaching visit() with no guard — without an early exit, later property accesses inside visit can also trigger presentation layer creation on the unsafe layer.

This was verified by iterating three fix attempts, each confirmed to recompile via LaunchDarklySessionReplay.o timestamp and tested on a physical device running iOS 26 (Xcode 26.x builds):

Attempt Change Result
1 CameraUI guard only in the layer-only else branch Still crashed — layer.sublayers on presentation layer with CameraUI child (symbolLocation:1992)
2 layer.model().sublayers + filter + .map { $0.presentation() ?? $0 } Still crashed — .presentation() on a non-CameraUI ancestor with CameraUI descendant (symbolLocation:2508)
3 (this PR) layer.model().sublayers + filter, no .presentation() call; CameraUI guard at top of visit() ✅ No crash

Changes

MaskCollector.swift

  • Added a CameraUI.* class-name guard as the very first line of visit(), before any property access (belt-and-suspenders for any layer that reaches visit() directly).
  • Replaced layer.sublayers (presentation) with layer.model().sublayers at both the root traversal site and inside visit() for recursion.
  • Removed the .map { $0.presentation() ?? $0 } step entirely — model layers are passed directly to visit(), so .presentation() is never called on any individual sublayer.
  • Filters out CameraUI.* layers before passing sublayers to visit().

MaskingPolicy.swift

  • Added a CameraUI.* prefix check at the top of shouldIgnore(_: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 shows CameraUI.ModeLoupeLayer. All three prior crash sites (symbolLocation:1992, 2508) are no longer triggered.

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
@nikhil-vonlabs nikhil-vonlabs requested review from a team and abelonogov-ld as code owners July 1, 2026 02:05
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.
@nikhil-vonlabs nikhil-vonlabs reopened this Jul 1, 2026
@abelonogov-ld

abelonogov-ld commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@nikhil-vonlabs
Thanks for fixing the issue. I'm going to take your PR branch and add slight optimization and reproduction screen.

@abelonogov-ld abelonogov-ld merged commit 6efb2b5 into launchdarkly:main Jul 1, 2026
9 checks passed
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 -->
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.

2 participants