Saltar al contenido
Development

El error de sugerencias que sobrevivió porque mi propia investigación me mintió dos veces

Por Victor Da Luz
swiftiostestingdev-logdeep-cut-atlas

Un usuario reportó que las sugerencias de “Más de este artista” de Deep Cut Atlas, la lista que se supone debe mostrar álbumes que el usuario todavía no tiene, seguían mostrando álbumes que ya tenía. Eso anula por completo el propósito de una función de descubrimiento. El issue ya tenía una teoría asociada: una carrera entre dos cargas asíncronas. Entré esperando confirmar esa teoría, escribir la corrección, y seguir adelante. Hicieron falta dos rondas de “en realidad, déjame revisar eso de nuevo” antes de confiar en lo que tenía.

El planteamiento

Tanto la pestaña Lista como la pestaña Historial tienen la misma forma. Cargar una lista de elementos, mostrársela al usuario, y luego, por separado, en segundo plano, obtener el conjunto de claves de álbumes que ya están en la biblioteca del usuario, para que una hoja de detalle pueda filtrar las sugerencias contra eso más adelante. Esa segunda consulta es costosa (un escaneo de toda la biblioteca), así que está deliberadamente desacoplada de la primera: la lista se vuelve interactiva antes de que termine el escaneo de biblioteca. Incluso hay un comentario en el código que lo explica, a propósito.

El view model de la hoja de detalle por álbum tomaba ese conjunto de claves de biblioteca como parámetro del constructor, una instantánea congelada, capturada en el momento en que se abría la hoja. Si se tocaba una fila durante la ventana entre que “la lista ya es interactiva” y que “termina el escaneo de biblioteca,” la instantánea quedaba congelada vacía, y el filtro de sugerencias no tenía nada contra qué filtrar. Para siempre, en esa hoja.

Esa es la teoría que ya planteaba el issue, marcada explícitamente como no confirmada. Primer paso: leer el código real y comprobarlo.

Primer traspié

Corrí una pasada de investigación para verificar la teoría en ambas pestañas, Lista e Historial. El resultado confirmó la carrera para Lista, pero concluyó que Historial estaba a salvo: el argumento era que la vista de Historial espera con await el load() completo del padre antes de mostrar nada, así que la lista nunca podría renderizarse antes de que las claves de biblioteca estuvieran listas.

Eso no me convenció. A @Observable de SwiftUI no le importa si la función que mutó una propiedad ya retornó o no, vuelve a renderizar ante la mutación misma, en la próxima oportunidad que tenga el run loop. Un await load() en el punto de llamada indica cuándo termina toda la función, no cuándo puede el usuario interactuar por primera vez con lo que esa función construyó. Envié una segunda pasada, más escéptica, sobre esa afirmación exacta, y se desarmó al inspeccionarla: el view model de Historial marca su lista como “cargada” y luego sigue corriendo, obteniendo un par de cosas más, y solo al final espera con await el escaneo de biblioteca. El primer punto real de suspensión después de que la lista se vuelve interactiva está dentro de ese escaneo. SwiftUI renderiza justo ahí. Historial tenía el mismo error, y peor: a diferencia de Lista, ni siquiera mostraba un indicador de carga durante esa ventana, así que no había ninguna pista visual de que algo seguía en curso.

Lección que sigo reaprendiendo: “espera con await toda la función” es una afirmación distinta de “nada se renderiza hasta que la función retorna.” Son mecanismos diferentes y tengo que comprobar cuál de los dos es realmente cierto, cada vez, en lugar de reconocer patrones a partir de la forma del código.

Segundo traspié

Con el error confirmado en ambas pestañas, esbocé la corrección: dejar de congelar la instantánea, leer en vivo el estado del padre en su lugar, y asegurarme de que el hijo espere con await la carga única del padre antes de mirar. Bastante simple. También decidí, sin que nadie lo pidiera, que esto necesitaba una pequeña capa de fusión de llamadas para que dos llamadores (el padre y el hijo que ahora también esperaba) no dispararan el escaneo dos veces.

Antes de escribir nada de eso, sometí el plan a una segunda opinión. Volvieron dos cosas. Primera: mi plan de usar async let para correr cosas de forma concurrente no compila en este codebase, un comentario en otra parte del código ya explica por qué (el tipo del servicio no es seguro para Sendable en ese caso). Bien, entonces en serie; el caso común ya es rápido. Segunda, más filosa: ¿había comprobado realmente si la fusión de llamadas que estaba por construir ya existía una capa más abajo?

Sí existía. Un cambio anterior ya había agregado exactamente ese cacheo al servicio real, un valor cacheado más una tarea compartida en curso, de modo que dos llamadores concurrentes comparten un solo escaneo en vez de correr dos. Había leído sobre ese patrón antes en la misma sesión y reaccioné con “replicarlo acá” en lugar de la inferencia más útil, “entonces no lo necesito.” Borré toda la capa de fusión de llamadas antes de escribir una sola línea. La corrección quedó más pequeña y simple por haber estado a punto de sobre-diseñarse.

La prueba que se resistió

Demostrar que un error de temporización quedó corregido normalmente significa, o un dispositivo en el que no se puede acertar de forma confiable una ventana de microsegundos, o una prueba determinista que deja una llamada async a mitad de camino y hace competir algo contra ella. Este codebase ya tenía ese recurso, una pequeña compuerta basada en continuations que una prueba anterior usaba para mantener abierta una consulta. Fui a reutilizarla y de inmediato choqué con el mismo problema que la corrección misma estaba esquivando: como el servicio simulado de biblioteca no fusiona llamadas (solo lo hace el real), el “el hijo también espera con await el loader del padre” de mi corrección significaba que dos llamadores independientes llegarían a la compuerta, la propia carga del padre y el await recién agregado del hijo. La compuerta existente solo manejaba un esperador; un segundo simplemente se quedaría colgado para siempre, porque la única continuation almacenada se sobrescribiría en silencio.

Arreglé la compuerta para que guardara una lista de esperadores en vez de uno solo, reanudarlos a todos al liberar, y exponer un contador que pudiera sondear en vez de un booleano: dejarla en pausa hasta que exactamente dos llamadores estuvieran esperando, y entonces liberar a ambos. Escribí la prueba de regresión para ambas pestañas contra eso: iniciar la carga del padre, esperar a que realmente se suspenda dentro del escaneo, abrir la hoja de detalle mientras sigue suspendida, esperar a que la propia carga de la hoja también se suspenda, liberar ambas, y luego verificar que el álbum ya poseído nunca se filtrara. Verde, de forma determinista, en cada corrida, lo cual es una garantía más sólida que un toque en un dispositivo que quizás no acierte dos veces en la misma ventana de veinte milisegundos.

Lo que no arreglé

La investigación reveló una segunda versión, más pequeña, del mismo error: una bandera de “esto ya está en la biblioteca” en la hoja de detalle de Historial, congelada de la misma manera, que controla si el botón Agregar está habilitado. Menor gravedad: una corrección previa ya hace que un toque desactualizado reporte “ya está en la biblioteca” en lugar de duplicar algo en silencio, así que el modo de falla es un botón habilitado por error, no datos corruptos. Corregirlo bien también implicaba rehacer una prueba existente que simulaba la desactualización a mano, ya que el mecanismo del que dependía desaparece en cuanto el valor deja de ser un parámetro del constructor. En vez de dejar que el alcance se expandiera dentro de un cambio que ya tocaba varios archivos, lo registré aparte y seguí adelante. Bastante conforme conmigo mismo por no arreglar de inmediato todo lo que encontré en una sola sesión.

Reflexión

El tema de este caso no fue el error, el error resultó bastante mecánico una vez confirmado de verdad. Fue cuántas veces una afirmación que sonaba plausible (de una pasada de investigación, de mi propio primer instinto) necesitó una segunda mirada, adversarial, antes de que actuara sobre ella. “Historial está a salvo porque espera por completo,” “acá necesito agregar fusión de llamadas,” “una compuerta de un solo esperador alcanza”: tres afirmaciones, las tres equivocadas, las tres detectadas al comprobar en vez de confiar en la primera respuesta que sonaba bien. La corrección terminó siendo más pequeña que mi primer borrador, lo cual suele ser buena señal de que la comprobación extra valió el tiempo.

Lecturas relacionadas