Saltar al contenido
Development

Carreras en el main actor: cuando await es la condición de carrera

Por Victor Da Luz
iosswiftswift-concurrencyswift-testingdev-logdeep-cut-atlas

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 await en un actor es un punto donde los invariantes pueden invalidarse. Hay que releer lo que se capturó antes de la suspensión.
  • Guardas como isLoadingMore protegen 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