A depuração de exceções em aplicativos da Web parece simples: pause a execução quando algo der errado e investigue. No entanto, a natureza assíncrona do JavaScript torna isso surpreendentemente complexo. Como o Chrome DevTools pode saber quando e onde pausar quando as exceções passam por promessas e funções assíncronas?
Esta postagem aborda os desafios da previsão de captura, a capacidade do DevTools de prever se uma exceção será capturada mais tarde no código. Vamos analisar por que isso é tão complicado e como as melhorias recentes no V8 (o mecanismo JavaScript que alimenta o Chrome) estão tornando o processo mais preciso, o que facilita a experiência de depuração.
Por que a previsão de captura é importante?
No Chrome DevTools, você tem a opção de pausar a execução do código apenas para exceções não detectadas, ignorando as detectadas.
Nos bastidores, o depurador é interrompido imediatamente quando ocorre uma exceção para preservar o contexto. É uma previsão porque, no momento, é impossível saber com certeza se a exceção será detectada ou não mais tarde no código, especialmente em cenários assíncronos. Essa incerteza decorre da dificuldade inerente de prever o comportamento do programa, semelhante ao problema de interrupção.
Considere o exemplo a seguir: onde o depurador deve pausar? (Procure uma resposta na próxima seção.)
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?
}
}
A pausa em exceções em um depurador pode ser perturbadora e levar a interrupções frequentes e saltos para códigos desconhecidos. Para reduzir esse problema, você pode escolher depurar apenas as exceções não detectadas, que têm maior probabilidade de sinalizar bugs reais. No entanto, isso depende da precisão da previsão de captura.
Previsões incorretas causam frustração:
- Falsos negativos (prever "não detectado" quando será detectado). Paradas desnecessárias no depurador.
- Falsos positivos (prever "capturado" quando não será). Oportunidades perdidas de detectar erros críticos, forçando você a depurar todas as exceções, incluindo as esperadas.
Outro método para reduzir interrupções na depuração é usar a lista de ignorados, que evita interrupções em exceções em códigos de terceiros especificados. No entanto, a previsão precisa de captura ainda é crucial. Se uma exceção gerada em código de terceiros escapar e afetar seu próprio código, você vai querer depurá-la.
Como funciona o código assíncrono
Promessas, async
e await
, e outros padrões assíncronos podem levar a cenários em que uma exceção ou rejeição, antes de ser processada, pode seguir um caminho de execução difícil de determinar no momento em que uma exceção é gerada. Isso ocorre porque as promessas não podem ser aguardadas ou ter gerenciadores de captura adicionados até que a exceção já tenha ocorrido. Vamos analisar nosso exemplo anterior:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
Neste exemplo, outer()
chama primeiro inner()
, que gera imediatamente uma exceção. Com isso, o depurador pode concluir que inner()
vai retornar uma promessa rejeitada, mas atualmente nada está aguardando ou processando essa promessa. O depurador pode supor que o outer()
provavelmente vai aguardar e supor que ele o fará no bloco try
atual e, portanto, processá-lo, mas o depurador não pode ter certeza disso até que a promessa rejeitada seja retornada e a instrução await
seja alcançada.
O depurador não pode garantir que as previsões de captura sejam precisas, mas usa várias heurísticas para que padrões de programação comuns sejam previstos corretamente. Para entender esses padrões, é útil aprender como as promessas funcionam.
No V8, uma Promise
do JavaScript é representada como um objeto que pode estar em um dos três estados: atendido, rejeitado ou pendente. Se uma promessa estiver no estado de cumprimento e você chamar o método .then()
, uma nova promessa pendente será criada e uma nova tarefa de reação de promessa será programada, que executará o gerenciador e, em seguida, definirá a promessa como cumprida com o resultado do gerenciador ou a definirá como rejeitada se o gerenciador gerar uma exceção. O mesmo acontece se você chamar o método .catch()
em uma promessa rejeitada. Por outro lado, chamar .then()
em uma promessa rejeitada ou .catch()
em uma promessa atendida vai retornar uma promessa no mesmo estado e não executar o gerenciador.
Uma promessa pendente contém uma lista de reações em que cada objeto de reação contém um gerenciador de atendimento ou de rejeição (ou ambos) e uma promessa de reação. Portanto, chamar .then()
em uma promessa pendente vai adicionar uma reação com um gerenciador atendido, bem como uma nova promessa pendente para a promessa de reação, que .then()
vai retornar. Chamar .catch()
vai adicionar uma reação semelhante, mas com um gerenciador de rejeição. Chamar .then()
com dois argumentos cria uma reação com os dois manipuladores. Chamar .finally()
ou aguardar a promessa adiciona uma reação com dois manipuladores que são funções integradas específicas para implementar esses recursos.
Quando a promessa pendente for atendida ou recusada, os jobs de reação serão programados para todos os gerenciadores atendidos ou recusados. As promessas de reação correspondentes serão atualizadas, potencialmente acionar os próprios trabalhos de reação.
Exemplos
Pense no seguinte código:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Talvez não seja óbvio que esse código envolve três objetos Promise
distintos. O código acima é equivalente ao seguinte:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
Neste exemplo, as seguintes etapas acontecem:
- O construtor
Promise
é chamado. - Uma nova
Promise
pendente é criada. - A função anônima é executada.
- Uma exceção é gerada. Nesse ponto, o depurador precisa decidir se vai parar ou não.
- O construtor da promessa captura essa exceção e muda o estado dela para
rejected
, com o valor definido como o erro gerado. Ele retorna essa promessa, que é armazenada empromise1
. .then()
não programa nenhum job de reação porquepromise1
está no estadorejected
. Em vez disso, uma nova promessa (promise2
) é retornada, que também está no estado rejeitado com o mesmo erro..catch()
programa um job de reação com o gerenciador fornecido e uma nova promessa de reação pendente, que é retornada comopromise3
. Nesse ponto, o depurador sabe que o erro será processado.- Quando a tarefa de reação é executada, o gerenciador é retornado normalmente e o estado de
promise3
é alterado parafulfilled
.
O próximo exemplo tem uma estrutura semelhante, mas a execução é bastante diferente:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Isso é equivalente a:
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;
Neste exemplo, as seguintes etapas acontecem:
- Um
Promise
é criado no estadofulfilled
e armazenado empromise1
. - Uma tarefa de reação de promessa é programada com a primeira função anônima, e a promessa de reação
(pending)
dela é retornada comopromise2
. - Uma reação é adicionada a
promise2
com um gerenciador atendido e a promessa de reação, que é retornada comopromise3
. - Uma reação é adicionada a
promise3
com um gerenciador rejeitado e outra promessa de reação, que é retornada comopromise4
. - A tarefa de reação programada na etapa 2 é executada.
- O gerenciador gera uma exceção. Nesse ponto, o depurador precisa decidir se vai parar ou não. No momento, o gerenciador é o único código JavaScript em execução.
- Como a tarefa termina com uma exceção, a promessa de reação associada (
promise2
) é definida como o estado rejeitado com o valor definido para o erro gerado. - Como
promise2
tinha uma reação e essa reação não tinha um gerenciador rejeitado, a promessa de reação (promise3
) também foi definida comorejected
com o mesmo erro. - Como
promise3
tinha uma reação e essa reação tinha um gerenciador rejeitado, uma tarefa de reação de promessa é programada com esse gerenciador e a promessa de reação (promise4
). - Quando essa tarefa de reação é executada, o gerenciador é retornado normalmente e o estado de
promise4
é alterado para "cumprida".
Métodos para previsão de captura
Há duas possíveis fontes de informações para a previsão de captura. Uma delas é a pilha de chamadas. Isso é válido para exceções síncronas: o depurador pode percorrer a pilha de chamadas da mesma forma que o código de desdobramento de exceção e vai parar se encontrar um frame em um bloco try...catch
. Para promessas ou exceções rejeitadas em construtores de promessa ou em funções assíncronas que nunca foram suspensas, o depurador também depende da pilha de chamadas, mas, nesse caso, a previsão não pode ser confiável em todos os casos. Isso ocorre porque, em vez de gerar uma exceção para o gerenciador mais próximo, o código assíncrono retorna uma exceção rejeitada, e o depurador precisa fazer algumas suposições sobre o que o autor da chamada vai fazer com ela.
Primeiro, o depurador presume que uma função que recebe uma promessa retornada provavelmente retornará essa promessa ou uma promessa derivada para que as funções assíncronas mais altas na pilha tenham uma chance de aguardar. Em segundo lugar, o depurador presume que, se uma promessa for retornada para uma função assíncrona, ela será aguardada sem entrar ou sair primeiro de um bloco try...catch
. Nenhuma dessas suposições é garantida como correta, mas elas são suficientes para fazer as previsões corretas dos padrões de programação mais comuns com funções assíncronas. Na versão 125 do Chrome, adicionamos outra heurística: o depurador verifica se um callee está prestes a chamar .catch()
no valor que será retornado (ou .then()
com dois argumentos ou uma cadeia de chamadas para .then()
ou .finally()
seguida por um .catch()
ou um .then()
de dois argumentos). Nesse caso, o depurador assume que esses são os métodos na promessa que estamos rastreando ou um relacionado a ela. Portanto, a rejeição será detectada.
A segunda fonte de informações é a árvore de reações de promessas. O depurador começa com uma promessa raiz. Às vezes, essa é uma promessa em que o método reject()
acabou de ser chamado. Mais comumente, quando uma exceção ou rejeição acontece durante um job de reação de promessa e nada na pilha de chamadas parece detectá-la, o depurador rastreia a promessa associada à reação. O depurador analisa todas as reações na promessa pendente e verifica se elas têm manipuladores de rejeição. Se alguma reação não for encontrada, ele vai analisar a promessa de reação e rastrear recursivamente a partir dela. Se todas as reações levarem a um manipulador de rejeição, o depurador considera que a rejeição de promessa foi detectada. Há alguns casos especiais a serem abordados, por exemplo, não contar o gerenciador de rejeição integrado para uma chamada .finally()
.
A árvore de reação de promessas fornece uma fonte de informações geralmente confiável, se as informações estiverem disponíveis. Em alguns casos, como uma chamada para Promise.reject()
ou em um construtor Promise
ou em uma função assíncrona que ainda não esperou nada, não haverá reações para rastrear, e o depurador terá que depender apenas da pilha de chamadas. Em outros casos, a árvore de reação de promessas geralmente contém os manipuladores necessários para inferir a previsão de captura, mas é sempre possível que mais manipuladores sejam adicionados mais tarde, o que mudará a exceção de capturada para não capturada ou vice-versa. Há também promessas como as criadas por Promise.all/any/race
, em que outras promessas no grupo podem afetar o tratamento de uma rejeição. Para esses métodos, o depurador assume que uma rejeição de promessa será encaminhada se a promessa ainda estiver pendente.
Confira estes dois exemplos:
Embora esses dois exemplos de exceções capturadas pareçam semelhantes, eles exigem heurísticas de previsão de captura bastante diferentes. No primeiro exemplo, uma promessa resolvida é criada, depois um job de reação para .then()
é programado, o que gera uma exceção. Em seguida, .catch()
é chamado para anexar um gerenciador de rejeição à promessa de reação. Quando a tarefa de reação é executada, a exceção é gerada, e a árvore de reação de promessas contém o manipulador de captura. Assim, ela é detectada como capturada. No segundo exemplo, a promessa é rejeitada imediatamente antes que o código para adicionar um manipulador de captura seja executado. Portanto, não há manipuladores de rejeição na árvore de reação da promessa. O depurador precisa verificar a pilha de chamadas, mas não há blocos try...catch
. Para prever isso corretamente, o depurador faz a verificação antes do local atual no código para encontrar a chamada para .catch()
e supõe com base nisso que a rejeição será processada.
Resumo
Esperamos que esta explicação tenha esclarecido como a previsão de captura funciona no Chrome DevTools, os pontos fortes e as limitações dela. Se você encontrar problemas de depuração devido a previsões incorretas, considere estas opções:
- Mude o padrão de programação para algo mais fácil de prever, como o uso de funções assíncronas.
- Selecione a opção de interromper todas as exceções se as DevTools não pararem quando deveriam.
- Use um ponto de interrupção "Nunca pausar aqui" ou condicional se o depurador estiver parando em um lugar que você não quer.
Agradecimentos
Agradecemos a Sofia Emelianova e a Jecelyn Yeen pela ajuda valiosa na edição desta postagem.