Отладка исключений в веб-приложениях кажется простой: приостановите выполнение, если что-то пойдет не так, и изучите ситуацию. Но асинхронная природа JavaScript делает эту задачу удивительно сложной. Как Chrome DevTools может узнать, когда и где делать паузу, когда исключения возникают из-за промисов и асинхронных функций?
В этом посте рассматриваются проблемы прогнозирования перехвата — способность DevTools предвидеть, будет ли исключение перехвачено позже в вашем коде. Мы выясним, почему это так сложно и как недавние улучшения в V8 (движок JavaScript, лежащий в основе Chrome) делают его более точным, что приводит к более плавной отладке.
Почему прогнозирование ловли имеет значение
В Chrome DevTools у вас есть возможность приостановить выполнение кода только для неперехваченных исключений, пропуская перехваченные.
За кулисами отладчик немедленно останавливается при возникновении исключения, чтобы сохранить контекст. Это прогноз , потому что в данный момент невозможно точно знать, будет ли исключение перехвачено или нет позже в коде, особенно в асинхронных сценариях. Эта неопределенность проистекает из неотъемлемой трудности прогнозирования поведения программы, аналогичной проблеме остановки .
Рассмотрим следующий пример: где должен остановиться отладчик? (Ищите ответ в следующем разделе.)
async function inner() {
throw new Error(); // Should the debugger pause here?
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ... or should the debugger pause here?
}
}
Приостановка обработки исключений в отладчике может привести к частым прерываниям работы и переходам к незнакомому коду. Чтобы смягчить это, вы можете выбрать отладку только неперехваченных исключений, которые с большей вероятностью будут сигнализировать о реальных ошибках. Однако это зависит от точности прогнозирования улова.
Неправильные прогнозы приводят к разочарованию:
- Ложноотрицательные результаты (предсказание «непойманности», когда она будет поймана) . Ненужные остановки в отладчике.
- Ложные срабатывания (предсказание «пойма», когда он не будет «пойман») . Упущенные возможности отловить критические ошибки, потенциально вынуждающие вас отлаживать все исключения, включая ожидаемые.
Другой метод уменьшения прерываний отладки — использование списка игнорирования , который предотвращает прерывания исключений в указанном стороннем коде. Однако точный прогноз улова здесь по-прежнему имеет решающее значение. Если исключение, возникшее в стороннем коде, ускользает и влияет на ваш собственный код, вам понадобится возможность его отладки.
Как работает асинхронный код
Промисы, async
и await
и другие асинхронные шаблоны могут привести к сценариям, в которых исключение или отклонение перед обработкой может пройти путь выполнения, который трудно определить в момент создания исключения. Это связано с тем, что обещания могут не ожидаться или не добавлять обработчики перехвата до тех пор, пока исключение уже не произошло. Давайте посмотрим на наш предыдущий пример:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
В этом примере outer()
сначала вызывает inner()
который немедленно генерирует исключение. Из этого отладчик может сделать вывод, что inner()
вернет отклоненное обещание, но в настоящее время ничего не ожидает или иным образом не обрабатывает это обещание. Отладчик может догадаться, что outer()
вероятно, будет ожидать его, и догадаться, что он сделает это в своем текущем блоке try
и, следовательно, обработает его, но отладчик не может быть уверен в этом до тех пор, пока не будет возвращено отклоненное обещание и не будет выполнен оператор await
. в итоге добрался.
Отладчик не может дать никаких гарантий, что прогнозы catch будут точными, но он использует различные эвристики для общих шаблонов кодирования для правильного прогнозирования. Чтобы понять эти закономерности, полезно узнать, как работают обещания.
В V8 Promise
JavaScript представлено как объект, который может находиться в одном из трех состояний: выполнено, отклонено или ожидается. Если обещание находится в состоянии выполнения и вы вызываете метод .then()
, создается новое ожидающее обещание и назначается новая задача реакции на обещание, которая запустит обработчик, а затем установит выполнение обещания с результатом обработчика. или установите для него значение «Отклонено», если обработчик выдает исключение. То же самое произойдет, если вы вызовете метод .catch()
для отклоненного обещания. Напротив, вызов .then()
для отклоненного обещания или .catch()
для выполненного обещания вернет обещание в том же состоянии и не запустит обработчик.
Ожидающее обещание содержит список реакций, в котором каждый объект реакции содержит обработчик выполнения или обработчик отклонения (или оба) и обещание реакции. Таким образом, вызов .then()
для ожидающего обещания добавит реакцию с выполненным обработчиком, а также новое ожидающее обещание для обещания реакции, которое .then()
вернет. Вызов .catch()
добавит аналогичную реакцию, но с обработчиком отклонения. Вызов .then()
с двумя аргументами создает реакцию с обоими обработчиками, а вызов .finally()
или ожидание обещания добавит реакцию с двумя обработчиками, которые являются встроенными функциями, специфичными для реализации этих функций.
Когда ожидающее обещание в конечном итоге будет выполнено или отклонено, задания реакции будут запланированы либо для всех его выполненных обработчиков, либо для всех его отклоненных обработчиков. Соответствующие обещания реагирования затем будут обновлены, что потенциально может вызвать собственные реакции.
Примеры
Рассмотрим следующий код:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Может быть неочевидно, что этот код включает в себя три различных объекта Promise
. Приведенный выше код эквивалентен следующему коду:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
В этом примере происходят следующие шаги:
- Вызывается конструктор
Promise
. - Создается новое ожидающее
Promise
. - Анонимная функция запускается.
- Выдается исключение. На этом этапе отладчик должен решить, остановиться или нет.
- Конструктор обещания перехватывает это исключение, а затем изменяет состояние своего обещания на
rejected
при этом его значение устанавливается на возникшую ошибку. Он возвращает это обещание, которое хранится вpromise1
. -
.then()
не планирует никакого задания реагирования, посколькуpromise1
находится в состоянииrejected
. Вместо этого возвращается новый промис (promise2
), который также находится в отклоненном состоянии с той же ошибкой. -
.catch()
планирует задание реакции с предоставленным обработчиком и новым ожидающим обещанием реакции, которое возвращается какpromise3
. На этом этапе отладчик знает, что ошибка будет обработана. - Когда задача реакции запускается, обработчик возвращается в обычном режиме, а состояние
promise3
меняется наfulfilled
.
Следующий пример имеет аналогичную структуру, но исполнение совершенно другое:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Это эквивалентно:
const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;
В этом примере происходят следующие шаги:
-
Promise
создается вfulfilled
состоянии и сохраняется вpromise1
. - Задача реакции на обещание запланирована с помощью первой анонимной функции, и ее
(pending)
обещание реакции возвращается какpromise2
. - Реакция добавляется к
promise2
с выполненным обработчиком и его обещанием реакции, которое возвращается какpromise3
. - Реакция добавляется к
promise3
с отклоненным обработчиком и другим обещанием реакции, которое возвращается какpromise4
. - Запускается задача реагирования, запланированная на шаге 2.
- Обработчик выдает исключение. На этом этапе отладчик должен решить, остановиться или нет. В настоящее время обработчик — это ваш единственный работающий код JavaScript.
- Поскольку задача завершается исключением, соответствующее обещание реакции (
promise2
) устанавливается в состояние отклонения, а его значение равно значению возникшей ошибки. - Поскольку у
promise2
была одна реакция, и эта реакция не имела обработчика отклонения, его обещание реакции (promise3
) также установлено какrejected
с той же ошибкой. - Поскольку у
promise3
была одна реакция, и эта реакция имела отклоненный обработчик, задача реакции на обещание запланирована с этим обработчиком и его обещанием реакции (promise4
). - Когда эта задача реакции запускается, обработчик возвращается в обычном режиме, и состояние
promise4
меняется на выполненное.
Методы прогнозирования улова
Существует два потенциальных источника информации для прогнозирования улова. Одним из них является стек вызовов. Это нормально для синхронных исключений: отладчик может обходить стек вызовов так же, как это делает код обработки исключений, и останавливается, если находит кадр, в котором он находится в блоке try...catch
. Для отклоненных обещаний или исключений в конструкторах обещаний или в асинхронных функциях, которые никогда не приостанавливались, отладчик также полагается на стек вызовов, но в этом случае его прогноз не может быть надежным во всех случаях. Это связано с тем, что вместо выдачи исключения ближайшему обработчику асинхронный код вернет отклоненное исключение, и отладчик должен сделать несколько предположений о том, что с ним будет делать вызывающая сторона.
Во-первых, отладчик предполагает, что функция, получившая возвращенное обещание, скорее всего, вернет это обещание или производное обещание, так что асинхронные функции, расположенные дальше по стеку, будут иметь возможность ожидать его. Во-вторых, отладчик предполагает, что если обещание будет возвращено асинхронной функции, она вскоре будет ожидать его, не входя и не выходя из блока try...catch
. Ни одно из этих предположений не является гарантированно правильным, но их достаточно, чтобы сделать правильные прогнозы для наиболее распространенных шаблонов кодирования с асинхронными функциями. В Chrome версии 125 мы добавили еще одну эвристику: отладчик проверяет, собирается ли вызываемый объект вызвать .catch()
для возвращаемого значения (или .then()
с двумя аргументами, или цепочку вызовов .then()
или .finally()
за которым следует .catch()
или .then()
с двумя аргументами). В этом случае отладчик предполагает, что это методы промиса, который мы отслеживаем, или методы, связанные с ним, поэтому отклонение будет перехвачено.
Второй источник информации — дерево реакций обещания. Отладчик начинается с корневого обещания. Иногда это промис, для которого только что был вызван метод reject()
. Чаще всего, когда во время задания реакции на обещание происходит исключение или отклонение, и в стеке вызовов нет ничего, что могло бы его перехватить, отладчик отслеживает обещание, связанное с реакцией. Отладчик просматривает все реакции на ожидающее обещание и проверяет, есть ли у них обработчики отклонения. Если каких-либо реакций нет, он смотрит на обещание реакции и рекурсивно отслеживает его. Если все реакции в конечном итоге приводят к обработчику отклонения, отладчик считает, что отклонение обещания перехвачено. Есть некоторые особые случаи, которые следует рассмотреть, например, не считая встроенного обработчика отклонения для вызова .finally()
.
Дерево реакций на обещания обычно обеспечивает надежный источник информации, если такая информация существует. В некоторых случаях, например, при вызове Promise.reject()
, в конструкторе Promise
или в асинхронной функции, которая еще ничего не ожидает, реакции на трассировку не будет, и отладчику придется полагаться только на стек вызовов. В других случаях дерево реакции обещания обычно содержит обработчики, необходимые для прогнозирования перехвата, но всегда возможно, что позже будут добавлены дополнительные обработчики, которые изменят исключение с перехваченного на неперехваченное или наоборот. Существуют также обещания, подобные тем, которые созданы Promise.all/any/race
, где другие обещания в группе могут влиять на то, как обрабатывается отказ. Для этих методов отладчик предполагает, что отказ от обещания будет перенаправлен, если обещание все еще находится на рассмотрении.
Взгляните на следующие два примера:
Хотя эти два примера перехваченных исключений выглядят одинаково, они требуют совершенно разных эвристик прогнозирования перехвата. В первом примере создается решенное обещание, затем запланировано задание реакции для .then()
, которое выдаст исключение, затем вызывается .catch()
, чтобы присоединить обработчик отклонения к обещанию реакции. При запуске задачи реагирования будет выброшено исключение, а дерево реакции обещания будет содержать обработчик catch, поэтому оно будет обнаружено как перехваченное. Во втором примере обещание немедленно отклоняется до запуска кода добавления обработчика перехвата, поэтому в дереве реакций обещания нет обработчиков отклонения. Отладчик должен просмотреть стек вызовов, но блоков try...catch
там тоже нет. Чтобы правильно предсказать это, отладчик просматривает текущее местоположение в коде, чтобы найти вызов .catch()
, и на этом основании предполагает, что отклонение в конечном итоге будет обработано.
Краткое содержание
Надеемся, что это объяснение пролило свет на то, как работает прогнозирование catch в Chrome DevTools, на его сильные стороны и ограничения. Если вы столкнулись с проблемами отладки из-за неверных прогнозов, рассмотрите следующие варианты:
- Измените шаблон кодирования на что-то более простое для прогнозирования, например, на использование асинхронных функций.
- Выберите, чтобы прерывать работу по всем исключениям, если DevTools не может остановиться, когда это необходимо.
- Используйте точку останова «Никогда не делать паузу здесь» или условную точку останова, если отладчик останавливается там, где вы этого не хотите.
Благодарности
Мы выражаем глубочайшую благодарность Софии Емельяновой и Джеселин Йен за неоценимую помощь в редактировании этого поста!