wandres.dev
ANALÍTICA Y CRASHES · saber qué pasa

Informes de fallo: cómo llegan, símbolos y la línea culpable

Un informe de fallo no es un mensaje de error: es el acta forense de un proceso que el sistema decidió matar. Esta lección reconstruye el camino completo —desde la señal fatal hasta el panel de Xcode—, explica por qué el binario que publicas es deliberadamente ilegible y cómo el `dSYM` devuelve los nombres, y enseña a leer una traza hasta señalar con precisión la línea de tu código responsable, distinguiendo los fallos verdaderos de las terminaciones que solo se les parecen.

⏱ 20 min

Todo programa que se ejecuta fuera de tu máquina acaba muriendo de formas que nunca reprodujiste. El informe de fallo es el único testigo de esa muerte, y como todo testigo escribe en un idioma propio: direcciones hexadecimales, nombres de excepción heredados de Mach, marcos de pila sin nombres y una nomenclatura que mezcla cuarenta años de historia de UNIX con decisiones de diseño de Apple de la última década. La reacción habitual ante ese documento es buscar en él una frase en castellano que explique qué pasó, no encontrarla, y archivarlo. La reacción profesional es la contraria: entender que ese texto contiene, casi siempre, la información suficiente para localizar la línea exacta que rompió un invariante, y que lo único que falta es el diccionario. Esta lección construye ese diccionario y, sobre todo, el método para usarlo sin perder la tarde.

🎯 Al terminar esta lección sabrás
  • Reconstruir el recorrido de un fallo desde la señal fatal hasta el panel de organización de Xcode y entender qué se pierde en cada tramo.
  • Explicar qué es un dSYM, por qué el binario publicado no lleva símbolos y cómo se garantiza la correspondencia por UUID.
  • Leer una traza de pila completa: cabecera, tipo de excepción, motivo de terminación, hilo culpable y marcos con desplazamiento.
  • Distinguir un fallo real de una terminación por vigilante, por presión de memoria o por punto de interrupción del sistema.

El viaje del fallo

Cuando tu proceso ejecuta una instrucción imposible —desreferenciar cero, escribir en una página de solo lectura, saltar a una dirección que no contiene código— la unidad de gestión de memoria genera una excepción de hardware que el núcleo traduce a una excepción de Mach. Esa excepción se ofrece primero a los puertos que el proceso haya registrado, y solo si nadie la atiende se convierte en la señal UNIX equivalente, que sin manejador termina el proceso. Ese doble camino, excepciones de Mach primero y señales después, es exactamente el hueco por el que se cuelan los recolectores de fallos de terceros: registran un puerto de excepción y capturan el evento antes de que el sistema escriba nada.

El sistema, por su parte, arranca ReportCrash, que inspecciona el cadáver del proceso y escribe un archivo .ips con la información que aún puede recuperar del estado de la memoria y de los registros. Ese archivo queda en el dispositivo, en la sección de datos analíticos de los ajustes de privacidad, y solo sale de allí si la persona usuaria activó el envío de datos a los desarrolladores. Esa condición es la primera fuente de sesgo que hay que interiorizar: no ves todos los fallos, ves los de quienes consintieron compartirlos, que ni son la mayoría ni son una muestra aleatoria de tu base de usuarios.

flowchart LR
a[Instruccion invalida] --> b[Excepcion Mach]
b --> c[Puerto de terceros si existe]
b --> d[Senal UNIX sin manejador]
d --> e[ReportCrash escribe el ips]
e --> f[Envio si hay consentimiento]
f --> g[Apple agrupa por firma]
g --> h[Panel de organizacion y API]

Conviene tener presente el catálogo corto de señales, porque cada una acota el tipo de error antes de mirar ninguna línea. SIGSEGV es un acceso a una dirección que no pertenece al proceso, típicamente un puntero nulo o colgante. SIGBUS es un acceso mal alineado o a una página que existe pero no se puede servir, y aparece con frecuencia al leer archivos mapeados en memoria que se truncaron. SIGILL es una instrucción que la arquitectura no reconoce, casi siempre síntoma de memoria de código pisada. Y SIGABRT no es un accidente sino una terminación deliberada del propio proceso: alguien llamó a la rutina de aborto, y ese alguien suele ser una aserción del sistema o una excepción de Objective-C que nadie capturó.

En el otro extremo del recorrido, Apple agrupa los informes por una firma calculada sobre los marcos superiores de la pila del hilo culpable. Esa agregación es útil y es peligrosa a partes iguales: útil porque convierte diez mil informes en veinte grupos manejables, peligrosa porque dos causas distintas que comparten los marcos superiores acaban en el mismo grupo, y una misma causa con dos rutas de llegada acaba partida en dos. Antes de declarar que un grupo está arreglado conviene mirar varios informes individuales dentro de él y comprobar que cuentan la misma historia.

ℹ️
Tres orígenes que no son intercambiables

El panel de organización de Xcode muestra lo que Apple recogió, con símbolos resueltos si subiste el dSYM y con métricas agregadas, pero con retraso de horas y sin ningún contexto de tu aplicación. Un recolector de terceros captura mucho antes, permite adjuntar migas de pan y valores de estado, y llega en minutos, pero al registrar manejadores de señal introduce su propio riesgo de fallar durante el fallo. Y las pruebas internas con TestFlight te dan informes casi completos porque el consentimiento suele estar activado. Un equipo serio usa los tres y sabe que sus cifras nunca coincidirán.

Símbolos, dSYM y la correspondencia por UUID

El binario que subes a la tienda no contiene los nombres de tus funciones. No es un accidente ni una medida de seguridad: es la configuración por omisión de compilación en publicación, que extrae toda la información de depuración a un paquete aparte, el archivo con formato DWARF que conocemos como dSYM. El binario queda más pequeño y más rápido de cargar, y la tabla que traduce direcciones a nombres de función, archivo y línea vive fuera. Sin ese paquete, un informe de fallo es una lista de direcciones; con él, es una traza legible.

La correspondencia entre ambos no se hace por nombre ni por número de versión, sino por un identificador único de compilación que se graba tanto en el binario como en su dSYM. Cambiar una sola línea y recompilar produce un identificador distinto, y un dSYM de una compilación vecina no sirve para nada. De ahí la primera regla operativa de esta lección: los dSYM de toda versión publicada se archivan en un lugar duradero, versionado y accesible, no en el disco de quien hizo la subida.

# Que UUID espera el informe y que UUID tiene mi paquete de simbolos
grep -A2 "Binary Images" informe.ips | head -5
dwarfdump --uuid MiApp.app.dSYM

# Traducir una direccion concreta a archivo y linea
atos -arch arm64 -o MiApp.app.dSYM/Contents/Resources/DWARF/MiApp \
     -l 0x1000e4000 0x1000f1a2c

Hay un matiz que descoloca a mucha gente la primera vez: cuando activas la opción de gestión de firma y recompilación en el proceso de distribución, el binario que Apple entrega a los dispositivos puede no ser exactamente el que subiste, y entonces el dSYM correcto es el que genera Apple y hay que descargarlo del panel de compilaciones. Si tu integración continua sube los símbolos a un recolector externo justo después de compilar, estará subiendo los equivocados. La solución habitual es descargar los dSYM desde la tienda tras el procesado y subirlos entonces, como paso independiente del de compilación.

Merece la pena saber qué ajustes de compilación gobiernan todo esto, porque son los que alguien cambia por descuido y dejan al equipo ciego durante meses. El formato de información de depuración debe estar fijado en la variante que genera archivo dSYM con DWARF, no en la que deja los símbolos dentro del binario; la eliminación de símbolos del ejecutable debe estar activa en publicación y desactivada en depuración; y la generación de símbolos ocultos para funciones internas conviene revisarla, porque su combinación con la eliminación agresiva produce trazas donde tu propio código aparece como una lista de direcciones sin nombre pese a tener el dSYM correcto.

Queda la parte del problema que ningún archivo resuelve: las bibliotecas del sistema. Los marcos que caen dentro de UIKit, Foundation o libswiftCore se resuelven con los símbolos del sistema operativo correspondiente, que Xcode descarga la primera vez que conecta un dispositivo con esa versión. Cuando ves un informe con nombres en tu código y direcciones desnudas en el sistema, no te falta tu dSYM: te faltan los símbolos de esa versión de iOS, y basta con conectar un dispositivo que la tenga o esperar a que Xcode los sincronice.

💡
Ofuscación de Swift y nombres decorados

Los símbolos de Swift llegan decorados con la codificación del compilador, algo parecido a un prefijo con la longitud del módulo seguida del nombre del tipo y de la firma. La herramienta swift demangle los devuelve a forma legible, y Xcode lo hace por ti al mostrar la traza. Si trabajas con informes en crudo desde un archivo o desde una API, incorpora ese paso al guion o pasarás minutos leyendo cadenas ilegibles que ya tienen solución automática.

Leer el informe hasta la línea culpable

Un informe se lee siempre en el mismo orden, y ese orden importa porque cada bloque descarta hipótesis del anterior. Primero la cabecera: identificador del proceso, versión de la aplicación, versión del sistema y modelo de dispositivo. Si todos los informes de un grupo comparten modelo o versión de sistema, acabas de reducir el espacio de búsqueda antes de mirar una sola dirección. Después el tipo de excepción, que responde a la pregunta de qué clase de imposibilidad ocurrió: EXC_BAD_ACCESS es memoria inválida, EXC_CRASH con SIGABRT es una terminación deliberada, EXC_BREAKPOINT es una comprobación del compilador o del entorno de ejecución que decidió detener el mundo.

Ese último merece un aparte porque en Swift es frecuentísimo y se malinterpreta. Un desempaquetado forzado de un valor nulo, un índice fuera de rango, un desbordamiento aritmético o una conversión imposible no producen memoria corrompida: producen una llamada explícita a una rutina de captura que dispara EXC_BREAKPOINT. Cuando veas ese tipo de excepción no busques punteros colgantes, busca una precondición del lenguaje que tu código violó, y la línea suele estar a un marco o dos del vértice de la pila.

// Cuatro fallos que llegan como EXC_BREAKPOINT y no como corrupcion de memoria
let usuario = cache[identificador]!          // desempaquetado forzado de nil
let primero = pendientes[0]                  // indice fuera de rango
let total = restantes - consumidos           // desbordamiento en enteros sin signo
let vista = contenedor as! PanelDetalle      // conversion descendente imposible

El bloque siguiente es el motivo de terminación, y es el que más información concentra en menos espacio cuando la muerte no fue una excepción de memoria. Allí aparecen los códigos del vigilante del sistema, los del recolector de memoria bajo presión y los mensajes de las comprobaciones de la interfaz de usuario en hilo secundario. Solo después de leerlo tiene sentido bajar a las trazas, y allí lo primero es identificar el hilo marcado como culpable, que casi nunca es el único interesante: si el fallo es un bloqueo mutuo o una carrera, el hilo que muere y el hilo que causó el problema son distintos, y la historia completa está repartida entre los dos.

Hay un bloque que solo aparece cuando hay excepciones de Objective-C de por medio y que conviene no pasar por alto, porque cuenta una historia distinta de la del hilo culpable. La traza de la última excepción registra dónde se lanzó, no dónde se abortó el proceso, y esas dos posiciones pueden estar separadas por decenas de marcos: la excepción viaja hasta el manejador general del entorno de ejecución, que es quien decide terminar, y la traza del hilo culpable apunta a ese manejador y no al origen. Cuando el informe incluye ese bloque, es el primero que hay que leer, porque contiene la pregunta real.

Dentro de la traza, cada marco lleva un número, el nombre de la imagen binaria, la dirección absoluta y la dirección base de la imagen más un desplazamiento. Esa suma es lo que atos necesita para devolverte archivo y línea. La disciplina de lectura consiste en descender desde el vértice ignorando los marcos del sistema hasta encontrar el primero que pertenece a tu binario: ese es tu punto de entrada al problema, aunque el error de programación pueda estar más abajo. Los marcos del sistema por encima de él no son ruido, son la explicación de cómo se llegó allí, y a menudo revelan que la llamada venía de un temporizador, de una notificación o de una descarga completada.

Los fallos que no son fallos

Una porción sustancial de los informes que verás no corresponde a código que ejecutó una instrucción imposible, sino a terminaciones administrativas que el sistema aplica para proteger al dispositivo. Confundirlas con fallos lleva a semanas perdidas buscando punteros inexistentes, y por eso conviene reconocerlas por su firma antes de abrir el depurador.

⏱️

Vigilante de arranque

El sistema mata la aplicación si tarda demasiado en llegar a la primera pantalla o en responder a un evento de ciclo de vida. Llega con un código de excepción propio y con la pila detenida en trabajo síncrono sobre el hilo principal.

🧠

Presión de memoria

No es una excepción, es una decisión del recolector del sistema, que elige la víctima por huella de memoria y prioridad. El informe tiene forma de terminación por recursos y sin traza útil de tu código.

🧵

Interfaz fuera del hilo principal

Con las comprobaciones activadas, tocar la interfaz desde un hilo secundario produce una detención deliberada. El fallo aparece donde se detectó, no donde se cometió, y esa distancia es la que despista.

La terminación por presión de memoria merece un tratamiento aparte porque es la más frecuente de las tres en aplicaciones ricas en contenido y la peor documentada en el propio informe. No tendrás traza de la línea culpable porque no hay línea culpable: hay un perfil de consumo que superó el límite tolerable para ese modelo en ese momento. Las herramientas que sirven son otras —el instrumento de asignaciones, el gráfico de memoria, los avisos de memoria del sistema— y la señal de que estás ante esto es la ausencia de patrón en las trazas junto a una concentración en dispositivos con menos memoria física.

Hay además una categoría que técnicamente no mata el proceso pero que las personas usuarias describen exactamente igual que un fallo: el bloqueo prolongado. Una aplicación congelada durante ocho segundos y una aplicación que se cierra sola producen la misma reseña de una estrella. Los diagnósticos de bloqueo llegan por el mismo canal que los fallos y se leen con la misma técnica, con la diferencia de que la traza está viva y muestra qué estaba haciendo el hilo principal en lugar de por qué murió.

El informe de fallo es la prueba de que tu tipo mentía

Conviene mirar un crash report con la mirada del teórico de tipos y no con la del bombero, porque entonces deja de ser un accidente y se convierte en información estructural. Cada fallo por desempaquetado forzado es la constatación empírica de que declaraste un valor como presente cuando el sistema podía entregarlo ausente; cada índice fuera de rango es la evidencia de que tratabas una colección como si su longitud fuera una constante conocida; cada acceso a memoria liberada es la factura de una relación de propiedad que existía en tu cabeza pero no en el código. En todos los casos la máquina no ha descubierto un problema nuevo: ha ejecutado el único contraejemplo que tú no habías considerado y lo ha hecho con una precisión que ninguna revisión humana alcanza. De ahí se sigue una consecuencia práctica que cambia la forma de trabajar. La pregunta correcta ante un fallo no es cómo evito que se rompa aquí, que casi siempre se contesta con una guarda condicional y un valor por omisión que esconde el síntoma; la pregunta correcta es qué tipo debería haber usado para que este estado fuera inexpresable. Un opcional que se propaga hasta el punto donde alguien puede decidir, un enumerado que hace imposible la combinación absurda, un tipo con la longitud garantizada, un identificador que no se puede confundir con otro. Los equipos que tratan cada fallo recurrente como un defecto de modelado ven bajar su tasa de fallos de forma sostenida; los que los tratan como incidentes individuales pasan años arreglando el mismo error con nombres distintos, porque el hueco en el modelo sigue ahí esperando a que otra ruta lo encuentre.

📝
Lo esencial

El fallo nace como excepción de Mach, se convierte en señal, lo captura ReportCrash o un recolector de terceros y solo llega a ti si hubo consentimiento. El binario publicado no tiene símbolos: los tiene el dSYM, emparejado por UUID de compilación, que hay que archivar por versión y descargar de la tienda si activaste la recompilación. Se lee de arriba abajo: cabecera, tipo de excepción, motivo de terminación, hilo culpable y primer marco propio. Y antes de depurar hay que descartar vigilante, presión de memoria y detenciones deliberadas, que se parecen a fallos y no lo son.

⚔️ Del informe a la línea
  1. Provoca un fallo deliberado en una compilación de publicación con desempaquetado forzado, recoge el .ips del dispositivo y comprueba que llega sin símbolos.
  2. Localiza el UUID que espera el informe, verifica que coincide con el de tu dSYM y resuelve dos direcciones con atos hasta obtener archivo y línea.
  3. Escribe un guion que archive el dSYM de cada versión publicada con su número de compilación y que falle si el identificador no coincide con el del binario.
  4. Toma tres grupos del panel de organización y comprueba, informe a informe, si de verdad cuentan la misma historia o si el agrupador ha mezclado causas distintas.
  5. Reproduce una terminación por vigilante bloqueando el hilo principal en el arranque y compara su informe con el del fallo del punto uno, anotando qué campos permiten distinguirlos.