Déboguer de l’asynchrone

Bon ! Si cette partie nous a appris quelque chose, c'est que l'asynchrone à base de callbacks tout nus, c'est pas facile et bourré de chausses-trappes.

Du coup, y'aura des bugs dans notre code. C'est la vie. Mais ce n'est pas une fatalité, et lorsque ça arrivera, aucune raison de vous limiter à des console.log() patauds pour trouver l'origine du problème.

Piles d’appels classiques ou « longues » ?

La première chose que vous voudrez sans doute identifier quand un code exécuté en asynchrone part en vrille, c'est l'origine de sa planification. Sa pile d'appels, en quelque sorte, mais pas juste celle qui l'exécute là tout de suite : celle qui a abouti à sa planification, et lui a fourni ses données de départ.

Le souci, c'est que les piles d'appels standard n'ont pas cette notion ; peu importe l'origine de planification de leur code, elles affichent la pile en vigueur au final.

v8 et Chrome ont commencé à garder trace des transitions asynchrones à chaque étape de planification d'un traitement dès 2014, avec l'apparition de la case à cocher “async” dans la vue Call Stack du débogueur JS de Chrome. Au moment où j'enregistre ceci, en mai 2019, c'est présent dans v8 et tout ce qui s'en sert (tous les projets à base de Chromium ou de Node.js), et dans JavaScriptCore, le moteur JS de Safari.

Ça change radicalement la donne. Voici un petit exemple historique de début 2014, quand Chrome a rendu cette fonctionnalité publique.

À gauche, la pile « réelle » d'appels au moment de l'exécution du callback de cet appel Ajax via jQuery. Notez comme elle est courte : elle démarre au niveau racine de traitement de réponse Ajax, qui est le niveau réseau, et c'est tout.

À droite, la même situation, mais avec les piles d'appels dites « longues » ou « asynchrones » activées. On y voit bien qu'on est arrivé à ce point du code, notre fonction postOnFail(), suite à un appel XHR fait au sein d'un appel de l'API Ajax de jQuery, depuis une fonction à nous appelée submitData(), laquelle était invoquée via un setTimeout() planifiée au sein de notre fonction retrySubmit(), elle-même apparemment appelée par une tentative antérieure de notre même fonction postOnFail().  On a beaucoup plus de billes pour comprendre le contexte.

Encore plus fort, si on clique sur n'importe quel niveau de cette pile, nous verrons dans le reste du débogueur l'état des portées au moment en question. On voyage dans le temps !

Depuis quelques années déjà, cette fonctionnalité n'est même plus une option à cocher, elle est activée par défaut. Les préférences des DevTools de Chrome permettent de la désactiver, mais je doute que quiconque s'handicape ainsi volontairement.

Dans Chrome 33+, Opera 15+, Edge 19+ et Electron

Côté navigateurs et logiciels desktop, beaucoup de choses sont basées sur v8 et disposent donc de cette fonctionnalité.

Elle est présente dans Chrome depuis la 33 (pour rappel, là tout de suite Chrome stable est en version 74, on a fait du chemin), Opera depuis la version 15, lorsqu'il a adopté le socle Chromium et le moteur Blink, et Edge dans sa version de développement actuelle, la 19, dont on ignore encore la date officielle de sortie stable, mais j'aime à croire que ce sera cette année. En effet, Edge vient lui-aussi d'adopter le socle Chromium comme base de travail, ce qui inclut le moteur JS v8. Le navigateur Brave, également basé Chromium, est aussi de la partie.

Exemple de code 18-debugging-async-browser.js

Voyons ce que ça donne dans ce Chrome. Ce code écoute un événement click sur le document, premier point d'asynchronie. Le gestionnaire d'événement fera un setTimeout(), deuxième transition asynchrone, lequel planifiera notre dernier callback grâce à un requestAnimationFrame(), 3e transition asynchrone. Que nous dit la pile d'appels une fois le point d'arrêt atteint ?

Eh oui, on voit bien ces trois transitions ! Fait intéressant, on les retrouve même dans les logs produits par console.trace() ou par un log point, ce qui est appréciable, surtout si on veut juste lister la pile sans interrompre l'exécution (démo).

(retour aux slides)

En pratique, une très large gamme de transitions asynchrones seront retranscrites dans les piles d'appels, qui couvrent à peu près tous les besoins imaginables, des timers aux événements en passant par tout ce qui touche aux entrées-sorties et couches de stockage.

Electron, un socle d'exécution pour applications desktop basé sur Chromium et v8, fournit naturellement la même chose. De nombreuses applications desktop très populaires sont basées sur Electron, telles que Slack, GitHub Desktop, VS Code, Microsoft Teams et bien d'autres.

Dans Firefox et Edge < 19

Côté Firefox, j'ai le regret de vous annoncer que non, même si leur débogueur JavaScript est excellent, en particulier pour la qualité d'exploitation des source maps, il ne fournit pas pour le moment de piles d'appels asynchrones, hélas.

Et pour Edge, dans ses versions publiques actuelles, basées sur leur moteur JavaScript appelé Chakra, ce n'est pas disponible non plus. Mais vous pouvez utiliser la version beta de la 19 en mode compatible et en bénéficier quand même !

Dans Safari

Safari ne s'en sort en revanche pas mal, puisqu'on y retrouve les piles d'appels asynchrones.

(démo du code 18 déjà vu, mais dans Safari)

Voyez plutôt.  Après, je n'ai pas trouvé l'info sur la liste des transitions asynchrones prises en charge, mais j'aime à croire qu'on est assez proches de celle gérée par Chromium.

En revanche, console.trace() n'inclut pas l'info, et on n'a pas la fonctionnalité de log points qu'on trouve sur les environnement basés sur v8.

Dans Node.js

Côté Node.js, sur des versions récentes c'est fourni par v8, comme pour les navigateurs. En pratique, c'est activé par défaut depuis Node 10, qui est l'actuelle version LTS, c'est-à-dire celle maintenue pendant 3 ans qui fait aujourd'hui référence, et sera maintenue jusqu’en avril 2021). En pratique, même si vous devez vous plier aux versions de Node prises en charge par votre hébergeur applicatif, vous avez aujourd'hui la garantie qu'au moins la version 10 est prise en charge, donc pas de souci.

Exemple de code 19-debugging-async-node.js

Voici un petit code Node similaire à celui que nous testions dans le navigateur. Ici, pas de requestAnimationFrame(), mais d'autres API asynchrones existent qui sont prises en charge, telles que process.nextTick() et setImmediate().

Le console.trace() de Node ne retranscrit pas pour le moment les transitions asynchrones, mais tout débogueur connecté à Node le fera. On peut par exemple utiliser un simple Chrome ou Brave pour déboguer notre instance de Node avec son débogueur actif (démo d'exécution). On a ici pas mal de bruit sur les rouages internes de Node, mais ça peut s'éliminer de façon persistente grâce au blackboxing, par exemple en ignorant tout ce qui démarre par /internals, puis au cas par cas les modules comme timers, _stream_readable et events, par exemple.

(retour aux slides)

Le 22 octobre 2019, la version 12, sortie en avril, deviendra elle-aussi une LTS active, maintenue jusqu'en avril 2022.  Elle fournit un drapeau de ligne de commande supplémentaire pour améliorer encore ces piles d'appels, mais pour le cas précis de la syntaxe async/await : nous y reviendrons dans la 3e partie de ce screencast.

Dans VS Code

Enfin, VS Code nous permet lui aussi de déboguer aussi bien du Node que des pages web directement depuis l'éditeur. Pour Node, c'est fourni de base, et pour les navigateurs il faut une extension.

Pour avoir les piles d'appels asynchrones dans VS Code, il faut qu'elles soient prises en charge par le moteur d'exécution visé, celui auquel l'éditeur se connecte. Ça ne va donc pas les ajouter par magie à Firefox ou Edge.

(retour au code 19, mais dans VS Code)

Dans la mesure où notre exemple a besoin de lire du texte sur le terminal, nous ne pouvons pas juste le lancer depuis Code, qui va court-circuiter ce flux entrant. Nous allons donc le lancer normalement, et utiliser le mode d'attachement à un processus Node en vigueur pour déboguer, préservant ainsi le terminal du processus.  C'est un besoin tout à fait spécifique à cet exemple, hein, vous n'en auriez pas besoin pour un serveur web par exemple.

Remarquez comme Code va auto-filtrer tous les modules internes à Node d'entrée de jeu, et mieux labelliser le process.nextTick(), par exemple.  La qualité de l'expérience de dev est ici supérieure à celle des navigateurs, sans parler du fait qu'on reste dans notre éditeur.