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
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é
reasonexiste déjà et sa lecture vautundefined, donc tester la présence de la propriété ne prouve jamais qu’une annulation a eu lieu. - Appeler
abort()sans argument stocke unDOMExceptiondont le constructeur estDOMExceptionet dont lenameestAbortError, avec le messageThis 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: passer42laisse le signal annulé avec un nombre, et les lectures suivantes dereason.messageoureason.namene trouvent rien. AbortSignal.timeout(15)démarre sans annulation et, quand la minuterie se déclenche, rapporte unDOMExceptionnomméTimeoutError, pasAbortError.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.onabortnon plus, alors quesignal.abortedvaut déjàtrue. - L’événement d’annulation est un
Eventsimple sans champreason, donc le gestionnaire doit liresignal.reasonplutô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
- Ce que contient la raison avant et après une annulation
- Comment timeout et any choisissent une seule raison
- Combien de fois l’événement d’annulation se déclenche
- Pourquoi throwIfAborted relance le même objet
- Consigner une lecture d’annulation
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 :
- 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.
- Le constructeur et le nom de la raison, parce qu’
AbortErroretTimeoutErrorpartagent un constructeur et ne diffèrent que par le nom. - 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.
- 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
- MDN : AbortController
- MDN : AbortSignal
- MDN : AbortSignal.any()
- MDN : AbortSignal.timeout()
- WHATWG DOM : annulation d’un signal
- BotBrowser : référence de cohérence d’automatisation
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.
Cet article fait partie du guide : Qu'est-ce qu'une empreinte de navigateur ?
Articles Connexes
Les pièges Proxy et les invariants que le moteur impose
EmpreinteÉvénements de viewport sur mobile : ordre, regroupement, mise en page
EmpreintePromise : ce qui échoue d’abord
EmpreinteMicrotâches et minuteries : l’ordre réel du code
EmpreinteWeakMap et WeakSet : garder et lâcher des clés
EmpreinteTrous de tableau creux et ce que fait chaque méthode
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.