Carreras en el main actor: cuando await es la condición de carrera
Esta app luego se renombró a Deep Cut Atlas. Más abajo se la llama “Discoverer” en todo el texto, porque así se llamaba el día en que pasó esto.
Todo en mi view model corre en el main actor. Un solo hilo, sin locks, sin condiciones de carrera de datos. Así que asumí que no había carreras de ningún tipo.
El tipo de carrera equivocado.
El problema
Mi pestaña de Historial pagina entre canciones reproducidas recientemente. La revisión marcó esto, y ahora tocaba arreglarlo. Dos métodos importan:
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)
// ...
}
Y un pull-to-refresh que trae el offset 0 y reemplaza tracks.
A ver si se detecta el bug: loadMore lee tracks.count, digamos, 8, y luego se suspende en el await. El main actor queda libre ahora. Alguien hace pull-to-refresh; se completa y reinicia tracks a una página nueva de 8. Después el loadMore viejo se reanuda y agrega la página que trajo en el offset viejo a la lista nueva. En un feed que cambia (es de reproducciones recientes, cambia cada vez que se reproduce algo), eso da filas duplicadas, filas con huecos, o ambas cosas.
¿La bandera isLoadingMore que ya tenía? Solo evita que loadMore compita consigo mismo. No dice nada sobre el refresh.
Esta es la parte de la concurrencia de Swift que más costó internalizar: @MainActor elimina las condiciones de carrera de datos, no las de intercalado. Cada await es una puerta. Mientras algo está suspendido, cualquier otra cosa puede entrar por ahí y reacomodar los muebles.
La solución: un token de generación
Consideré cancelar el load-more en curso cuando arranca un refresh, pero el view model nunca es dueño de esas Tasks, SwiftUI las crea desde .task y .refreshable. En vez de eso, seis líneas de concurrencia optimista:
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
}
}
La regla es “el que escribe tarde pierde”. La línea sutil es la del catch: sin ella, un load-more obsoleto que fallara pondría hasMore = false y mataría en silencio la paginación de una lista a la que nunca perteneció.
Probarlo sin sleeps
La carrera solo existe a mitad de la suspensión, y mi servicio simulado responde de forma síncrona. Las pruebas basadas en Task.sleep son una ruleta de tiempos. Lo que funcionó: darle al mock un hook esperable (aprovechando la costura de inyección de errores), y estacionarlo en una continuación.
// 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
El intercalado se fuerza, no se corre como carrera real, la prueba es determinista y corre en un milisegundo. También la verifiqué mentalmente contra el código sin corregir: el load-more estacionado agregaría después del refresh y la aserción fallaría. Una prueba de regresión que no puede fallar no es una.
Lecciones
- Un solo hilo no significa libre de carreras. Cada
awaiten un actor es un punto donde los invariantes pueden invalidarse. Hay que releer lo que se capturó antes de la suspensión. - Guardas como
isLoadingMoreprotegen a un método de sí mismo. Las carreras viven entre métodos, hay que coordinarlos explícitamente. - Un token de generación es la coordinación más barata que existe cuando la política correcta es “gana el más nuevo”, y en una lista que se puede refrescar, casi siempre lo es.
- Para probar una carrera, no hay que agregar demoras, hay que agregar una costura de suspensión y estacionarla ahí. Las continuaciones hacen que los intercalados sean reproducibles.
Lecturas relacionadas
La trampa de aislamiento de actores en "simplemente sácalo del hilo principal"
Las conformidades Codable sintetizadas también heredan el aislamiento MainActor por defecto, y async let tiene exigencias más estrictas que la tarea que ya tenías.
Cuando @MainActor y TaskGroup no se llevan bien en Swift 6
El verificador de aislamiento basado en regiones me dice que reporte un bug, y el Task no estructurado, pasado de moda, resulta ser la herramienta correcta.
Cierre de las brechas de pruebas: lógica pura, una rama simulada inalcanzable y un cambio a Swift 6
Extracción de algoritmos fuera del alcance de MusicKit, un parámetro simulado que se ignoraba en silencio, y dos pruebas que habían estado pasando por la razón equivocada todo este tiempo.