Skip to content

Stop the full-screen media viewer racing on its album cache - #788

Open
mpretty-cyro wants to merge 1 commit into
session-foundation:devfrom
mpretty-cyro:fix/mediapager-albumdata-race
Open

mpretty-cyro wants to merge 1 commit into
session-foundation:devfrom
mpretty-cyro:fix/mediapager-albumdata-race

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

Fixes an EXC_BAD_ACCESS inside Dictionary.subscript.getter that 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.swift is byte-identical between that build and dev (blob 356815af29, last touched 2026-03-03), so the diagnosis carries over unchanged.

Cause

MediaGalleryViewModel.albumData had no synchronisation and two writers in different isolation domains:

Writer Reached from Runs on
handleUpdatesupdateAlbumData the album observation callback main actor
loadAndCacheAlbumDataupdateAlbumData prefetchAdjacentAlbums via Task.detached off-main

Task.detached inherits no actor; the Task {} eleven lines away in the view controller does inherit main. Prefetch fires on every page transition, on setCurrentItem and in viewDidLoad, so a page turn during an in-flight prefetch is enough — no coincidence of two album updates is needed.

Fix

@MainActor on albumData, updateAlbumData and prefetchAdjacentAlbums, 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. loadAndCacheAlbumData and createDetailViewController stay non-isolated async, and only the dictionary write hops. Verified against the real Storage actor from a main-isolated caller:

callerOnMain=true   queryBodyOnMain=false   mainActorTicksDuringQuery=100

Storage is an actor, so await storage.read suspends 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 the MainActor.run it already had, which also collapses the two reads either side of the isEmpty check 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.start already declares onChange as @MainActor @Sendable and dispatches it as Task { @MainActor in … }, and MediaPageViewController inherits main-actor isolation from UIPageViewController. So handleUpdates was already isolated twice over, and two callbacks serialise rather than race.
  • Annotating the view controller would therefore have compiled clean, changed nothing behaviourally, and left the crash in place.

Test

SessionTests/Media/MediaGalleryViewModelSpec.swift asserts the isolation, not the timing — a timing test for a data race would flake. A probe subclass overrides updateAlbumData and records Thread.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.

Tree Result
this branch [true] — passes
annotations reverted expected to equal <[true]>, got <[false]> — fails, 0 compile errors

That 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 Session scheme: 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, where PagedDatabaseObserver.onChangeUnsorted writes unobservedGalleryDataChanges from an unannotated closure — the same shape of unsynchronised state, untouched here.

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.
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