Capturer les prédictions dans les outils pour les développeurs Chrome: pourquoi c'est difficile et comment les améliorer

Eric Leese
Eric Leese

Le débogage des exceptions dans les applications Web semble simple: suspendez l'exécution en cas de problème et effectuez une analyse. Toutefois, la nature asynchrone de JavaScript rend cette tâche étonnamment complexe. Comment les outils pour les développeurs Chrome peuvent-ils savoir quand et où suspendre lorsque des exceptions traversent des promesses et des fonctions asynchrones ?

Cet article présente les défis de la prédiction de capture, c'est-à-dire la capacité de DevTools à anticiper si une exception sera détectée plus tard dans votre code. Nous verrons pourquoi c'est si difficile et comment les améliorations récentes apportées à V8 (le moteur JavaScript qui alimente Chrome) le rendent plus précis, ce qui permet une expérience de débogage plus fluide.

Pourquoi la prévision des prises est-elle importante ?

Dans les outils de développement Chrome, vous pouvez suspendre l'exécution du code uniquement pour les exceptions non détectées, en ignorant celles qui sont détectées. 

Les outils pour les développeurs Chrome proposent des options distinctes pour suspendre l'exécution en cas d'exception détectée ou non

En coulisses, le débogueur s'arrête immédiatement lorsqu'une exception se produit pour préserver le contexte. Il s'agit d'une prédiction, car, à ce stade, il est impossible de savoir avec certitude si l'exception sera interceptée ou non plus tard dans le code, en particulier dans les scénarios asynchrones. Cette incertitude découle de la difficulté inhérente à la prédiction du comportement d'un programme, semblable au problème d'arrêt.

Prenons l'exemple suivant: où le débogueur doit-il s'arrêter ? (Vous trouverez la réponse dans la section suivante.)

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?
  }
}

La mise en pause sur des exceptions dans un débogueur peut être perturbatrice et entraîner des interruptions fréquentes et des sauts vers du code inconnu. Pour atténuer ce problème, vous pouvez choisir de ne déboguer que les exceptions non détectées, qui sont plus susceptibles de signaler des bugs réels. Toutefois, cela dépend de la précision de la prédiction des prises.

Les prédictions incorrectes génèrent de la frustration:

  • Faux négatifs (prédiction de "non détecté" alors qu'il sera détecté) Arrêts inutiles dans le débogueur.
  • Faux positifs (prédire "attrapé" alors qu'il ne l'est pas) Vous avez manqué des occasions de détecter des erreurs critiques, ce qui vous obligera peut-être à déboguer toutes les exceptions, y compris celles attendues.

Une autre méthode permettant de réduire les interruptions de débogage consiste à utiliser la liste d'ignorement, qui empêche les interruptions en cas d'exceptions dans le code tiers spécifié.  Toutefois, une prévision précise des prises reste cruciale. Si une exception provenant d'un code tiers s'échappe et affecte votre propre code, vous devez pouvoir la déboguer.

Fonctionnement du code asynchrone

Les promesses, async et await, ainsi que d'autres modèles asynchrones, peuvent entraîner des scénarios dans lesquels une exception ou un refus, avant d'être gérés, peuvent emprunter un chemin d'exécution difficile à déterminer au moment où une exception est générée. En effet, les promesses ne peuvent pas être attendues ni des gestionnaires de capture ajoutés tant que l'exception n'est pas déjà survenue. Reprenons notre exemple précédent:

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

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

Dans cet exemple, outer() appelle d'abord inner(), qui génère immédiatement une exception. Le débogueur peut en conclure que inner() renverra une promesse refusée, mais qu'actuellement, rien n'attend ni ne gère cette promesse. Le débogueur peut deviner que outer() l'attendra probablement et qu'il le fera dans son bloc try actuel, et donc le gérer, mais il ne peut pas en être certain tant que la promesse refusée n'est pas renvoyée et que l'instruction await n'est pas finalement atteinte.

Le débogueur ne peut pas garantir que les prédictions de capture seront exactes, mais il utilise diverses heuristiques pour les modèles de codage courants afin de les prédire correctement. Pour comprendre ces modèles, il est utile de savoir comment fonctionnent les promesses.

Dans V8, un Promise JavaScript est représenté par un objet qui peut se trouver dans l'un des trois états suivants : "traité", "refusé" ou "en attente". Si une promesse est à l'état "réussie" et que vous appelez la méthode .then(), une nouvelle promesse en attente est créée et une nouvelle tâche de réaction de promesse est planifiée. Elle exécutera le gestionnaire, puis définira la promesse sur "réussie" avec le résultat du gestionnaire ou sur "refusée" si le gestionnaire génère une exception. Il en va de même si vous appelez la méthode .catch() sur une promesse refusée. À l'inverse, appeler .then() sur une promesse refusée ou .catch() sur une promesse remplie renvoie une promesse dans le même état et n'exécute pas le gestionnaire. 

Une promesse en attente contient une liste de réactions, où chaque objet de réaction contient un gestionnaire de traitement ou un gestionnaire de refus (ou les deux) et une promesse de réaction. Ainsi, appeler .then() sur une promesse en attente ajoutera une réaction avec un gestionnaire rempli, ainsi qu'une nouvelle promesse en attente pour la promesse de réaction, que .then() renverra. L'appel de .catch() ajoute une réaction similaire, mais avec un gestionnaire de refus. Appeler .then() avec deux arguments crée une réaction avec les deux gestionnaires. Appeler .finally() ou attendre la promesse ajoute une réaction avec deux gestionnaires qui sont des fonctions intégrées spécifiques à l'implémentation de ces fonctionnalités.

Lorsque la promesse en attente est finalement exécutée ou refusée, des tâches de réaction sont planifiées pour tous ses gestionnaires d'exécution ou tous ses gestionnaires de refus. Les promesses de réaction correspondantes seront ensuite mises à jour, ce qui peut déclencher leurs propres tâches de réaction.

Exemples

Prenons l'exemple de code suivant :

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

Il peut ne pas être évident que ce code implique trois objets Promise distincts. Le code ci-dessus équivaut au code suivant:

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

Dans cet exemple, les étapes suivantes se produisent:

  1. Le constructeur Promise est appelé.
  2. Un Promise en attente est créé.
  3. La fonction anonyme est exécutée.
  4. Une exception est générée. À ce stade, le débogueur doit décider s'il doit s'arrêter ou non.
  5. Le constructeur de promesse intercepte cette exception, puis définit l'état de sa promesse sur rejected, avec sa valeur définie sur l'erreur générée. Il renvoie cette promesse, qui est stockée dans promise1.
  6. .then() ne planifie aucune tâche de réaction, car promise1 est dans l'état rejected. À la place, une nouvelle promesse (promise2) est renvoyée, qui est également dans l'état "rejected" (refusé) avec la même erreur.
  7. .catch() planifie une tâche de réaction avec le gestionnaire fourni et une nouvelle promesse de réaction en attente, qui est renvoyée sous la forme promise3. À ce stade, le débogueur sait que l'erreur sera gérée.
  8. Lorsque la tâche de réaction s'exécute, le gestionnaire renvoie normalement et l'état de promise3 est remplacé par fulfilled.

L'exemple suivant présente une structure similaire, mais l'exécution est très différente:

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

Cela équivaut à:

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;

Dans cet exemple, les étapes suivantes se produisent:

  1. Un Promise est créé dans l'état fulfilled et stocké dans promise1.
  2. Une tâche de réaction de promesse est planifiée avec la première fonction anonyme, et sa promesse de réaction (pending) est renvoyée sous la forme promise2.
  3. Une réaction est ajoutée à promise2 avec un gestionnaire rempli et sa promesse de réaction, qui est renvoyée sous la forme promise3.
  4. Une réaction est ajoutée à promise3 avec un gestionnaire refusé et une autre promesse de réaction, qui est renvoyée sous la forme promise4.
  5. La tâche de réaction planifiée à l'étape 2 est exécutée.
  6. Le gestionnaire génère une exception. À ce stade, le débogueur doit décider s'il doit s'arrêter ou non. Actuellement, le gestionnaire est votre seul code JavaScript en cours d'exécution.
  7. Étant donné que la tâche se termine par une exception, la promesse de réaction associée (promise2) est définie sur l'état "rejected" (refusé), et sa valeur est définie sur l'erreur générée.
  8. Comme promise2 a eu une réaction et que cette réaction n'avait pas de gestionnaire refusé, sa promesse de réaction (promise3) est également définie sur rejected avec la même erreur.
  9. Comme promise3 a eu une réaction et que cette réaction avait un gestionnaire refusé, une tâche de réaction de promesse est planifiée avec ce gestionnaire et sa promesse de réaction (promise4).
  10. Lorsque cette tâche de réaction s'exécute, le gestionnaire renvoie normalement et l'état promise4 est remplacé par "accompli".

Méthodes de prédiction des prises

Il existe deux sources d'informations potentielles pour la prévision des prises. La pile d'appel en est un. Cela est correct pour les exceptions synchrones: le débogueur peut parcourir la pile d'appels de la même manière que le code de déroulement de l'exception et s'arrête s'il trouve un frame dans lequel il se trouve dans un bloc try...catch. Pour les promesses ou exceptions rejetées dans les constructeurs de promesses ou dans les fonctions asynchrones qui n'ont jamais été suspendues, le débogueur s'appuie également sur la pile d'appels. Toutefois, dans ce cas, sa prédiction ne peut pas être fiable dans tous les cas. En effet, au lieu de générer une exception au gestionnaire le plus proche, le code asynchrone renvoie une exception rejetée, et le débogueur doit faire quelques hypothèses sur ce que l'appelant en fera.

Tout d'abord, le débogueur suppose qu'une fonction qui reçoit une promesse renvoyée est susceptible de renvoyer cette promesse ou une promesse dérivée afin que les fonctions asynchrones plus haut dans la pile aient la possibilité de l'attendre. Deuxièmement, le débogueur suppose que si une promesse est renvoyée à une fonction asynchrone, elle sera bientôt attendue sans qu'il soit nécessaire d'entrer ou de quitter un bloc try...catch. Aucune de ces hypothèses n'est garantie d'être correcte, mais elles sont suffisantes pour effectuer les prédictions correctes pour les modèles de codage les plus courants avec des fonctions asynchrones. Dans la version 125 de Chrome, nous avons ajouté une autre heuristique: le débogueur vérifie si un appelé est sur le point d'appeler .catch() sur la valeur qui sera renvoyée (ou .then() avec deux arguments, ou une chaîne d'appels à .then() ou .finally() suivie d'un .catch() ou d'un .then() à deux arguments). Dans ce cas, le débogueur suppose qu'il s'agit des méthodes de la promesse que nous suivons ou d'une méthode qui y est associée. Le rejet sera donc détecté.

La deuxième source d'informations est l'arborescence des réactions de promesse. Le débogueur commence par une promesse racine. Il s'agit parfois d'une promesse dont la méthode reject() vient d'être appelée. Plus communément, lorsqu'une exception ou un refus se produit lors d'une tâche de réaction de promesse et qu'aucun élément de la pile d'appels ne semble la détecter, le débogueur effectue des traces à partir de la promesse associée à la réaction. Le débogueur examine toutes les réactions sur la promesse en attente et vérifie si elles disposent de gestionnaires de refus. Si certaines réactions ne le font pas, il examine la promesse de réaction et la trace de manière récursive. Si toutes les réactions mènent finalement à un gestionnaire de refus, le débogueur considère que le refus de la promesse est détecté. Il existe des cas particuliers à prendre en compte, par exemple, en ne comptabilisant pas le gestionnaire de refus intégré pour un appel .finally().

L'arborescence de réaction des promesses fournit généralement une source d'informations fiable, si les informations sont disponibles. Dans certains cas, comme un appel à Promise.reject(), dans un constructeur Promise ou dans une fonction asynchrone qui n'a encore rien attendu, il n'y aura aucune réaction à tracer, et le débogueur doit s'appuyer uniquement sur la pile d'appels. Dans d'autres cas, l'arbre de réaction de la promesse contient généralement les gestionnaires nécessaires pour inférer la prédiction de capture, mais il est toujours possible que d'autres gestionnaires soient ajoutés ultérieurement, ce qui changera l'exception de "capturée" à "non capturée" ou inversement. Il existe également des promesses comme celles créées par Promise.all/any/race, où d'autres promesses du groupe peuvent affecter le traitement d'un refus. Pour ces méthodes, le débogueur suppose qu'un refus de promesse sera transmis si la promesse est toujours en attente.

Examinez les deux exemples suivants:

Deux exemples de prédiction des prises

Bien que ces deux exemples d'exceptions détectées se ressemblent, ils nécessitent des heuristiques de prédiction de capture très différentes. Dans le premier exemple, une promesse résolue est créée, puis une tâche de réaction pour .then() est planifiée, ce qui génère une exception, puis .catch() est appelé pour associer un gestionnaire de refus à la promesse de réaction. Lorsque la tâche de réaction est exécutée, l'exception est générée et l'arborescence de réaction de la promesse contient le gestionnaire de capture. Elle sera donc détectée comme capturée. Dans le deuxième exemple, la promesse est immédiatement rejetée avant l'exécution du code permettant d'ajouter un gestionnaire de capture. Il n'y a donc pas de gestionnaires de rejet dans l'arborescence de réaction de la promesse. Le débogueur doit examiner la pile d'appels, mais il n'y a pas non plus de blocs try...catch. Pour effectuer une prédiction correcte, le débogueur analyse l'emplacement actuel du code avant de trouver l'appel à .catch(), et part du principe que le refus sera finalement géré.

Résumé

Nous espérons que cette explication vous aura éclairé sur le fonctionnement de la prédiction de capture dans les outils pour les développeurs Chrome, ses avantages et ses limites. Si vous rencontrez des problèmes de débogage en raison de prédictions incorrectes, envisagez les options suivantes:

  • Modifiez le modèle de codage pour qu'il soit plus facile à prévoir, par exemple en utilisant des fonctions asynchrones.
  • Sélectionnez l'option "Arrêter sur toutes les exceptions" si DevTools ne s'arrête pas comme prévu.
  • Utilisez un point d'arrêt "Ne jamais mettre en pause ici" ou un point d'arrêt conditionnel si le débogueur s'arrête à un endroit où vous ne le souhaitez pas.

Remerciements

Nous remercions chaleureusement Sofia Emelianova et Jecelyn Yeen pour leur aide précieuse lors de la rédaction de cet article.