Stop the full-screen media viewer racing on its album cache - #788
Open
mpretty-cyro wants to merge 1 commit into
Open
mpretty-cyro wants to merge 1 commit into
mpretty-cyro wants to merge 1 commit into
Conversation
EXC_BAD_ACCESS inside Dictionary.subscript.getter while a photo or video is open full-screen and the underlying album changes — five identical reports on 2.15.4, five devices, all iOS 26.x. A SIGSEGV there is a torn read of a dictionary being mutated on another thread rather than a nil unwrap. `albumData` had no synchronisation and two writers in different isolation domains. `prefetchAdjacentAlbums` fires on every page transition, on `setCurrentItem` and in `viewDidLoad`, and reaches `loadAndCacheAlbumData` through `Task.detached`, so its write lands off the main thread while `MediaPageViewController.handleUpdates` reads the same dictionary on the main actor. Isolating the property to the main actor makes the compiler place that write: a synchronous call into it from a non-isolated context is now an error. Nothing expensive moves — `loadAndCacheAlbumData` and `createDetailViewController` stay non-isolated `async`, the database read still runs on the storage actor, and only the dictionary write hops. `createDetailViewController`'s album lookup moves inside the `MainActor.run` it already had, which also collapses the two reads either side of the `isEmpty` check into one. The observation callback was never the second writer: `Storage.start` declares `onChange` `@MainActor` and `MediaPageViewController` inherits main-actor isolation from `UIPageViewController`, so callbacks arriving together already serialise.
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.
Fixes an
EXC_BAD_ACCESSinsideDictionary.subscript.getterthat kills the app while a photo or video is open full-screen and the underlying album changes — a new attachment arrives, or one is deleted or expires.Five byte-identical reports on 2.15.4 (723 ·
f13adf0) over 2026-09-13/14, across five devices, all iOS 26.x.MediaPageViewController.swiftis byte-identical between that build anddev(blob356815af29, last touched 2026-03-03), so the diagnosis carries over unchanged.Cause
MediaGalleryViewModel.albumDatahad no synchronisation and two writers in different isolation domains:handleUpdates→updateAlbumDataloadAndCacheAlbumData→updateAlbumDataprefetchAdjacentAlbumsviaTask.detachedTask.detachedinherits no actor; theTask {}eleven lines away in the view controller does inherit main. Prefetch fires on every page transition, onsetCurrentItemand inviewDidLoad, so a page turn during an in-flight prefetch is enough — no coincidence of two album updates is needed.Fix
@MainActoronalbumData,updateAlbumDataandprefetchAdjacentAlbums, so the compiler places the write instead of leaving it to convention. A synchronous call into that state from a non-isolated context is now a hard error rather than a comment nobody can check.Deliberately not a lock around the dictionary: that would silence the crash and leave a UIKit view controller mutating view state from an unknown thread, which is a worse bug and a quieter one.
Nothing expensive moves onto the main thread.
loadAndCacheAlbumDataandcreateDetailViewControllerstay non-isolatedasync, and only the dictionary write hops. Verified against the realStorageactor from a main-isolated caller:Storageis anactor, soawait storage.readsuspends and releases the main actor — the query never runs on the main thread, and the main actor stayed free throughout.createDetailViewController's album lookup moves inside theMainActor.runit already had, which also collapses the two reads either side of theisEmptycheck into a single hop.Two things the crash report got wrong, for the record
The report this came from attributed the race to two observation callbacks arriving together, and proposed annotating
MediaPageViewController@MainActor. Neither holds:Storage.startalready declaresonChangeas@MainActor @Sendableand dispatches it asTask { @MainActor in … }, andMediaPageViewControllerinherits main-actor isolation fromUIPageViewController. SohandleUpdateswas already isolated twice over, and two callbacks serialise rather than race.Test
SessionTests/Media/MediaGalleryViewModelSpec.swiftasserts the isolation, not the timing — a timing test for a data race would flake. A probe subclass overridesupdateAlbumDataand recordsThread.isMainThread; an override inherits the overridden method's isolation, so the spec compiles against fixed and unfixed code alike and goes red rather than failing to build.[true]— passesexpected to equal <[true]>, got <[false]>— fails, 0 compile errorsThat second row is also the direct evidence for the mechanism: with the isolation gone, the write genuinely lands off the main thread.
Full suite via the
Sessionscheme: 2189 passed, 0 failed, 0 restarts,** TEST SUCCEEDED **.Not claimed
Seven further 2.15.4 reports share the signature family (
Dictionary._Variant.isUniquelyReferenced,_swift_stdlib_malloc_size,swift_isUniquelyReferenced_nonNull_native) but carry no Session frame at the top. They are not attributed to this site and should not be closed out by this change. One unverified candidate worth a separate look: the gallery half of the same view model, wherePagedDatabaseObserver.onChangeUnsortedwritesunobservedGalleryDataChangesfrom an unannotated closure — the same shape of unsynchronised state, untouched here.