Retour au Centre de connaissances
Empreinte

Raison d’annulation, composition et écouteurs tardifs

Une sonde sur les raisons d’annulation : la raison par défaut, le choix fait par timeout et any, le nombre de déclenchements et l’écouteur tardif ignoré.

BotBrowser Team

Documentation

Vous voulez la documentation structurée pour Empreinte ?

Cet article fait partie de la bibliothèque éditoriale. Pour les étapes de configuration, la référence et les mises à jour continues, passez directement à la section docs.

BotBrowser permet de rejouer un fixture de même origine autorisé sous un profil déclaré et de garder une trace bornée des chemins d’annulation qu’une page atteint réellement, mais il ne peut pas faire exécuter un écouteur ajouté après l’annulation, ne peut pas transformer une raison qui n’est pas un Error en Error, ni faire rapporter deux raisons à un signal composé. Cette revue exécute une sonde sur douze cas fixes et consigne ce que contient signal.reason avant et après une annulation, quel nom de DOMException produit chaque source intégrée, comment AbortSignal.timeout() et AbortSignal.any() choisissent parmi plusieurs causes, combien de fois l’événement se déclenche quand abort() est appelé deux fois, ce qui arrive à un écouteur ajouté après coup, et la règle d’identité derrière throwIfAborted().

Résumé

  • Avant toute annulation, la propriété reason existe déjà et sa lecture vaut undefined, donc tester la présence de la propriété ne prouve jamais qu’une annulation a eu lieu.
  • Appeler abort() sans argument stocke un DOMException dont le constructeur est DOMException et dont le name est AbortError, avec le message This operation was aborted.
  • Une raison explicite est conservée par identité, donc l’objet passé par celui qui annule est le même objet qu’un bloc catch reçoit.
  • Une raison n’a pas besoin d’être un Error : passer 42 laisse le signal annulé avec un nombre, et les lectures suivantes de reason.message ou reason.name ne trouvent rien.
  • AbortSignal.timeout(15) démarre sans annulation et, quand la minuterie se déclenche, rapporte un DOMException nommé TimeoutError, pas AbortError.
  • AbortSignal.any([a, b]) transmet sans changement la raison du membre gagnant, et gagne le membre qui annule en premier quelle que soit sa position dans le tableau.
  • Un composite construit avec un membre déjà annulé est annulé pendant l’appel même à any(), parce que les membres sont inspectés de façon synchrone.
  • Deux appels à abort() sur un contrôleur produisent exactement un événement d’annulation et conservent la première raison.
  • Un écouteur ajouté après l’annulation ne s’exécute jamais, et signal.onabort non plus, alors que signal.aborted vaut déjà true.
  • L’événement d’annulation est un Event simple sans champ reason, donc le gestionnaire doit lire signal.reason plutôt que son argument.
  • throwIfAborted() relance la raison stockée par identité, ce qui signifie que l’objet d’erreur de celui qui annule arrive intact au bloc catch de l’appelant.

Sommaire

Le premier panneau montre un AbortController dont la raison vaut undefined avant l’annulation puis un DOMException nommé AbortError après. Le deuxième montre un objet de raison explicite conservé par identité et une raison numérique 42. Le troisième montre AbortSignal.timeout rapportant TimeoutError et AbortSignal.any transmettant la raison de la première source. Le quatrième montre deux appels à abort produisant un seul événement, un écouteur tardif qui ne s’exécute jamais et throwIfAborted relançant le même objet d’erreur.

Ce que contient la raison avant et après une annulation

L’annulation dans la plateforme web est un signal qui porte une valeur, et cette valeur est la seule chose qu’un consommateur peut inspecter après coup. La sonde commence par regarder un contrôleur neuf :

node=v26.9.0
initial -> aborted=false hasReason=true
default-reason -> DOMException:AbortError(This operation was aborted) message="This operation was aborted"
explicit-reason -> same=true object:[object Object]
static-abort -> aborted=true string:pre-aborted
timeout-reason -> DOMException:TimeoutError(The operation was aborted due to timeout) message="The operation was aborted due to timeout"
any-reason -> same=true Error:Error(first won)
any-preborted -> aborted=true string:already
fire-once -> events=1 reasonKept=number:1
late-listener-after-task -> not-yet onabort=false aborted=true
throwIfAborted -> same:Error:Error(propagated)
non-error-reason -> aborted=true number:42
event-type -> Event
race -> winner=string:second-source

La première ligne est celle que l’on rate souvent. Un contrôleur jamais annulé possède déjà une propriété reason, et le hasReason=true de la sonde vient d’un test simple 'reason' in signal. La propriété fait partie de l’interface, ce n’est pas un marqueur d’annulation, donc un utilitaire qui décide de prendre un raccourci en testant la propriété prendra toujours le raccourci. Les deux champs qui répondent à la question sont signal.aborted et, si la raison compte, signal.reason !== undefined.

La raison par défaut est un DOMException. La sonde imprime les deux parties de son identité : DOMException:AbortError. Cette séparation mérite d’être retenue, parce qu’un utilitaire de journalisation qui discrimine sur error.constructor.name rangera une annulation avec toute autre exception de la plateforme, alors qu’un utilitaire qui discrimine sur error.name la sépare. Aucune des deux chaînes ne dit quoi que ce soit du code qui a annulé, et le message This operation was aborted est la formulation de la plateforme, pas une description du travail arrêté.

Passer une raison change ce que voit le consommateur, pas la règle de stockage. La sonde annule avec un objet simple puis compare la valeur stockée à l’original avec une égalité stricte, et la comparaison donne same=true. Rien n’est copié, emballé, cloné ni sérialisé à l’entrée. Cela compte pour deux raisons. D’abord, un consommateur peut lire des champs qu’il a lui-même définis, comme un identifiant de requête ou un compteur de tentatives, directement depuis signal.reason. Ensuite, muter l’objet après l’annulation est visible pour tout lecteur ultérieur, parce qu’il n’y a qu’un seul objet, et un consommateur qui tente de figer la raison ensuite modifie une valeur partagée au lieu de protéger une copie.

L’interface accepte aussi des valeurs qui ne sont pas des erreurs. La sonde annule avec le nombre 42 et le signal rapporte aborted=true avec une raison number:42. Un gestionnaire d’annulation écrit pour des erreurs lira alors reason.name et obtiendra undefined, et un gestionnaire qui formate reason.message.length lèvera un TypeError depuis sa propre branche d’erreur, qui est le pire endroit pour un second échec. La forme défensive est un petit normaliseur en tête du gestionnaire : utiliser la raison quand c’est un Error et, sinon, en construire un qui porte la valeur dans un champ.

AbortSignal.abort() court-circuite toute la séquence. La sonde l’appelle avec la chaîne pre-aborted et trouve un signal déjà annulé à la construction, avec cette chaîne comme raison. Ce constructeur statique est le seul moyen d’obtenir un signal annulé sans contrôleur intermédiaire, et c’est le choix naturel pour une fonction qui veut renvoyer un résultat annulé afin de signaler « ne continue pas » sans allouer un contrôleur qu’elle n’utilisera plus jamais.

L’événement d’annulation ne porte rien. La sonde enregistre un écouteur, annule, puis demande au gestionnaire quel genre d’objet il a reçu ; le type imprimé est Event. Il n’y a pas de champ reason sur cet objet, donc le fragment habituel signal.addEventListener('abort', event => log(event.reason)) imprime undefined alors que la raison se trouve à une propriété de distance sur le signal. Lire signal.reason dans le gestionnaire fonctionne aussi quand la raison arrive après l’enregistrement de l’écouteur, ce qui est le cas qui compte quand l’annulation et le gestionnaire vivent dans des modules différents. La raison vit sur le signal et non sur l’événement pour la même raison qu’une promesse rejetée porte son erreur sur la promesse, et modes d’échec des combinateurs de promesses montre comment all, allSettled et any rapportent quel membre a échoué.

Comment timeout et any choisissent une seule raison

La plateforme fournit deux sources composites, et chacune répond à une question différente. AbortSignal.timeout() répond « après un délai » et AbortSignal.any() répond « ce qui survient en premier parmi celles-ci ». Les deux produisent un signal de forme identique à n’importe quel autre, et les deux choisissent une seule raison au lieu de fusionner.

AbortSignal.timeout(15) démarre avec aborted=false, que la sonde consigne avant d’attendre. Quand la minuterie se déclenche, la raison est un DOMException dont le nom est TimeoutError et dont le message est The operation was aborted due to timeout. Ce nom diffère de l’AbortError simple, et la différence est le seul moyen structurel de séparer un délai d’une annulation volontaire sur un signal que vous n’avez pas créé vous-même. Une politique de nouvelle tentative qui traite AbortError comme définitif et TimeoutError comme réessayable dépend entièrement de ce test de nom, donc un gestionnaire qui ne compare qu’à AbortError traite un délai dépassé comme un arrêt définitif sans le signaler.

La durée est aussi incrustée dans le texte du message, et un utilitaire qui tente de l’extraire trouvera la formulation de la plateforme plutôt qu’un nombre net. Une page qui doit savoir quel délai a expiré doit tenir sa propre table indexée par la valeur qu’elle a passée, parce que la plateforme ne renvoie pas le délai comme champ.

AbortSignal.any() prend un itérable de signaux et renvoie un signal qui s’annule quand le premier membre s’annule. La sonde crée deux contrôleurs, les compose, annule le premier avec une erreur nommée, puis annule le second, et compare la raison du composite à la première erreur avec une égalité stricte ; le résultat est same=true. La composition transmet une raison par identité et ignore tout membre ultérieur, donc un consommateur qui veut savoir quelle source a déclenché doit attacher cette information à la raison avant de la passer, généralement en enveloppant la raison de chaque membre dans un objet avec une étiquette de source. Une fois le composite annulé, les annulations ultérieures des autres membres ne sont plus observables à travers lui.

La sonde montre aussi que la composition est immédiate plutôt que paresseuse. Construire un composite avec un signal déjà annulé et un signal encore vivant produit un signal annulé pendant l’appel même à any(), avec la raison already prise au membre mort. Il n’y a aucune fenêtre où le signal renvoyé est vivant. Une fonction qui compose le signal de l’appelant avec un nouveau délai doit donc traiter le cas où le signal de l’appelant était déjà mort, et elle ne peut pas supposer que la branche du délai sera atteinte un jour.

Le cas de course fixe l’ambiguïté restante. La sonde annule le second contrôleur en premier et le premier ensuite, puis lit la raison du composite, et le gagnant imprimé est la seconde source. L’ordre qui décide est celui des appels à abort(), pas celui du tableau, ce qui signifie qu’une page qui liste un délai en premier et une annulation utilisateur ensuite rapporte quand même l’annulation utilisateur quand l’utilisateur agit d’abord. C’est le comportement que presque tous les appelants veulent, et cela vaut la peine d’être dit parce qu’une implémentation qui parcourrait le tableau et préférerait le membre le plus ancien serait identique dans tout test à source unique.

Combiner les deux sources est l’endroit où les noms paient. AbortSignal.any([AbortSignal.timeout(5000), userSignal]) produit un signal qui rapporte TimeoutError quand le délai passe et la raison propre de l’appelant quand il annule. Un seul bloc catch qui lit reason.name peut alors séparer un délai d’une annulation sans tenir un indicateur, et une annulation qui passe un Error avec un code conserve ce code, parce que la composition a préservé l’objet au lieu de le remplacer par une exception de la plateforme.

Combien de fois l’événement d’annulation se déclenche

Le contrôleur est un dispositif à usage unique, et la sonde précise ce que cela signifie pour l’événement comme pour la raison stockée. Elle appelle abort() deux fois sur le même contrôleur avec deux valeurs différentes et compte les événements d’annulation ; le compte imprimé est events=1, et la raison conservée est la première valeur. Donc abort() est idempotent vis-à-vis de l’événement, et la première raison est définitive. Une page qui appelle abort() depuis un gestionnaire de délai et depuis un chemin de nettoyage n’a pas besoin de son propre verrou pour éviter une double livraison, même si un verrou reste utile quand le second appel ferait un vrai travail avant d’atteindre abort().

Le cas de l’écouteur tardif est le bord le plus tranchant de toute l’interface. La sonde annule d’abord, enregistre ensuite un écouteur, assigne ensuite aussi signal.onabort, puis attend une macrotâche complète. La ligne imprimée est late-listener-after-task -> not-yet onabort=false aborted=true. Aucune des deux formes n’a tourné. Attendre plus longtemps n’aiderait pas, parce que l’événement a déjà été distribué pendant l’appel antérieur à abort() et il n’y a pas de rejeu pour un abonné ultérieur. Un utilitaire qui cache le signal derrière une interface et accroche son écouteur dans la fonction qui le consomme a donc un trou silencieux : si l’annulation a eu lieu avant l’appel à l’utilitaire, l’utilitaire attend indéfiniment.

La bonne forme teste le drapeau d’abord. Un consommateur qui veut s’exécuter une fois sur l’une ou l’autre branche lit signal.aborted, traite immédiatement le cas mort, puis seulement attache un écouteur pour le cas vivant, avec { once: true } pour que le gestionnaire soit retiré après la distribution unique. L’attente de la sonde pour la raison de délai utilise exactement ce motif, et c’est pourquoi le cas du délai doit garder une minuterie séparée en vie : l’annulation vient d’une source que la sonde ne contrôle pas, donc l’écouteur est le seul moyen de l’observer.

Il y a une seconde conséquence plus subtile. Comme un écouteur enregistré avant l’annulation s’exécute pendant l’appel à abort(), le code qui suit abort() sur la même pile voit déjà l’effet du gestionnaire. Cela fait d’abort() une diffusion synchrone pour les abonnés existants et une opération vide pour tous les autres, une combinaison qui se comporte différemment d’un émetteur d’événements qui tamponne. Un test qui annule puis affirme aussitôt qu’un espion a été appelé le voit, et le même test écrit contre un émetteur asynchrone devrait attendre. Cette livraison synchrone est la même frontière de tour que décrit microtâches et minuteries, où un rappel mis en file comme microtâche s’exécute avant la tâche suivante mais un écouteur appelé pendant la distribution s’exécute avant les deux.

Pourquoi throwIfAborted relance le même objet

Le signal offre une garde qui transforme un état annulé en valeur levée, et la sonde vérifie ce qui sort exactement. Elle annule avec une erreur nommée, appelle throwIfAborted() et compare la valeur capturée à l’original avec une égalité stricte. Le résultat imprimé est same:Error:Error(propagated), donc la garde relance la raison stockée plutôt qu’un nouvel emballage. Le bloc catch de l’appelant reçoit donc l’objet propre à celui qui annule, avec les champs qui y ont été attachés, ce qui est le moyen le moins coûteux de transporter un identifiant de requête ou un code de motif à travers les frontières de modules.

La garde ne fait rien tant que le signal est vivant, elle peut donc se placer en tête de chaque étape sur le point de travailler. Ce placement est ce qui rend l’annulation immédiate : une boucle qui appelle la garde avant chaque itération s’arrête à la première itération qui court après l’annulation, plutôt qu’à la prochaine frontière avec await. Dans une fonction async, la levée synchrone devient automatiquement une promesse rejetée, ce qui est en général la forme attendue par l’appelant, et dans une fonction simple elle se propage comme exception normale.

Ce qu’une vérification ne doit pas supposer, c’est que la valeur levée ait la forme d’une erreur. Le dernier cas de la sonde annule avec le nombre 42 et confirme que aborted vaut true et que la raison est un number. Un bloc catch qui journalise error.name journalisera undefined, et un bloc qui appelle error.message.trim() lèvera pendant qu’il traite une annulation. La règle qui survit à tous les cas observés est : tester signal.aborted, traiter reason comme une valeur inconnue, et ne réduire à Error qu’après vérification. Cet ordre garde aussi honnête un signal composite, parce qu’une raison de délai est un DOMException et qu’une raison d’annulation peut être n’importe quoi.

Consigner une lecture d’annulation

Une lecture d’annulation soutient une affirmation bornée : ce moteur, cette source, cette raison ont produit ces observations. Quatre champs la gardent vérifiable :

  1. Le type de source, parce qu’un contrôleur simple, une annulation statique, un délai et un composite choisissent chacun leur raison par une règle différente.
  2. Le constructeur et le nom de la raison, parce qu’AbortError et TimeoutError partagent un constructeur et ne diffèrent que par le nom.
  3. Le résultat d’identité, parce qu’un consommateur qui compare la valeur capturée à l’objet de celui qui annule dépend d’un transfert et non d’une copie.
  4. Si l’écouteur a été enregistré avant ou après l’annulation, parce qu’un écouteur tardif est ignoré quelle que soit la durée d’attente.

Sources publiques

Conclusion pratique

Traitez la raison comme la seule valeur qu’une annulation porte et traitez le signal comme un drapeau à usage unique : lisez signal.aborted avant d’attacher un écouteur, nommez la source quand une trace décrit une annulation, et comparez à reason.name plutôt qu’au constructeur de la raison quand il faut séparer un délai d’une annulation manuelle. Pour le versant ordonnancement du même mécanisme, priorités de l’ordonnanceur et travail post-tâche montre comment un jeton d’annulation change l’ordre dans lequel les travaux en file se retirent, et pour le versant ressources, protection des ports utilise un signal de délai borné pour qu’une sonde locale ne reste pas suspendue.

#AbortSignal#AbortController#Annulation#DOMException#Async

Cet article fait partie du guide : Qu'est-ce qu'une empreinte de navigateur ?

Faites passer BotBrowser de la recherche à la production

Utilisez ces guides pour comprendre le modèle, puis passez à la validation multi-plateforme, aux contextes isolés et au déploiement navigateur prêt pour l'échelle.