Racing the main actor: when await is the race condition
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
Everything in my view model runs on the main actor. One thread, no locks, no data races. So I assumed no races at all.
Wrong kind of race.
The problem
My History tab pages through recently-played tracks. The review flagged this, and now it was time to fix it. Two methods matter:
func loadMore() async {
guard hasMore, !isLoadingMore, state == .loaded else { return }
isLoadingMore = true
defer { isLoadingMore = false }
let page = try await service.fetchRecentlyPlayed(offset: tracks.count, limit: pageSize)
tracks.append(contentsOf: page)
// ...
}
And a pull-to-refresh that fetches offset 0 and replaces tracks.
Spot the bug: loadMore reads tracks.count - say, 8 - then suspends at the await. The main actor is free now. The user pulls to refresh; it completes and resets tracks to a fresh page of 8. Then the old loadMore resumes and appends the page it fetched at the old offset onto the new list. On a feed that shifts (it’s recently-played - it shifts every time you play something), that’s duplicate rows, gap rows, or both.
The isLoadingMore flag I already had? It only stops loadMore from racing itself. It says nothing about refresh.
This is the part of Swift concurrency that took longest to internalize: @MainActor eliminates data races, not interleaving races. Every await is a door. While you’re suspended, anyone can walk through and rearrange the furniture.
The fix: a generation token
I considered cancelling the in-flight load-more when a refresh starts, but the view model never owns those Tasks - SwiftUI spawns them from .task and .refreshable. Instead, six lines of optimistic concurrency:
private var generation = 0
private func fetchFirstPage() async {
generation += 1 // any in-flight work is now stale
let gen = generation
let page = try await service.fetchRecentlyPlayed(offset: 0, limit: pageSize)
guard gen == generation else { return } // ...including me, if superseded
tracks = page
// ...
}
func loadMore() async {
// ...
let gen = generation
let offset = tracks.count // captured together, atomically (no await between)
do {
let page = try await service.fetchRecentlyPlayed(offset: offset, limit: pageSize)
guard gen == generation else { return } // stale page: discard
tracks.append(contentsOf: page)
} catch {
guard gen == generation else { return } // stale FAILURE: also discard
hasMore = false
}
}
The rule is “late writer loses”. The subtle line is the one in the catch: without it, a stale load-more that failed would set hasMore = false and quietly kill pagination on a list it never belonged to.
Testing it without sleeps
The race only exists mid-suspension, and my mock service returns synchronously. Task.sleep-based tests are timing roulette. What worked: give the mock an awaitable hook (building on the error-injection seam), and park it on a continuation.
// in the mock
var onFetchRecentlyPlayed: (() async -> Void)?
// in the test
let gate = FetchGate() // wait() parks on a continuation
service.onFetchRecentlyPlayed = { await gate.wait() }
let loadMore = Task { await vm.loadMore() }
while !gate.entered { await Task.yield() } // it's parked mid-fetch now
service.onFetchRecentlyPlayed = nil
await vm.refresh() // resets list, bumps generation
gate.release()
await loadMore.value
#expect(vm.tracks.map(\.id) == firstPageIDs) // stale page discarded
The interleaving is forced, not raced - the test is deterministic and runs in a millisecond. I also sanity-checked it against the unfixed code in my head: the parked load-more would append after the refresh and the assertion fails. A regression test that can’t fail isn’t one.
Lessons
- Single-threaded does not mean race-free. Every
awaiton an actor is a point where your invariants can be invalidated. Re-read what you captured before the suspension. - Guards like
isLoadingMoreprotect a method from itself. Races live between methods; coordinate them explicitly. - A generation token is the cheapest coordination there is when “newest wins” is the right policy - and on a refreshable list, it almost always is.
- To test a race, don’t add delays - add a suspension seam and park it. Continuations make interleavings reproducible.
Related reading
The actor-isolation trap in "just move it off the main thread"
Synthesized Codable conformances inherit default MainActor isolation too, and async let has stricter demands than the task you already have.
When @MainActor and TaskGroup don't mix in Swift 6
The region-based isolation checker tells me to file a bug, and the unfashionable unstructured Task turns out to be the correct tool.
Closing the test gaps: pure logic, an unreachable mock branch, and a Swift 6 flip
Extracting algorithms out of MusicKit's reach, a mock parameter that was silently ignored, and two tests that had been passing for the wrong reason all along.