Video Summary and Transcription
La charla trata sobre un error grave que causó 20,000 bloqueos en una aplicación de JS. El error fue una excepción de índice fuera de límites en la clase SimplePool. El equipo utilizó un depurador para analizar el error y descubrió una condición de carrera causada por una actualización de React Native SVG. Colaboraron con los contribuyentes de React Native para solucionar el problema y desplegaron una versión parcheada. La charla enfatiza la importancia de utilizar una herramienta de informes de bloqueos, monitorear la salud de las versiones y aprender de los errores y el análisis del código fuente.
1. The Story of the Vicious Bug
Hoy les contaré una historia, la historia de un error y nuestra lucha contra este error. Un error tan cruel y despiadado que causó nada menos que 20,000 bloqueos. Nuestra tasa de bloqueo aumentó significativamente y nuestra herramienta de informes de bloqueos está reportando una excepción cada minuto. Es un error de excepción de argumento ilegal en una aplicación JS mientras se actualiza una propiedad de estilo en un nodo de sombra de un componente React Native. Esto ocurre para cada usuario, en cada dispositivo Android, y todos los dispositivos Android se ven afectados.
Native. Y nuestra historia comienza en octubre. Somos un equipo de nueve personas y estamos muy felices y orgullosos de lanzar la versión 4.3 de nuestra aplicación. ¿Por qué estamos tan felices y orgullosos? Bueno, porque nos estábamos preparando para nuestro evento en vivo del 11 de octubre que la aplicación estaba cubriendo, y estábamos agregando muchas características esenciales a la aplicación. ¡Genial! Estamos súper felices.
Pero luego, sucede lo inesperado. De repente, nuestra tasa de bloqueo aumenta significativamente. Nuestra herramienta de informes de bloqueos que estamos utilizando, Sentry, está bajo fuego intenso. Está reportando una excepción cada minuto, luego muchas excepciones cada minuto. Básicamente, es una excepción cada segundo y se está volviendo abrumador. Todas esas excepciones son un poco diferentes, pero todas tienen la misma forma. Son así. Básicamente, es un error de excepción de argumento ilegal en una aplicación JS mientras se actualiza una propiedad de estilo en un nodo de sombra de un componente react Native.
Entonces, bueno, el primer pensamiento es, bueno, ya sabes, probamos esta versión, lo probamos mucho. ¿Por qué no vimos que esto estaba sucediendo? Y también, si buscas un poco más sobre este error, tiende a ocurrir si estableces un valor incorrecto para un estilo. Por ejemplo, si establezco el relleno superior como NAN, no es un número, esto es lo que ocurriría. Parece algo bastante fácil de detectar. Entonces, bueno, tal vez solo sucede en ciertos casos extremos que no hemos probado adecuadamente antes. Pero resulta que Sentry está informando que ocurre para cada usuario, cada dispositivo Android, por lo que esto es solo un problema de Android, pero todos los dispositivos Android se ven afectados. Y también en nuestra aplicación puedes favorecer al equipo, por ejemplo, para cambiar un poco la experiencia de la aplicación. Pero no importa qué equipo estés favoreciendo, esto no tiene impacto. Sigues obteniendo el bloqueo.
2. Analyzing the Crash and Reproduction Attempts
Tenemos un gran bloqueo al iniciar que afecta a cualquier dispositivo y usuario. No pudimos reproducirlo, así que analizamos la traza de la pila y encontramos una excepción de índice fuera de límites en la clase SimplePool. Retroceder la versión no era una opción, ya que tenía un alto valor para los usuarios. Con una tasa de bloqueo del 10%, intentamos reproducir el problema en varios dispositivos pero no obtuvimos ningún bloqueo.
De acuerdo. Bueno, tenemos un gran bloqueo, tenemos un gran problema que resolver, así que empecemos intentando reproducir el bloqueo, ¿de acuerdo? Afortunadamente, configuramos Sentry o una herramienta de informes de bloqueos para que nos diga qué estaba haciendo el usuario antes de desencadenar el bloqueo. Aquí vemos que el usuario está abriendo la aplicación, iniciando la primera pantalla de la aplicación, que se llama Inicio. Y boom, se bloquea instantáneamente.
De acuerdo, básicamente me estás diciendo que afecta a cualquier dispositivo, se bloquea al iniciar, afecta a cualquier usuario y no podemos reproducirlo. Nunca lo hemos visto antes, ¿cómo es eso posible?
De acuerdo, bueno, supongo que el segundo paso, si no puedes reproducirlo realmente, es analizar la traza de la pila. Así que echemos un vistazo. Vale, dije que tenemos varios errores diferentes. Supongo que veamos el primero. Este es una excepción de índice fuera de límites. Es un error de Java. Ocurre en la clase llamada SimplePool y es una clase de la biblioteca de soporte de Android v4. Y ocurre en SimplePool.release, en la línea 116 de pools.java. Para ser honesto, en este punto ni siquiera sé qué es SimplePool. Ni siquiera sé por qué estoy en el código fuente de Android. Hay un gran problema que resolver y parece que va a llevar mucho tiempo entender qué está pasando porque realmente no entiendo esto. Así que supongo que busquemos una solución más fácil para resolver el problema.
Una idea sería, bueno, ¿podríamos simplemente retroceder nuestra versión? Bueno, si eres un desarrollador de aplicaciones móviles, sabes que no podemos retroceder realmente la versión. En realidad, tenemos que implementar una nueva versión con el código antiguo. Es un poco molesto y significa que ciertos usuarios, ya sabes, los usuarios obtendrán una actualización de la aplicación que revierte todo. En este momento, sabemos que nuestra tasa de bloqueo es del 10%. Parece que básicamente un usuario que abre la aplicación tiene una probabilidad de 1 de cada 10 de bloquear la aplicación. Pero parece que cuando intentan restaurarla, funciona. Además, esta versión tiene un gran valor para los usuarios. Resultó ser una de las versiones con mejor calificación a pesar de este bloqueo excepcional. Así que pensamos, bueno, no retrocedamos. No es el fin del mundo. Es extremadamente grande tener una tasa de bloqueo del 10%, pero intentemos solucionarlo de otra manera. De acuerdo, sabemos que la tasa de bloqueo es del 10%, así que estoy pensando, bien, puedo idear un plan de batalla. Voy a tomar seis dispositivos Android, voy a desencadenar con un script 10 lanzamientos de la aplicación por dispositivo, así que estadísticamente debería obtener entre cinco y diez bloqueos, ¿verdad? Y al menos eso sería algún tipo de reproducción. Finalmente podría ver el problema y si obtengo una solución, podría probarla. El resultado fue que no obtuve ningún bloqueo.
3. Investigando las Dependencias Nativas y las Pruebas
Nuestra versión anterior no se bloqueaba, esta versión se bloquea. Actualizamos dos dependencias nativas: React Native SVG y navegación nativa. Sospechamos que la navegación nativa es la culpable de los bloqueos. Podemos lanzar una nueva versión al 10% de nuestros usuarios para probar si la nueva versión soluciona el bloqueo. Si no lo hace, podemos retroceder la biblioteca SVG. Si ninguna de las soluciones soluciona el bloqueo, podría llevar a desinstalaciones potenciales.
Ninguna en absoluto. Bastante desafortunado. Bueno, supongo que necesitamos encontrar algo más. Otra idea fue qué cambió realmente. Nuestra versión anterior no se bloqueaba, esta versión se bloquea. Entonces, ¿qué introdujimos entre las dos versiones que realmente bloqueó la aplicación?
Entonces, mi idea en este punto fue echar un vistazo a las dependencias nativas que actualizamos. Porque bueno, esta es una excepción de Java, por lo que ocurre en el código nativo, por lo que probablemente el culpable sea una dependencia nativa que actualizamos. Resulta que actualizamos dos dependencias nativas desde la última versión. La primera fue React Native SVG, y la segunda fue la navegación nativa. Probablemente no conozcas la navegación nativa. En realidad, es un fork que hicimos de una biblioteca de navegación de Airbnb, que utiliza, bueno, navegación nativa. Resulta que nosotros mismos agregamos algunas características para mejorar el rendimiento al inicio, ¿verdad? Suena como un buen culpable, ya sabes, lo actualizamos para mejorar el rendimiento al inicio, obtenemos bloqueos al inicio. Vale, parece que este debería ser el culpable detrás de nuestros bloqueos.
Como sabrás, en Play Store, puedes lanzar una nueva versión de tu aplicación solo para un subconjunto de tus usuarios. Por ejemplo, puedes lanzar la nueva versión solo para el 10% de tus usuarios. Esto nos permite idear un nuevo plan de batalla. Si la navegación nativa es realmente la culpable, podemos probarlo. No creamos la navegación nativa. Lanzamos una nueva versión que lanzamos solo para el 10% de nuestros usuarios. Volvemos a verificar, deberíamos poder ver en aproximadamente una hora si la nueva versión es exitosa. Y si tiene éxito, luego la lanzamos para todos, la nueva versión porque, bueno, el bloqueo está solucionado, ¡yay! Pero, ¿qué pasa si en realidad no soluciona el bloqueo? Bueno, supongo que en este caso, retrocedamos la otra, la biblioteca SVG, y bueno, hacemos lo mismo. Lanzamos para el 10% de nuestros usuarios. Volvemos a verificar. Si es exitoso, yay, lanzamiento completo, ok, genial, ganamos. Pero, ¿qué pasa si nuevamente eso no soluciona el bloqueo? Esto significaría que si aún no soluciona el bloqueo, significaría que actualizamos dos veces nuestra aplicación y cada vez, cada vez, el 10% de nuestros usuarios obtuvo una actualización que en realidad no hizo nada y no solucionó el bloqueo. Eso es realmente una fuente de desinstalación potencial, como cuando un usuario recibe muchas actualizaciones de su aplicación pero no hace nada por él. A veces sucede que un usuario desinstala la aplicación debido a esto. Para ser honesto, ese plan es sí, es un poco tonto.
4. Analyzing the Bug and Using the Debugger
Nuestro error fue una excepción de índice fuera de límites en la clase SimplePool. Intentamos acceder a un array en el índice mPoolSize, que era -1. El único lugar donde mPoolSize cambia es en la función acquire, y está protegido para que no sea inferior a cero. Decidimos utilizar el depurador para investigar más a fondo.
De acuerdo. Supongo que en este punto, sí, necesitamos ir más profundo. Realmente necesitamos entender el error y analizarlo. Así que echemos otro vistazo. Nuestro error, como recordarás, fue una excepción de índice fuera de límites en un array. De acuerdo. Veamos dónde estaba ocurriendo. Estaba ocurriendo, como quizás recuerdes, en una clase llamada SimplePool dentro del código de la biblioteca de soporte Android v4. Y básicamente el error fue este. Tenemos un array de objetos llamado mPool y tenemos un índice llamado mPoolSize y estamos intentando acceder a este array en el índice mPoolSize que aparentemente es igual a menos uno. Así que ahora no necesitas ser un experto desarrollador de Java para saber que acceder a un array en el índice menos uno no es una buena idea. Así que puedes entender de dónde viene el bloqueo. El valor de mPoolSize es menos uno, lo cual no es bueno. Entonces la pregunta ahora es qué puede modificar realmente mPoolSize. mPoolSize solo se modifica en este lugar. Se inicializa en diez y luego solo se reduce en esta función llamada acquire dentro de SimplePool. Este es el único lugar donde mPoolSize realmente cambia en esta función y se reduce pero puedes notar algo allí, en realidad hay una condición para protegerlo de ser inferior a cero. Aquí está, si mPoolSize es mayor que cero, entonces se reduce mPoolSize. Así que suena imposible que mPoolSize se convierta en menos uno porque si mPoolSize es cero, no puedes reducirlo aún más, así que eso realmente suena imposible.
5. Analyzing the Bug with the Debugger
Utilizamos el depurador en Android Studio para analizar la función acquire en el código de React Native. Descubrimos que la función dynamic de map create podía ser llamada desde diferentes hilos, lo que provocaba una condición de carrera. La actualización de SVG causó el error.
De acuerdo, supongo que es hora de sacar nuestra arma definitiva y, por supuesto, estoy hablando del depurador, el arma definitiva contra los errores. Así que bien, abramos Android Studio y veamos la famosa función que en realidad disminuye mPoolSize, que es la función acquire, y en las trazas de pila que estábamos viendo, veíamos que esta función era llamada desde el código de React Native en la clase llamada dynamic de map y la función create. Exactamente esta línea, así que, bien, pongamos un punto de interrupción ahí. La primera vez que se activó el punto de interrupción, básicamente ejecuté la aplicación y bueno, llegué al primer punto de interrupción bastante rápido y me está diciendo básicamente que, en realidad, dynamic de map es utilizado por React Native para almacenar propiedades de estilo de componente. Aquí estamos actualizando el ancho de un cierto componente. Así que puedes imaginar que esto va a suceder mucho porque, básicamente, cada vez que modificamos un estilo, llegamos al punto de interrupción. Así que sí, el segundo punto de interrupción fue bastante similar y en realidad, sí, hice clic básicamente 34 veces en el botón de reproducción y obtuve resultados similares pero, oh, en realidad, una revelación en el clic número 34 porque desde el primer clic hasta el 33, estoy obteniendo algo como esto, en el clic 34, estoy obteniendo algo como esto. Hay una diferencia muy sutil en esos puntos de interrupción porque desde el primer clic hasta el 33, los hilos que Android Studio estaba reportando son MQT native modules y bueno, este es el hilo que que React Native suele usar para trabajar con código nativo a través del puente.
6. Analyzing the Bug and Fixing the Issue
En el hito 34, se utilizó el hilo principal, lo que dio una gran pista. La función dynamic de map create puede ser llamada desde diferentes hilos. El error fue causado por la actualización de SVG, donde ocurrió una condición imposible debido a la seguridad del hilo. Lo solucionamos colaborando con los contribuyentes de React Native y desplegando una versión parcheada para el 10% de nuestros usuarios. Lección aprendida: Utiliza tu herramienta de informes de errores de manera exhaustiva y configúrala para capturar las acciones del usuario.
Pero en el hito 34, el hilo que se utilizó fue el hilo principal, por lo que básicamente esto significa que en este caso no estaba activando el error, pero esto me dio una pista muy importante. Esta función dynamic de map create podría ser llamada desde diferentes hilos. Si observamos el hito 34, en realidad notamos que en este caso la propiedad que se estaba actualizando era una propiedad llamada fill, y bueno, esto realmente no suena como una propiedad de estilo de React Native, ¿verdad? De hecho, es una propiedad de SVG. Así que fue la actualización de SVG la que causó este error. Veamos qué puede suceder.
Entonces, React Native SVG, lo actualizamos a la versión 7 y comenzaron a utilizar este código dynamic de app create para mejorar el rendimiento de las animaciones SVG nativas. Pero lo estaban utilizando desde el hilo principal mientras que React Native lo estaba utilizando desde los módulos nativos MQT. Entonces, ¿qué puede suceder en realidad? Esta condición imposible, bueno, cuando tienes algo imposible sucediendo en Java, generalmente es debido a la seguridad del hilo. Como desarrolladores de JavaScript, no estamos acostumbrados a lidiar con múltiples hilos, pero cuando usas React Native, también tienes Java en la mezcla. Así que tienes seguridad de hilos en la mezcla. Aquí, esta condición imposible, podría suceder que dos hilos, hilo A y hilo B, pudieran ir prácticamente al mismo tiempo en la condición si amplesize es mayor que cero y pensar que es mayor que cero, y luego ambos ingresan a la condición al mismo tiempo, lo que significa que ambos lo disminuyen. Es algo así. El hilo A ve que amplesize es mayor que cero, genial, pero no tiene tiempo para disminuirlo aún, no tiene tiempo para salir de la función porque el hilo B también está ingresando a la condición y verificando que amplesize es mayor que cero. Y si al principio amplesize es uno, entonces vuelve a ser uno cuando verificamos la condición para el hilo B. Y luego, lo que sucede es que ambos disminuyen amplesize, por lo que se convierte en cero y luego en menos uno. ¡Vaya, realmente sabemos de dónde viene esto y por eso fue tan difícil de reproducir, porque esta es una condición de carrera que fue muy difícil de activar! Así que arreglemos esto.
Cuando investigamos, encontramos que había una solicitud de extracción en React Native que trataba esto, tratando la seguridad del hilo en dynamic de map create. Y así, en colaboración con el contribuyente principal de React Native que presentó la solicitud de extracción y los mantenedores de React Native SVG, ideamos un plan de batalla final. Parcheamos React Native localmente, desplegamos esta versión para el 10% de nuestros usuarios solo para verificar, y luego, por supuesto, verificamos nuevamente. ¿Fue exitoso? Sí. Finalmente. Lo arreglamos y nuestra tasa de fallos volvió a la normalidad. ¡Hurra! Muy bien. Esto fue fantástico. Pero tal vez algunas lecciones aprendidas de esto. La primera es esta. Debes utilizar tu herramienta de informes de fallos de manera exhaustiva y configurarla para poder utilizarla, porque vas a tener fallos en producción y probablemente vas a tener fallos que no puedes reproducir. Por lo tanto, debes saber qué está haciendo el usuario antes de activar el fallo. Por defecto, es posible que no tengas esto en tu herramienta de informes de fallos, así que debes configurarlo para que sea fácil ver, por ejemplo, las pantallas a las que está navegando tu usuario.
7. Detalles del Usuario, Salud de la Versión y Aprendizaje
Debes agregar detalles sobre el usuario, monitorear la salud de la versión y proteger a tus usuarios. Implementar versiones a un 10% de los usuarios permite monitorear y minimizar el impacto. Profundizar en los errores y el código fuente brinda valiosas experiencias de aprendizaje.
También debes agregar tantos detalles sobre el usuario como sea posible, por supuesto, de manera compatible con el RGPD. Por ejemplo, en nuestro caso, agregar qué equipos el usuario estaba marcando como favoritos para cambiar su experiencia, porque a veces activas errores solo en ciertos casos en tu aplicación, por supuesto.
Luego, por supuesto, debes monitorear la salud de tu versión. Una tasa de fallos del 10%, por supuesto, es excepcional. Es realmente, realmente malo. Una tasa de fallos del 0.2% es un poco mejor. El estándar del mercado es de aproximadamente 0.3, 0.4 para Android. Es aún más bajo para iOS.
Y si realmente haces eso, también te permite hacer una cosa, proteger a tus usuarios. Y eso es lo que hicimos después de esto. Cada vez que implementamos una nueva versión, en realidad la lanzamos para el 10% de nuestros usuarios. Por supuesto, nunca deberíamos tener fallos, fallos excepcionales como este en esos 10%, pero en caso de que realmente suceda, al menos solo afectamos al 10% de nuestros usuarios. Entonces, el resto de los usuarios no tienen impacto. Y, por supuesto, eso significa que puedes saber si la versión fue exitosa, por lo que puedes monitorear la salud de tu versión.
Y, por supuesto, tienes tiempo entre la implementación inicial y, por ejemplo, en nuestro caso, tuvimos el evento en vivo el 11 de octubre. Hicimos la implementación el 9 de octubre. No fue realmente una buena idea.
El último punto es este. Puedes aprender mucho profundizando. Nunca he aprendido tantas cosas como cuando estaba investigando un error que no podía reproducir, y me sumergí más profundamente en el código fuente de las bibliotecas que estaba usando, y cada vez, aprendí mucho.
Y eso es todo. Gracias por ver, y contáctame si tienes alguna pregunta en el canal de Discord o en Twitter. Gracias.
Comments