Captura la predicción en Herramientas para desarrolladores de Chrome: Por qué es difícil y cómo mejorarla

Eric Leese
Eric Leese

La depuración de excepciones en aplicaciones web parece simple: pausa la ejecución cuando algo sale mal y, luego, investiga. Sin embargo, la naturaleza asíncrona de JavaScript hace que esto sea sorprendentemente complejo. ¿Cómo puede Chrome DevTools saber cuándo y dónde detenerse cuando las excepciones pasan por promesas y funciones asíncronas?

En esta publicación, se analizan los desafíos de la predicción de captura, la capacidad de DevTools para anticipar si se capturará una excepción más adelante en tu código. Exploraremos por qué es tan complicado y cómo las mejoras recientes en V8 (el motor de JavaScript que potencia Chrome) lo hacen más preciso, lo que lleva a una experiencia de depuración más fluida.

Por qué es importante la predicción de capturas 

En Chrome DevTools, tienes la opción de pausar la ejecución de código solo para excepciones no detectadas y omitir las que se detecten. 

Chrome DevTools proporciona opciones independientes para pausar en excepciones detectadas o no detectadas.

En segundo plano, el depurador se detiene de inmediato cuando se produce una excepción para preservar el contexto. Es una predicción porque, en este momento, es imposible saber con certeza si la excepción se capturará o no más adelante en el código, especialmente en situaciones asíncronas. Esta incertidumbre proviene de la dificultad inherente de predecir el comportamiento de un programa, similar al problema de detención.

Considera el siguiente ejemplo: ¿dónde debería detenerse el depurador? (Busca una respuesta en la siguiente sección).

async function inner() {
  throw new Error(); // Should the debugger pause here?
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ... or should the debugger pause here?
  }
}

Hacer una pausa en las excepciones de un depurador puede ser disruptivo y provocar interrupciones frecuentes y saltos a código desconocido. Para mitigar esto, puedes elegir depurar solo las excepciones no detectadas, que tienen más probabilidades de indicar errores reales. Sin embargo, esto depende de la precisión de la predicción de capturas.

Las predicciones incorrectas generan frustración:

  • Falsos negativos (predecir "no detectado" cuando se detectará). Detenciones innecesarias en el depurador
  • Falsos positivos (predecir “detección” cuando no se detectará). Se pierden oportunidades para detectar errores críticos, lo que podría obligarte a depurar todas las excepciones, incluidas las esperadas.

Otro método para reducir las interrupciones de depuración es usar la lista de elementos ignorados, que evita las pausas en las excepciones dentro del código de terceros especificado.  Sin embargo, la predicción precisa de la captura sigue siendo fundamental. Si una excepción que se origina en el código de terceros escapa y afecta tu propio código, querrás poder depurarla.

Cómo funciona el código asíncrono

Las promesas, async y await, y otros patrones asíncronos pueden generar situaciones en las que una excepción o un rechazo, antes de que se manejen, pueden tomar una ruta de ejecución que es difícil de determinar en el momento en que se genera una excepción. Esto se debe a que es posible que no se esperen las promesas ni se agreguen controladores de captura hasta que ya haya ocurrido la excepción. Veamos nuestro ejemplo anterior:

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

En este ejemplo, outer() primero llama a inner(), que arroja una excepción de inmediato. A partir de esto, el depurador puede concluir que inner() mostrará una promesa rechazada, pero que, en este momento, no hay nada que esté esperando o administrando esa promesa. El depurador puede adivinar que outer() probablemente lo esperará y que lo hará en su bloque try actual y, por lo tanto, lo controlará, pero no puede estar seguro de esto hasta que se devuelva la promesa rechazada y se llegue a la sentencia await.

El depurador no puede ofrecer ninguna garantía de que las predicciones de captura sean precisas, pero usa una variedad de heurísticas para que los patrones de codificación comunes realicen predicciones correctamente. Para comprender estos patrones, es útil aprender cómo funcionan las promesas.

En V8, un Promise de JavaScript se representa como un objeto que puede estar en uno de tres estados: entregado, rechazado o pendiente. Si una promesa está en el estado de entrega y llamas al método .then(), se crea una nueva promesa pendiente y se programa una nueva tarea de reacción de promesa que ejecutará el controlador y, luego, establecerá la promesa como entregada con el resultado del controlador o la establecerá como rechazada si el controlador arroja una excepción. Lo mismo sucede si llamas al método .catch() en una promesa rechazada. Por el contrario, llamar a .then() en una promesa rechazada o a .catch() en una promesa completada mostrará una promesa en el mismo estado y no ejecutará el controlador. 

Una promesa pendiente contiene una lista de reacciones en la que cada objeto de reacción contiene un controlador de entrega o un controlador de rechazo (o ambos) y una promesa de reacción. Por lo tanto, llamar a .then() en una promesa pendiente agregará una reacción con un controlador entregado, así como una nueva promesa pendiente para la promesa de reacción, que mostrará .then(). Si llamas a .catch(), se agregará una reacción similar, pero con un controlador de rechazo. Llamar a .then() con dos argumentos crea una reacción con ambos controladores, y llamar a .finally() o esperar la promesa agregará una reacción con dos controladores que son funciones integradas específicas para implementar estas funciones.

Cuando la promesa pendiente se realice o rechace, se programarán trabajos de reacción para todos sus controladores de entrega o rechazo. Luego, se actualizarán las promesas de reacción correspondientes, lo que podría activar sus propios trabajos de reacción.

Ejemplos

Considera el siguiente código:

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Es posible que no sea obvio que este código involucra tres objetos Promise distintos. El código anterior es equivalente al siguiente:

const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;

En este ejemplo, se realizan los siguientes pasos:

  1. Se llama al constructor Promise.
  2. Se crea un nuevo Promise pendiente.
  3. Se ejecuta la función anónima.
  4. Se arroja una excepción. En este punto, el depurador debe decidir si detenerse o no.
  5. El constructor de promesas captura esta excepción y, luego, cambia el estado de su promesa a rejected con su valor establecido en el error que se arrojó. Muestra esta promesa, que se almacena en promise1.
  6. .then() no programa ninguna tarea de reacción porque promise1 está en el estado rejected. En su lugar, se muestra una promesa nueva (promise2), que también está en el estado rechazado con el mismo error.
  7. .catch() programa un trabajo de reacción con el controlador proporcionado y una nueva promesa de reacción pendiente, que se muestra como promise3. En este punto, el depurador sabe que se controlará el error.
  8. Cuando se ejecuta la tarea de reacción, el controlador se muestra de forma normal y el estado de promise3 cambia a fulfilled.

El siguiente ejemplo tiene una estructura similar, pero la ejecución es bastante diferente:

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Esto equivale a lo siguiente:

const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;

En este ejemplo, se realizan los siguientes pasos:

  1. Se crea un Promise en el estado fulfilled y se almacena en promise1.
  2. Se programa una tarea de reacción de promesa con la primera función anónima y su promesa de reacción (pending) se muestra como promise2.
  3. Se agrega una reacción a promise2 con un controlador entregado y su promesa de reacción, que se muestra como promise3.
  4. Se agrega una reacción a promise3 con un controlador rechazado y otra promesa de reacción, que se muestra como promise4.
  5. Se ejecuta la tarea de reacción programada en el paso 2.
  6. El controlador arroja una excepción. En este punto, el depurador debe decidir si detenerse o no. Actualmente, el controlador es tu único código JavaScript en ejecución.
  7. Como la tarea finaliza con una excepción, la promesa de reacción asociada (promise2) se establece en el estado rechazado con su valor establecido en el error que se generó.
  8. Debido a que promise2 tenía una reacción y esa reacción no tenía un controlador rechazado, su promesa de reacción (promise3) también se establece en rejected con el mismo error.
  9. Debido a que promise3 tenía una reacción, y esa reacción tenía un controlador rechazado, se programó una tarea de reacción de promesa con ese controlador y su promesa de reacción (promise4).
  10. Cuando se ejecuta esa tarea de reacción, el controlador se muestra de forma normal y el estado de promise4 cambia a completado.

Métodos para la predicción de captura

Existen dos fuentes potenciales de información para la predicción de capturas. Una es la pila de llamadas. Esto es adecuado para las excepciones síncronas: el depurador puede recorrer la pila de llamadas de la misma manera que lo hará el código de desenrollado de excepciones y se detiene si encuentra un marco en el que está en un bloque try...catch. En el caso de promesas o excepciones rechazadas en constructores de promesas o en funciones asíncronas que nunca se suspendieron, el depurador también se basa en la pila de llamadas, pero, en este caso, su predicción no puede ser confiable en todos los casos. Esto se debe a que, en lugar de generar una excepción al controlador más cercano, el código asíncrono mostrará una excepción rechazada, y el depurador debe hacer algunas suposiciones sobre lo que hará el llamador con ella.

Primero, el depurador supone que una función que recibe una promesa que se muestra es probable que muestre esa promesa o una promesa derivada para que las funciones asíncronas más arriba en la pila tengan la oportunidad de esperarla. En segundo lugar, el depurador supone que, si se muestra una promesa a una función asíncrona, pronto la esperará sin ingresar primero a un bloque try...catch ni salir de él. No se garantiza que ninguna de estas suposiciones sea correcta, pero son suficientes para realizar las predicciones correctas de los patrones de codificación más comunes con funciones asíncronas. En la versión 125 de Chrome, agregamos otra heurística: el depurador verifica si un llamador está a punto de llamar a .catch() en el valor que se mostrará (o .then() con dos argumentos, o una cadena de llamadas a .then() o .finally() seguida de un .catch() o un .then() de dos argumentos). En este caso, el depurador supone que estos son los métodos de la promesa a la que estamos haciendo un seguimiento o uno relacionado con ella, por lo que se detectará el rechazo.

La segunda fuente de información es el árbol de reacciones de promesas. El depurador comienza con una promesa raíz. A veces, esta es una promesa para la que se acaba de llamar a su método reject(). Más comúnmente, cuando se produce una excepción o un rechazo durante un trabajo de reacción de promesa y nada en la pila de llamadas parece detectarlo, el depurador rastrea desde la promesa asociada con la reacción. El depurador analiza todas las reacciones en la promesa pendiente y comprueba si tienen controladores de rechazo. Si alguna reacción no lo hace, observa la promesa de reacción y realiza un seguimiento de forma recursiva a partir de ella. Si todas las reacciones conducen a un controlador de rechazo, el depurador considera que se debe capturar el rechazo de la promesa. Hay algunos casos especiales que se deben abordar, por ejemplo, no contar el controlador de rechazo integrado para una llamada .finally().

El árbol de reacción de promesas proporciona una fuente de información generalmente confiable si la información está disponible. En algunos casos, como una llamada a Promise.reject() o en un constructor Promise o en una función asíncrona que aún no esperó nada, no habrá reacciones para rastrear y el depurador solo debe depender de la pila de llamadas. En otros casos, el árbol de reacción de promesa suele contener los controladores necesarios para inferir la predicción de captura, pero siempre es posible que se agreguen más controladores más adelante que cambien la excepción de capturada a no capturada o viceversa. También hay promesas como las que crea Promise.all/any/race, en las que otras promesas del grupo pueden afectar la forma en que se trata un rechazo. Para estos métodos, el depurador supone que se reenviará un rechazo de promesa si esta aún está pendiente.

Observa los siguientes dos ejemplos:

Dos ejemplos de predicción de capturas

Si bien estos dos ejemplos de excepciones detectadas se ven similares, requieren heurísticas de predicción de captura bastante diferentes. En el primer ejemplo, se crea una promesa resuelta, luego se programa un trabajo de reacción para .then() que arrojará una excepción y, luego, se llama a .catch() para adjuntar un controlador de rechazo a la promesa de reacción. Cuando se ejecute la tarea de reacción, se arrojará la excepción y el árbol de reacción de promesa contendrá el controlador de captura, por lo que se detectará como capturado. En el segundo ejemplo, la promesa se rechaza de inmediato antes de que se ejecute el código para agregar un controlador de captura, por lo que no hay controladores de rechazo en el árbol de reacción de la promesa. El depurador debe observar la pila de llamadas, pero tampoco hay bloques try...catch. Para predecir esto correctamente, el depurador escanea antes de la ubicación actual en el código para encontrar la llamada a .catch() y, en función de eso, supone que el rechazo se controlará en última instancia.

Resumen

Esperamos que esta explicación haya aclarado cómo funciona la predicción de captura en las Herramientas para desarrolladores de Chrome, sus fortalezas y sus limitaciones. Si tienes problemas de depuración debido a predicciones incorrectas, considera estas opciones:

  • Cambia el patrón de programación a algo más sencillo de predecir, como usar funciones asíncronas.
  • Selecciona la opción para hacer una pausa en todas las excepciones si DevTools no se detiene cuando debería.
  • Usa un punto de interrupción "Nunca pausar aquí" o un punto de interrupción condicional si el depurador se detiene en un lugar donde no quieres que lo haga.

Agradecimientos

Agradecemos de corazón a Sofia Emelianova y Jecelyn Yeen por su invaluable ayuda para editar esta publicación.