Skip to content

Fix CollectionViewLayout delegate lifetime and ListView teardown crash#613

Closed
RoyalPineapple wants to merge 5 commits intomainfrom
RoyalPineapple/fix-collection-layout-delegate-lifetime
Closed

Fix CollectionViewLayout delegate lifetime and ListView teardown crash#613
RoyalPineapple wants to merge 5 commits intomainfrom
RoyalPineapple/fix-collection-layout-delegate-lifetime

Conversation

@RoyalPineapple
Copy link
Copy Markdown
Collaborator

@RoyalPineapple RoyalPineapple commented May 2, 2026

Problem

ListView.deinit has two lifecycle issues that can cause crashes during view hierarchy teardown:

1. CollectionViewLayout delegate

CollectionViewLayout held its delegate as unowned let. If the delegate is deallocated before the layout (e.g. during a layout swap or via a deferred dispatch from sendEndQueuingEditsAfterDelay), any access traps. Concretely, OperationQueue.main.addOperation { self.delegate.listViewShouldEndQueueingEditsForReorder() } schedules work for a later runloop turn — if the owning ListView deallocates between scheduling and dispatch, the closure trapped on the dangling unowned reference.

2. UICollectionView remaining in view hierarchy during deallocation (crash fix)

The embedded UICollectionView remained in the view hierarchy during ListView.deinit. If a layout pass (layoutBelowIfNeeded) hit the window during this interval, UIKit would call layoutSubviews on the collection view, which internally accesses UICollectionViewData._updateItemCounts. The UICollectionViewData stores a weak reference to its parent UICollectionView at offset +0x8. During the deallocation cascade, this weak reference's memory was already freed and reused, causing objc_loadWeakRetained to read garbage values and SIGSEGV.

How we discovered this

While integrating Market's accessibility label deferral feature into ios-register, we added .accessibility(identifier:) to tab bar items, which wraps each item in an additional CombinableView (Blueprint's AccessibilitySetter). This extra view layer caused additional layoutSubviewsupdateAccessibility() calls during KIF's recursive accessibilityElementMatchingBlock traversal. The extra layout passes triggered layoutBelowIfNeeded on the window, which forced layout on a ListView's embedded UICollectionView whose internal UICollectionViewData was mid-deallocation.

Evidence:

  • Deterministic SIGSEGV in objc_loadWeakRetained called from UICollectionViewData._updateItemCounts
  • Crash address was always a small garbage value (0x3, 0xf, 0x1d, 0x24, 0x33, 0x3e, 0x45) — use-after-free with memory reused for small allocations
  • x2 register contained type metadata for CollectionViewLayout — confirming it was a ListableUI collection view
  • Hopper decompilation of UICollectionViewData.initWithCollectionView:layout: confirmed the weak ref at offset +0x8
  • Reproduced deterministically (7/7 crashes) with a specific 10-test KIF shard on iPad Air 5th gen, iOS 18.1
  • Vanished under lldb or ASan (Heisenbug — observation changed timing/memory layout)
  • Adding self.collectionView.removeFromSuperview() in ListView.deinit eliminated the crash (0/3 on same config, 0/1 in CI)

Fix

Commit 1: Delegate lifetime

Change delegate from unowned let to private(set) weak var, thread the delegate through layout helpers as a parameter (so it stays strongly held for the duration of each call), capture it weakly in the deferred OperationQueue closure, and guard prepare() with an early return when the delegate has gone.

Commit 2: Collection view removal

Add self.collectionView.removeFromSuperview() in ListView.deinit. This ensures the collection view is immediately removed from the view hierarchy before the deallocation cascade reaches UICollectionViewData, so layoutBelowIfNeeded won't find it in the layer tree.

Test

Adds two unit tests in CollectionViewLayoutTests:

  • test_prepare_whenDelegateHasBeenDeallocated — verifies prepare() is safe after the delegate has been released.
  • test_sendEndQueuingEditsAfterDelay_whenDelegateDeallocatesBeforeDispatch — exercises the original crash path: schedule the deferred operation, drop the delegate, then drain the main queue to confirm the closure no-ops instead of trapping.

The removeFromSuperview fix was validated against the deterministic crash reproduction in ios-register's KIF test suite (no unit test — the crash is a UIKit-internal use-after-free that requires a window/layout pass to reproduce).

Risk

Low. Both fixes target deallocation edge cases. In normal flows, ListView retains its delegate and is properly removed from the hierarchy before dealloc.

Checklist

  • Ensure any public-facing changes are reflected in the changelog

Hold the layout delegate weakly instead of unowned so the layout no longer
crashes if the delegate is deallocated before the layout. prepare() and the
deferred reorder flush now snapshot the delegate locally; LayoutManager's
layout-swap path uses listableInternalFatal if the previous layout's
delegate is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RoyalPineapple RoyalPineapple marked this pull request as ready for review May 2, 2026 15:35
@RoyalPineapple RoyalPineapple marked this pull request as draft May 2, 2026 15:46
RoyalPineapple and others added 2 commits May 2, 2026 22:10
During ListView.deinit, the embedded UICollectionView could remain
in the view hierarchy briefly while its internal UICollectionViewData
was being torn down. If a layout pass (e.g. from layoutBelowIfNeeded)
hit the window during this window, UIKit would call layoutSubviews on
the collection view, accessing the already-freed UICollectionViewData
and crashing in objc_loadWeakRetained with a garbage weak ref.

Explicitly removing the collection view from its superview in deinit
ensures UIKit won't attempt to lay it out after ListView begins
deallocation.
Covers the OperationQueue.main path in sendEndQueuingEditsAfterDelay,
which was the original crash motivating the weak-delegate fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@RoyalPineapple RoyalPineapple changed the title Fix CollectionViewLayout delegate lifetime Fix CollectionViewLayout delegate lifetime and ListView teardown crash May 2, 2026
The removeFromSuperview in deinit fires too late — the ListView may still
be retained while KIF traverses the view hierarchy during test teardown.
Moving cleanup to didMoveToWindow(nil) detaches the collection view as
soon as the ListView leaves the window, preventing UICollectionViewData
from accessing a corrupted weak reference during layout.
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.

1 participant