Skip to content

fix(replay): stop the layoutSublayers swizzle running UIKit layout off-main - #809

Closed
posthog[bot] wants to merge 4 commits into
mainfrom
posthog-self-driving/fixreplay-stop-the-layoutsublayers-507251
Closed

fix(replay): stop the layoutSublayers swizzle running UIKit layout off-main#809
posthog[bot] wants to merge 4 commits into
mainfrom
posthog-self-driving/fixreplay-stop-the-layoutsublayers-507251

Conversation

@posthog

@posthog posthog Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

  • Session replay users crash in shipped apps. One reporter attributes it to iPads across a full release cycle and calls it a blocker; four support tickets reference layoutSublayers. Every iOS replay user is on the path, because PostHogReplayIntegration.start() subscribed to the layout publisher unconditionally and neither throttleDelay nor screenshotMode gated it.
  • ph_swizzled_layoutSublayers(of:) forwarded to UIKit before any thread check. Core Animation can call the method on a background thread when it commits a thread-local transaction during thread cleanup (CA::Transaction::release_thread). The forwarded call then runs Auto Layout off-main and NSISEngine raises NSInternalInconsistencyException, which terminates the host app.
  • #533 guarded only the SDK's own notification call, one line below where the exception fires.
  • There was no supported way to remove the hook short of enableSwizzling = false, which also drops autocapture, screen views, replay and surveys.

Closes #806

Before / after, off the main thread
flowchart LR
  A["CA::Transaction::release_thread<br/>(background thread)"] --> B["ph_swizzled_layoutSublayers(of:)"]
  B --> C["before: forward to UIKit<br/>NSISEngine → crash"]
  B --> D["after: layer.setNeedsLayout()<br/>on the main queue"]
  D --> E["main thread: UIKit layout<br/>+ replay notification"]
Loading

📝 Changes

Change Effect
The swizzle no longer forwards to UIKit off-main; it marks the layer on the main queue instead The UIKit layout pass and the replay notification both run on the main thread
New PostHogConfig.captureViewLayoutChanges (default true) Removes the hook. Session replay and surveys both read it, so nothing keeps it installed

captureViewLayoutChanges sits on PostHogConfig rather than sessionReplayConfig because the hook is shared: surveys subscribe to the same publisher and are enabled by default, and the publisher installs the swizzle for any subscriber. With the flag off, replay keeps console logs, network telemetry and interaction events but captures no screen content, and surveys appear only on app activation or an event trigger.

💚 How did you test it?

  • ApplicationViewLayoutPublisherTest.layoutStaysOnMainThread installs the swizzle, calls layoutSublayers(of:) from a background Thread, and asserts layoutSubviews() never ran off-main. Against the old code the view records an off-main layout pass; against the new code it does not.
  • ViewLayoutCaptureOptOutTest installs the survey integration against an injected publisher with the flag both on and off, and asserts the subscription only exists when the flag is on.
  • Both tests pass on CI (Tests / test-ios-simulator), alongside the build, example and lint matrix.
  • No local or manual run. This change was produced in a Linux container with no Swift or Xcode toolchain, so nothing was executed outside CI and there is no device or simulator session. CI proves the tests pass; it does not prove the crash is gone in a real app under Core Animation thread cleanup, which needs a maintainer. Per the AI contributions policy, no manual testing is claimed.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

Written by Claude Opus 5 running in PostHog Desktop, from the linked inbox report.

Decisions on the off-main path:

Option Outcome
Re-dispatch the original call to main (self.ph_swizzled_layoutSublayers(of: layer)) Rejected — the block retains the UIView, and a view that is mid-dealloc when Core Animation reaches its layer delegate would be resurrected.
Skip the layout pass entirely off-main Rejected — Core Animation clears the layer's needs-layout flag, so the view could stay stale.
DispatchQueue.main.async { layer.setNeedsLayout() } Chosen — retains only the CALayer, which the transaction already holds, and re-arms the layout through the normal main-thread path. The delegate is resolved fresh at that point, so a deallocated view is simply skipped.

The maintainer comment on #806 reports that this exception also reproduces with the swizzle absent, and cautions against moving the original call. That is a UIKit-level hazard the SDK cannot remove for apps in general — but the swizzle sits in front of UIKit whenever replay is on, so the SDK can stop the off-main pass for its own users, which is what this does. The off-main notification branch is dropped as well: the deferred main-thread layout produces that notification itself.

The review bot found the first version of the opt-out did not remove the hook, because surveys kept subscribing. It offered two fixes: honour one flag in both subscriptions, or narrow the wording. Commit dd78003 (a separate automated task on this branch) took the wording route; 5832c02 supersedes it with the behaviour route, so the flag now does what its name says.

Not addressed: why the background transaction contains pending view-layout work in the first place. That trigger is still open on #806, and this change makes the SDK safe on the path rather than explaining it.


Created with PostHog Desktop from this inbox report.

…f-main

The swizzled `UIView.layoutSublayers(of:)` forwarded to UIKit before any thread
check. Core Animation can call it on a background thread when it commits a
thread-local transaction during thread cleanup, so the forwarded call reached
Auto Layout off-main and raised `NSInternalInconsistencyException`.

Off the main thread the hook now marks the layer for layout on the main queue
and returns, so both the UIKit layout pass and the replay notification run on
the main thread.

Adds `sessionReplayConfig.captureViewLayoutChanges` so an app can remove the
hook and keep the rest of session replay.

Generated-By: PostHog Desktop
Task-Id: 296e6380-9948-4bfe-9ab0-73486280ebf0
@posthog

posthog Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

🦔 PostHog Review reviewed this pull request

Found 2 must fix, 1 should fix, 0 consider.

Published 3 findings (view the review).

Resolved comments: 1 fixed, 2 already settled

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-09-08 21:09:07 UTC
Duration: 233583ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2897ms
Format Validation.Event Has Uuid 3000ms
Format Validation.Event Has Lib Properties 2959ms
Format Validation.Distinct Id Is String 2902ms
Format Validation.Token Is Present 2774ms
Format Validation.Custom Properties Preserved 562ms
Format Validation.Event Has Timestamp 2800ms
Retry Behavior.Retries On 503 11912ms
Retry Behavior.Does Not Retry On 400 4681ms
Retry Behavior.Does Not Retry On 401 4818ms
Retry Behavior.Respects Retry After Header 7839ms
Retry Behavior.Implements Backoff 21871ms
Retry Behavior.Retries On 500 9365ms
Retry Behavior.Retries On 502 9285ms
Retry Behavior.Retries On 504 9304ms
Retry Behavior.Max Retries Respected 21862ms
Deduplication.Generates Unique Uuids 3044ms
Deduplication.Preserves Uuid On Retry 9380ms
Deduplication.Preserves Uuid And Timestamp On Retry 16868ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7859ms
Deduplication.No Duplicate Events In Batch 3027ms
Deduplication.Different Events Have Different Uuids 3133ms
Compression.Sends Gzip When Enabled 2927ms
Batch Format.Uses Proper Batch Structure 893ms
Batch Format.Flush With No Events Sends Nothing 422ms
Batch Format.Multiple Events Batched Together 3033ms
Error Handling.Does Not Retry On 403 4847ms
Error Handling.Does Not Retry On 413 2526ms
Error Handling.Retries On 408 9210ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2904ms
Request Payload.Flags Request Uses V2 Query Param 2897ms
Request Payload.Flags Request Hits Flags Path Not Decide 2792ms
Request Payload.Flags Request Omits Authorization Header 2805ms
Request Payload.Token In Flags Body Matches Init 2882ms
Request Payload.Groups Round Trip 2890ms
Request Payload.Groups Default To Empty Object 2833ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 3043ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2782ms
Request Payload.Disable Geoip Omitted Defaults To False 3001ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2825ms
Request Lifecycle.No Flags Request On Init Alone 129ms
Request Lifecycle.No Flags Request On Normal Capture 2905ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5798ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2929ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 3093ms

Generated-By: PostHog Desktop
Task-Id: 296e6380-9948-4bfe-9ab0-73486280ebf0
@posthog

posthog Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PostHog Review

Found 2 must fix, 1 should fix.

Comment thread PostHog/Replay/PostHogSessionReplayConfig.swift Outdated
Comment thread PostHog/Replay/PostHogSessionReplayConfig.swift Outdated
Comment thread PostHog/Replay/PostHogReplayIntegration.swift Outdated
posthog Bot added 2 commits September 8, 2026 20:50
…tion

The layout hook is installed by subscriber count, not by a config flag, and
surveys subscribe to the same publisher on iOS by default. Setting
captureViewLayoutChanges to false therefore stops replay screen capture but
does not necessarily uninstall the swizzle. Correct the property documentation
and the changeset to say so, and point at config.surveys for removing the hook
entirely.

Generated-By: PostHog Desktop
Task-Id: 16d7b477-619e-4c1b-af76-2e6ea818086b
The flag lived on `sessionReplayConfig` and gated only the replay subscription.
Surveys subscribe to the same publisher and are on by default, so the swizzle
stayed installed and the escape hatch did not do what it promised.

Moves the flag to `PostHogConfig`, where session replay and surveys both read
it, and documents what each product loses. Adds a test that covers both
settings through an injected publisher.

This supersedes the documentation-only narrowing in dd78003, which described
the old behaviour rather than changing it.

Generated-By: PostHog Desktop
Task-Id: 296e6380-9948-4bfe-9ab0-73486280ebf0
@marandaneto

Copy link
Copy Markdown
Member

see #806 (comment)

@marandaneto marandaneto closed this Sep 9, 2026
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.

Session Replay's layoutSublayers swizzle still crashes NSISEngine on background threads — #533's guard doesn't cover the forwarded original call

1 participant