Назад в Центр знаний
Отпечатки

Причина отмены, композиция и поздние слушатели

Один зонд про причины отмены: причина по умолчанию, выбор timeout и any, число срабатываний события и поздний слушатель, который не запускается.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser позволяет воспроизвести авторизованный фикстур того же источника под объявленным профилем и сохранить ограниченную запись того, какие пути отмены действительно достигает страница, но он не может заставить запуститься слушатель, добавленный после отмены, не может переписать причину, которая не является Error, в Error, и не может заставить составной сигнал сообщать две причины. Этот разбор запускает один зонд по двенадцати фиксированным случаям и фиксирует, что содержит signal.reason до и после отмены, какое имя DOMException даёт каждый встроенный источник, как AbortSignal.timeout() и AbortSignal.any() выбирают среди нескольких причин, сколько раз срабатывает событие при двойном вызове abort(), что происходит со слушателем, добавленным задним числом, и правило идентичности за throwIfAborted().

Кратко

  • До любой отмены свойство reason уже существует, и его чтение даёт undefined, поэтому проверка наличия свойства никогда не доказывает, что отмена произошла.
  • Вызов abort() без аргумента сохраняет DOMException, у которого конструктор DOMException, а name равно AbortError, с сообщением This operation was aborted.
  • Явная причина сохраняется по идентичности, поэтому объект, переданный отменяющим кодом, это тот же объект, который получает блок catch.
  • Причина не обязана быть Error: передача 42 оставляет сигнал отменённым с числом, а последующие чтения reason.message или reason.name ничего не находят.
  • AbortSignal.timeout(15) начинает без отмены и, когда таймер срабатывает, сообщает DOMException с именем TimeoutError, а не AbortError.
  • AbortSignal.any([a, b]) передаёт причину победившего члена без изменений, и побеждает тот член, который отменяется первым, независимо от его позиции в массиве.
  • Составной сигнал, собранный из уже отменённого члена, отменяется во время самого вызова any(), потому что члены проверяются синхронно.
  • Два вызова abort() на одном контроллере дают ровно одно событие отмены и сохраняют первую причину.
  • Слушатель, добавленный после отмены, никогда не запускается, и signal.onabort тоже, хотя signal.aborted уже равно true.
  • Событие отмены это обычный Event без поля reason, поэтому обработчику нужно читать signal.reason, а не свой аргумент.
  • throwIfAborted() повторно выбрасывает сохранённую причину по идентичности, то есть объект ошибки отменяющего кода доходит до блока catch вызывающего без изменений.

Содержание

Первая панель показывает AbortController, у которого до отмены причина равна undefined, а после отмены это DOMException с именем AbortError. Вторая панель показывает явную причину в виде объекта, сохранённую по идентичности, и числовую причину 42. Третья показывает AbortSignal.timeout, сообщающий TimeoutError, и AbortSignal.any, передающий причину первого источника. Четвёртая показывает два вызова abort, дающих одно событие, поздний слушатель, который не запускается, и throwIfAborted, повторно выбрасывающий тот же объект ошибки.

Что содержит причина до и после отмены

Отмена в веб платформе это сигнал, который несёт одно значение, и это значение единственное, что потребитель может проверить после факта. Зонд начинает с нового контроллера:

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

Первая строка это то, что чаще всего понимают неверно. У контроллера, который никогда не отменяли, уже есть свойство reason, и hasReason=true у зонда получено простой проверкой 'reason' in signal. Свойство является частью интерфейса, а не признаком отмены, поэтому помощник, который решает, можно ли пойти по короткому пути, проверяя свойство, всегда пойдёт по короткому пути. Два поля, которые отвечают на вопрос, это signal.aborted и, если причина важна, signal.reason !== undefined.

Причина по умолчанию это DOMException. Зонд печатает обе части её идентичности: DOMException:AbortError. Это разделение стоит запомнить, потому что помощник журналирования, который различает по error.constructor.name, сгруппирует отмену вместе с любым другим исключением платформы, а помощник, который различает по error.name, отделит её. Ни одна из строк ничего не говорит о коде, который отменил, и сообщение This operation was aborted это формулировка платформы, а не описание остановленной работы.

Передача причины меняет то, что видит потребитель, но не правило хранения. Зонд отменяет простым объектом и затем сравнивает сохранённое значение с оригиналом строгим равенством, и сравнение даёт same=true. На входе ничего не копируется, не оборачивается, не клонируется и не сериализуется. Это важно по двум причинам. Во-первых, потребитель может читать поля, которые он определил сам, например идентификатор запроса или счётчик повторов, прямо из signal.reason. Во-вторых, изменение объекта после отмены видно каждому последующему читателю, потому что объект ровно один, и потребитель, который пытается заморозить причину задним числом, меняет общее значение, а не защищает копию.

Интерфейс принимает и значения, которые не являются ошибками. Зонд отменяет числом 42, и сигнал сообщает aborted=true с причиной number:42. Обработчик отмены, написанный под ошибки, прочитает тогда reason.name и получит undefined, а обработчик, который форматирует reason.message.length, выбросит TypeError изнутри собственного пути ошибки, что является худшим местом для второй неисправности. Защитная форма это небольшой нормализатор в начале обработчика: использовать причину, когда это Error, и иначе построить ошибку, которая несёт значение в поле.

AbortSignal.abort() сокращает всю последовательность. Зонд вызывает его со строкой pre-aborted и получает сигнал, уже отменённый при создании, с этой строкой в качестве причины. Этот статический конструктор единственный способ получить отменённый сигнал без промежуточного контроллера, и это естественный выбор для функции, которая хочет вернуть отменённый результат, чтобы сказать «не продолжай», не выделяя контроллер, который больше никогда не понадобится.

Само событие отмены ничего не несёт. Зонд регистрирует слушатель, отменяет и спрашивает у обработчика, объект какого вида он получил; печатается тип Event. Поля reason у этого объекта нет, поэтому обычный фрагмент signal.addEventListener('abort', event => log(event.reason)) печатает undefined, тогда как причина находится на расстоянии одного свойства на сигнале. Чтение signal.reason внутри обработчика работает и тогда, когда причина приходит после регистрации слушателя, а это и есть случай, который важен, когда отмена и обработчик живут в разных модулях. Причина живёт на сигнале, а не на событии, по той же причине, по которой отклонённый промис несёт свою ошибку на промисе, и режимы отказа комбинаторов промисов показывает, как all, allSettled и any сообщают, какой член отказал.

Как timeout и any выбирают одну причину

Платформа предоставляет два составных источника, и каждый отвечает на свой вопрос. AbortSignal.timeout() отвечает «после срока» и AbortSignal.any() отвечает «что случилось первым из этих». Оба дают сигнал той же формы, что и любой другой, и оба выбирают одну причину вместо слияния.

AbortSignal.timeout(15) начинает с aborted=false, что зонд фиксирует до ожидания. Когда таймер срабатывает, причина это DOMException с именем TimeoutError и сообщением The operation was aborted due to timeout. Это имя отличается от простого AbortError, и различие единственный структурный способ отделить срок от намеренной отмены на сигнале, который вы не создавали сами. Политика повторов, которая считает AbortError окончательным, а TimeoutError повторяемым, целиком зависит от этой проверки имени, поэтому обработчик, который сравнивает только с AbortError, молча считает истёкший срок постоянной остановкой.

Длительность также встроена в текст сообщения, и помощник, который пытается её извлечь, найдёт формулировку платформы, а не чистое число. Страница, которой нужно знать, какой срок истёк, должна держать собственную таблицу с ключом по переданному значению, потому что платформа не возвращает задержку отдельным полем.

AbortSignal.any() принимает итерируемое множество сигналов и возвращает сигнал, который отменяется при отмене первого члена. Зонд создаёт два контроллера, составляет их, отменяет первый именованной ошибкой, затем отменяет второй и сравнивает причину составного сигнала с первой ошибкой строгим равенством; результат same=true. Композиция передаёт одну причину по идентичности и игнорирует любой последующий член, поэтому потребитель, которому нужно знать, какой источник сработал, должен прикрепить эту информацию к причине до передачи, обычно оборачивая причину каждого члена в объект с меткой источника. После отмены составного сигнала последующие отмены других членов через него не наблюдаемы.

Зонд также показывает, что композиция немедленная, а не ленивая. Построение составного сигнала из уже отменённого члена и ещё живого даёт сигнал, отменённый во время самого вызова any(), с причиной already, взятой у мёртвого члена. Окна, в котором возвращённый сигнал жив, нет. Функция, которая составляет сигнал вызывающего с новым сроком, поэтому должна обрабатывать случай, когда сигнал вызывающего уже был мёртв, и не может предполагать, что ветвь срока когда-либо будет достигнута.

Случай гонки убирает оставшуюся неоднозначность. Зонд отменяет второй контроллер первым, а первый вторым, затем читает причину составного сигнала, и напечатанный победитель это второй источник. Порядок, который решает, это порядок вызовов abort(), а не порядок массива, что значит, что страница, которая перечисляет срок первым, а отмену пользователя вторым, всё равно сообщает отмену пользователя, когда пользователь действует первым. Это поведение нужно почти всем вызывающим, и о нём стоит сказать, потому что реализация, которая перебирала бы массив и предпочитала самый ранний член, выглядела бы одинаково в любом тесте с одним источником.

Сочетание двух источников это место, где имена окупаются. AbortSignal.any([AbortSignal.timeout(5000), userSignal]) даёт сигнал, который сообщает TimeoutError при истечении срока и собственную причину вызывающего при его отмене. Один блок catch, который читает reason.name, может тогда отделить срок от отмены без отдельного флага, а отмена, которая передаёт Error с кодом, сохраняет этот код, потому что композиция сохранила объект, а не заменила его исключением платформы.

Сколько раз срабатывает событие отмены

Контроллер это устройство однократного действия, и зонд уточняет, что это значит как для события, так и для сохранённой причины. Он вызывает abort() дважды на одном контроллере с двумя разными значениями и считает события отмены; напечатанный счётчик events=1, а сохранённая причина это первое значение. Значит abort() идемпотентен по отношению к событию, и первая причина окончательна. Страница, которая вызывает abort() и из обработчика срока, и из пути очистки, не нуждается в собственном флаге, чтобы избежать двойной доставки, хотя флаг всё ещё полезен, когда второй вызов сделает реальную работу до достижения abort().

Случай позднего слушателя это самый острый край всего интерфейса. Зонд отменяет сначала, затем регистрирует слушатель, затем также назначает signal.onabort, а затем ждёт полную макро задачу. Напечатанная строка late-listener-after-task -> not-yet onabort=false aborted=true. Ни одна из двух форм не запустилась. Ждать дольше не помогло бы, потому что событие уже было разослано во время более раннего вызова abort() и повтора для позднего подписчика нет. Помощник, который прячет сигнал за интерфейсом и прикрепляет свой слушатель внутри потребляющей функции, поэтому имеет тихую дыру: если отмена произошла до вызова помощника, помощник ждёт вечно.

Правильная форма сначала проверяет флаг. Потребитель, который хочет выполниться один раз в любой из двух ветвей, читает signal.aborted, сразу обрабатывает мёртвый случай и только затем прикрепляет слушатель для живого случая, с { once: true }, чтобы обработчик удалялся после единственной рассылки. Собственное ожидание зонда причины срока использует ровно этот приём, и именно поэтому случай срока должен держать отдельный таймер живым: отмена приходит из источника, который зонд не контролирует, поэтому слушатель единственный способ её наблюдать.

Есть и второе, более тонкое следствие. Поскольку слушатель, зарегистрированный до отмены, выполняется во время вызова abort(), код после abort() в том же стеке уже видит эффект обработчика. Это делает abort() синхронной рассылкой для существующих подписчиков и пустой операцией для всех остальных, сочетание, которое ведёт себя иначе, чем эмиттер событий с буфером. Тест, который отменяет и затем сразу утверждает, что шпион вызван, его видит, а тот же тест, написанный против асинхронного эмиттера, должен был бы ждать. Эта синхронная доставка это та же граница хода, что описана в микрозадачи и таймеры, где обратный вызов, поставленный микрозадачей, выполняется до следующей задачи, а слушатель, вызванный во время рассылки, выполняется раньше обоих.

Почему throwIfAborted выбрасывает тот же объект

Сигнал предоставляет проверку, которая превращает отменённое состояние в выброшенное значение, и зонд проверяет, что именно выходит. Он отменяет именованной ошибкой, вызывает throwIfAborted() и сравнивает перехваченное значение с оригиналом строгим равенством. Напечатанный результат same:Error:Error(propagated), поэтому проверка повторно выбрасывает сохранённую причину, а не новую обёртку. Блок catch вызывающего получает поэтому собственный объект отменяющего кода, с прикреплёнными полями, что является самым дешёвым способом пронести идентификатор запроса или код причины через границы модулей.

Проверка ничего не делает, пока сигнал жив, поэтому её можно ставить в начало каждого шага, который собирается работать. Это размещение и делает отмену немедленной: цикл, который вызывает проверку перед каждой итерацией, останавливается на первой итерации, которая выполняется после отмены, а не на следующей границе с await. Внутри функции async синхронный выброс автоматически становится отклонённым промисом, что обычно и есть форма, которую ждёт вызывающий, а в простой функции он распространяется как обычное исключение.

Чего проверка не должна предполагать, так это того, что выброшенное значение имеет форму ошибки. Последний случай зонда отменяет числом 42 и подтверждает, что aborted равно true, а причина это number. Блок catch, который журналирует error.name, запишет undefined, а блок, который вызывает error.message.trim(), выбросит во время обработки отмены. Правило, которое выдерживает все наблюдавшиеся случаи, таково: проверять signal.aborted, относиться к reason как к неизвестному значению и сужать до Error только после проверки. Этот порядок также держит составной сигнал честным, потому что причина срока это DOMException, а причина отмены может быть чем угодно.

Запись одного показания отмены

Показание отмены подтверждает узкое утверждение: эта среда, этот источник, эта причина дали эти наблюдения. Четыре поля держат его проверяемым:

  1. Тип источника, потому что простой контроллер, статическая отмена, срок и составной сигнал выбирают причину каждый по своему правилу.
  2. Конструктор и имя причины, потому что AbortError и TimeoutError делят конструктор и различаются только именем.
  3. Результат идентичности, потому что потребитель, который сравнивает перехваченное значение с объектом отменяющего кода, полагается на передачу, а не на копию.
  4. Был ли слушатель прикреплён до или после отмены, потому что поздний слушатель пропускается независимо от того, сколько он ждёт.

Публичные источники

Практический вывод

Относитесь к причине как к единственному значению, которое несёт отмена, и к сигналу как к флагу однократного действия: читайте signal.aborted до прикрепления слушателя, называйте источник, когда запись описывает отмену, и сравнивайте с reason.name, а не с конструктором причины, когда нужно отделить срок от ручной отмены. Для стороны планирования того же механизма приоритеты планировщика и работа после задачи показывает, как токен отмены меняет порядок вывода работ из очереди, а для стороны ресурсов защита портов использует ограниченный сигнал срока, чтобы локальный зонд не зависал.

#AbortSignal#AbortController#Отмена#DOMException#Async

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.