Razón de aborto, composición y oyentes tardíos
Una sonda sobre razones de aborto: cuál es la razón por defecto, cómo timeout y any eligen una, cuántas veces se dispara el evento y por qué un oyente tardío no corre.
BotBrowser Team
Quieres la documentación estructurada de Huella digital?
Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.
BotBrowser permite reproducir un fixture del mismo origen autorizado bajo un perfil declarado y guardar un registro acotado de qué rutas de cancelación alcanza realmente una página, pero no puede impedir que se omita un oyente añadido después del aborto, no puede convertir una razón que no es un Error en un Error, ni puede hacer que dos señales compuestas informen dos razones. Esta revisión ejecuta una sonda sobre doce casos fijos y registra qué contiene signal.reason antes y después de un aborto, qué nombre de DOMException produce cada fuente incorporada, cómo AbortSignal.timeout() y AbortSignal.any() eligen entre varias causas, cuántas veces se dispara el evento cuando se llama dos veces a abort(), qué ocurre con un oyente añadido a posteriori y la regla de identidad que hay detrás de throwIfAborted().
Resumen
- Antes de cualquier aborto la propiedad
reasonya existe y leerla devuelveundefined, así que comprobar que la propiedad está presente nunca demuestra que hubo un aborto. - Llamar a
abort()sin argumentos guarda unDOMExceptioncuyo constructor esDOMExceptiony cuyonameesAbortError, con el mensajeThis operation was aborted. - Una razón explícita se conserva por identidad, así que el objeto que pasó quien cancela es el mismo objeto que recibe un bloque catch.
- Una razón no tiene que ser un
Error: pasar42deja la señal abortada con un número, y las lecturas posteriores dereason.messageoreason.nameno encuentran nada. AbortSignal.timeout(15)empieza sin abortar y, cuando vence el temporizador, informa unDOMExceptionllamadoTimeoutError, noAbortError.AbortSignal.any([a, b])reenvía sin cambios la razón del miembro ganador, y gana el miembro que aborta primero sin importar su posición en el arreglo.- Un compuesto construido con un miembro ya abortado queda abortado durante la propia llamada a
any(), porque los miembros se inspeccionan de forma síncrona. - Dos llamadas a
abort()sobre un controlador producen exactamente un evento de aborto y conservan la primera razón. - Un oyente añadido después del aborto nunca corre, y
signal.onaborttampoco, aunquesignal.abortedya seatrue. - El evento de aborto es un
Eventplano sin camporeason, así que el manejador debe leersignal.reasonen lugar de su argumento. throwIfAborted()relanza la razón guardada por identidad, lo que significa que el objeto de error de quien cancela llega intacto al bloque catch del llamador.
Contenido
- Qué contiene la razón antes y después de un aborto
- Cómo timeout y any eligen una sola razón
- Cuántas veces se dispara el evento de aborto
- Por qué throwIfAborted relanza el mismo objeto
- Registrar una lectura de aborto
Qué contiene la razón antes y después de un aborto
La cancelación en la plataforma web es una señal que lleva un valor, y ese valor es lo único que un consumidor puede inspeccionar después de los hechos. La sonda empieza mirando un controlador recién creado:
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 primera línea es la que se suele errar. Un controlador que nunca se abortó ya tiene una propiedad reason, y el hasReason=true de la sonda sale de una prueba simple 'reason' in signal. La propiedad forma parte de la interfaz, no es un marcador de cancelación, así que un ayudante que decide si tomar un atajo comprobando la propiedad siempre tomará el atajo. Los dos campos que responden la pregunta son signal.aborted y, si la razón importa, signal.reason !== undefined.
La razón por defecto es un DOMException. La sonda imprime las dos partes de su identidad: DOMException:AbortError. Esa separación conviene retenerla, porque un ayudante de registro que discrimina por error.constructor.name agrupará un aborto junto a cualquier otra excepción de la plataforma, mientras que un ayudante que discrimina por error.name lo separa. Ninguna de las dos cadenas dice nada del código que canceló, y el mensaje This operation was aborted es la redacción de la plataforma, no una descripción del trabajo detenido.
Pasar una razón cambia lo que ve el consumidor, no la regla de almacenamiento. La sonda aborta con un objeto simple y después compara el valor guardado con el original usando igualdad estricta, y la comparación da same=true. Nada se copia, envuelve, clona ni serializa al entrar. Eso importa por dos motivos. Primero, un consumidor puede leer campos que él mismo definió, como un identificador de petición o un contador de reintentos, directamente desde signal.reason. Segundo, mutar el objeto después del aborto es visible para todo lector posterior, porque hay exactamente un objeto, y un consumidor que intente congelar la razón después está cambiando un valor compartido en lugar de proteger una copia.
La interfaz también acepta valores que no son errores. La sonda aborta con el número 42 y la señal informa aborted=true con una razón number:42. Un manejador de cancelación escrito para errores leerá entonces reason.name y obtendrá undefined, y un manejador que formatee reason.message.length lanzará un TypeError desde dentro de su propia ruta de error, que es el peor lugar para un segundo fallo. La forma defensiva es un pequeño normalizador al inicio del manejador: usar la razón cuando es un Error y, en caso contrario, construir uno que lleve el valor en un campo.
AbortSignal.abort() acorta toda la secuencia. La sonda la llama con la cadena pre-aborted y encuentra una señal ya abortada al construirse, con esa cadena como razón. Este constructor estático es la única forma de obtener una señal abortada sin un controlador intermedio, y es la opción natural para una función que quiere devolver un resultado cancelado para indicar «no continúes» sin asignar un controlador que nunca volverá a usar.
El evento de aborto no lleva nada. La sonda registra un oyente, aborta y pregunta al manejador qué clase de objeto recibió; el tipo impreso es Event. No hay campo reason en ese objeto, así que el fragmento habitual signal.addEventListener('abort', event => log(event.reason)) imprime undefined mientras la razón está a una propiedad de distancia en la señal. Leer signal.reason dentro del manejador también funciona cuando la razón llega después de registrar el oyente, que es el caso que importa cuando el aborto y el manejador viven en módulos distintos. La razón vive en la señal y no en el evento por la misma razón por la que una promesa rechazada lleva su error en la promesa, y modos de fallo de los combinadores de promesas muestra cómo all, allSettled y any informan qué miembro falló.
Cómo timeout y any eligen una sola razón
La plataforma incluye dos fuentes compuestas, y cada una responde una pregunta distinta. AbortSignal.timeout() responde «después de un plazo» y AbortSignal.any() responde «lo que ocurriera primero de estas». Ambas producen una señal con la misma forma que cualquier otra, y ambas eligen una sola razón en lugar de fusionar.
AbortSignal.timeout(15) empieza con aborted=false, que la sonda registra antes de esperar. Cuando vence el temporizador, la razón es un DOMException cuyo nombre es TimeoutError y cuyo mensaje es The operation was aborted due to timeout. Ese nombre es distinto del AbortError simple, y la diferencia es la única forma estructural de separar un plazo de una cancelación deliberada en una señal que no creaste tú. Una política de reintentos que trata AbortError como definitivo y TimeoutError como reintentable depende por completo de esta comprobación de nombre, así que un manejador que solo compara con AbortError trata un plazo agotado como una parada permanente sin avisar.
La duración también queda incrustada en el texto del mensaje, y un ayudante que intente extraerla encontrará la redacción de la plataforma en lugar de un número limpio. Una página que necesite saber qué plazo venció debe mantener su propia tabla indexada por el valor que pasó, porque la plataforma no devuelve el retraso como campo.
AbortSignal.any() toma un iterable de señales y devuelve una señal que se aborta cuando se aborta el primer miembro. La sonda crea dos controladores, los compone, aborta el primero con un error con nombre, después aborta el segundo, y compara la razón del compuesto con el primer error usando igualdad estricta; el resultado es same=true. La composición reenvía una razón por identidad e ignora cualquier miembro posterior, así que un consumidor que quiera saber qué fuente disparó tiene que adjuntar esa información a la razón antes de pasarla, normalmente envolviendo la razón de cada miembro en un objeto con una etiqueta de origen. Una vez que el compuesto se abortó, los abortos posteriores de otros miembros no son observables a través de él.
La sonda también muestra que la composición es inmediata en lugar de perezosa. Construir un compuesto con una señal que ya estaba abortada y otra que sigue viva produce una señal que queda abortada durante la propia llamada a any(), con la razón already tomada del miembro muerto. No hay ventana en la que la señal devuelta esté viva. Una función que compone la señal del llamador con un plazo nuevo tiene que manejar el caso en que la señal del llamador ya estaba muerta, y no puede suponer que la rama del plazo se alcanzará alguna vez.
El caso de carrera fija la ambigüedad restante. La sonda aborta el segundo controlador primero y el primero después, luego lee la razón del compuesto, y el ganador impreso es la segunda fuente. El orden que decide es el de las llamadas a abort(), no el del arreglo, lo que significa que una página que liste un plazo primero y una cancelación del usuario segundo sigue informando la cancelación del usuario cuando el usuario actúa primero. Ese es el comportamiento que casi todos los llamadores quieren, y vale la pena decirlo porque una implementación que recorriera el arreglo y prefiriera el miembro más temprano se vería idéntica en toda prueba con una sola fuente.
Combinar las dos fuentes es donde los nombres rinden. AbortSignal.any([AbortSignal.timeout(5000), userSignal]) produce una señal que informa TimeoutError cuando pasa el plazo y la razón propia del llamador cuando el llamador cancela. Un único bloque catch que lea reason.name puede entonces separar un plazo de una cancelación sin llevar una bandera, y una cancelación que pase un Error con un código conserva ese código, porque la composición preservó el objeto en lugar de reemplazarlo por una excepción de la plataforma.
Cuántas veces se dispara el evento de aborto
El controlador es un dispositivo de un solo uso, y la sonda precisa qué significa eso tanto para el evento como para la razón guardada. Llama a abort() dos veces sobre el mismo controlador con dos valores distintos y cuenta los eventos de aborto; el conteo impreso es events=1, y la razón retenida es el primer valor. Así que abort() es idempotente respecto del evento, y la primera razón es definitiva. Una página que llama a abort() desde un manejador de plazo y desde una ruta de limpieza no necesita su propia guarda para evitar una entrega doble, aunque una guarda sigue valiendo cuando la segunda llamada haría trabajo real antes de llegar a abort().
El caso del oyente tardío es el borde más afilado de toda la interfaz. La sonda aborta primero, después registra un oyente, después asigna también signal.onabort, y luego espera una macrotarea completa. La línea impresa es late-listener-after-task -> not-yet onabort=false aborted=true. Ninguna de las dos formas corrió. Esperar más no ayudaría, porque el evento ya se despachó durante la llamada anterior a abort() y no hay repetición para un suscriptor posterior. Un ayudante que oculte la señal tras una interfaz y registre su oyente dentro de la función que la consume tiene por tanto un agujero silencioso: si el aborto ocurrió antes de llamar al ayudante, el ayudante espera para siempre.
La forma correcta comprueba primero la bandera. Un consumidor que quiera correr una vez en cualquiera de las dos ramas lee signal.aborted, maneja el caso muerto de inmediato y solo después registra un oyente para el caso vivo, con { once: true } para que el manejador se quite tras el único despacho. La propia espera de la sonda por la razón de plazo usa exactamente ese patrón, y es la razón por la que el caso de plazo tiene que mantener vivo un temporizador aparte: el aborto llega de una fuente que la sonda no controla, así que el oyente es la única forma de observarlo.
Hay una segunda consecuencia más sutil. Como un oyente registrado antes del aborto corre durante la llamada a abort(), el código que sigue a abort() en la misma pila ya ve el efecto del manejador. Eso hace de abort() un aviso síncrono para los suscriptores existentes y una operación vacía para todos los demás, una combinación que se comporta distinto de un emisor de eventos que almacena. Una prueba que aborta y luego afirma de inmediato que se llamó a un espía lo ve, y la misma prueba escrita contra un emisor asíncrono necesitaría esperar. Esa entrega síncrona es la misma frontera de turno que describe microtareas y temporizadores, donde una retrollamada encolada como microtarea corre antes de la siguiente tarea pero un oyente llamado durante el despacho corre antes que ambas.
Por qué throwIfAborted relanza el mismo objeto
La señal ofrece una guarda que convierte un estado abortado en un valor lanzado, y la sonda comprueba qué sale exactamente. Aborta con un error con nombre, llama a throwIfAborted() y compara el valor capturado con el original usando igualdad estricta. El resultado impreso es same:Error:Error(propagated), así que la guarda relanza la razón guardada en lugar de un envoltorio nuevo. El bloque catch del llamador recibe por tanto el objeto propio de quien cancela, con los campos que se le adjuntaron, que es la forma más barata de llevar un identificador de petición o un código de motivo a través de fronteras de módulos.
La guarda no hace nada mientras la señal está viva, así que puede situarse al inicio de cada paso que está a punto de trabajar. Esa colocación es lo que hace inmediata la cancelación: un bucle que llama a la guarda antes de cada iteración se detiene en la primera iteración que corre después del aborto, en lugar de en la siguiente frontera con await. Dentro de una función async el lanzamiento síncrono se convierte automáticamente en una promesa rechazada, que suele ser la forma que espera un llamador, y en una función normal se propaga como excepción normal.
Lo que una comprobación no debe suponer es que el valor lanzado tenga forma de error. El último caso de la sonda aborta con el número 42 y confirma que aborted es true y la razón es un number. Un bloque catch que registre error.name registrará undefined, y un bloque que llame a error.message.trim() lanzará mientras maneja una cancelación. La regla que sobrevive a todos los casos observados es: comprobar signal.aborted, tratar reason como un valor desconocido y solo reducir a Error después de comprobarlo. Ese orden también mantiene honesta una señal compuesta, porque una razón de plazo es un DOMException y una razón de cancelación puede ser cualquier cosa.
Registrar una lectura de aborto
Una lectura de aborto sostiene una afirmación acotada: este tiempo de ejecución, esta fuente, esta razón, produjeron estas observaciones. Cuatro campos la mantienen verificable:
- El tipo de fuente, porque un controlador simple, un aborto estático, un plazo y un compuesto eligen su razón cada uno por una regla distinta.
- El constructor y el nombre de la razón, porque
AbortErroryTimeoutErrorcomparten constructor y difieren solo en el nombre. - El resultado de identidad, porque un consumidor que compara el valor capturado con el objeto de quien cancela depende de un reenvío y no de una copia.
- Si el oyente se registró antes o después del aborto, porque un oyente tardío se omite sin importar cuánto espere.
Fuentes públicas
- MDN: AbortController
- MDN: AbortSignal
- MDN: AbortSignal.any()
- MDN: AbortSignal.timeout()
- WHATWG DOM: aborting a signal
- BotBrowser: referencia de consistencia de automatización
Conclusión
Trata la razón como el único valor que lleva una cancelación y trata la señal como una bandera de un solo disparo: lee signal.aborted antes de registrar un oyente, nombra la fuente cuando un registro describa una cancelación, y compara contra reason.name en lugar del constructor de la razón cuando haya que separar un plazo de una cancelación manual. Para el lado de planificación de tareas del mismo mecanismo, prioridades del planificador y trabajo posterior muestra cómo un testigo de cancelación cambia el orden en que se retiran los trabajos en cola, y para el lado de recursos, protección de puertos usa una señal de plazo acotada para que una sonda local no se cuelgue.
Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?
Artículos Relacionados
Trampas de proxy y las invariantes que impone el motor
Huella digitalEventos de cambio de viewport en móvil: orden y medida
Huella digitalCombinadores Promise: qué falla y qué nunca se asienta
Huella digitalMicrotareas y temporizadores: el orden real de tu código
Huella digitalWeakMap y WeakSet: qué guardan y qué sueltan
Huella digitalHuecos en arrays dispersos y qué hace cada método
Lleva BotBrowser de la investigación a producción
Usa estas guías para entender el modelo y después avanzar hacia validación multiplataforma, contextos aislados y despliegue de navegador preparado para escalar.