Il debug delle eccezioni nelle applicazioni web sembra semplice: metti in pausa l'esecuzione quando si verifica un problema ed esamina il problema. Tuttavia, la natura asincrona di JavaScript rende questa operazione sorprendentemente complessa. In che modo Chrome DevTools può sapere quando e dove mettere in pausa quando le eccezioni passano attraverso promesse e funzioni asincrone?
Questo post illustra le sfide della previsione di eccezione, ovvero la capacità di DevTools di prevedere se un'eccezione verrà rilevata in un secondo momento nel codice. Vedremo perché è così complicato e in che modo i recenti miglioramenti a V8 (il motore JavaScript alla base di Chrome) lo stanno rendendo più preciso, il che si traduce in un'esperienza di debug più fluida.
Perché la previsione delle catture è importante
In Strumenti per sviluppatori di Chrome, hai la possibilità di mettere in pausa l'esecuzione del codice solo per le eccezioni non rilevate, ignorando quelle rilevate.
Dietro le quinte, il debugger si arresta immediatamente quando si verifica un'eccezione per preservare il contesto. Si tratta di una previsione perché, al momento, è impossibile sapere con certezza se l'eccezione verrà rilevata o meno in un secondo momento nel codice, in particolare in scenari asincroni. Questa incertezza deriva dalla difficoltà intrinseca di prevedere il comportamento del programma, in modo simile al problema di arresto.
Considera il seguente esempio: dove deve essere messa in pausa il debugger? (Cerca una risposta nella sezione successiva.)
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?
}
}
Mettere in pausa il programma in caso di eccezioni in un debugger può essere dannoso e causare interruzioni frequenti e salti a codice non familiare. Per ovviare a questo problema, puoi scegliere di eseguire il debug solo delle eccezioni non rilevate, che hanno maggiori probabilità di segnalare bug effettivi. Tuttavia, questo si basa sull'accuratezza della previsione delle catture.
Le previsioni errate generano frustrazione:
- Falsi negativi (previsione di "non rilevato" quando verrà rilevato). Interruzioni non necessarie nel debugger.
- Falsi positivi (previsione di "cattura" quando non verrà rilevata). Opportunità perse per rilevare errori critici, che potrebbero costringerti a eseguire il debug di tutte le eccezioni, incluse quelle previste.
Un altro metodo per ridurre le interruzioni del debug è utilizzare l'elenco di ignorati, che impedisce le interruzioni in caso di eccezioni all'interno di codice di terze parti specificato. Tuttavia, la previsione accurata delle catture è ancora fondamentale. Se un'eccezione proveniente da codice di terze parti esce ed influisce sul tuo codice, devi essere in grado di eseguire il debug.
Come funziona il codice asincrono
Le promesse, async
e await
e altri pattern asincroni possono portare a scenari in cui un'eccezione o un rifiuto, prima di essere gestiti, può seguire un percorso di esecuzione difficile da determinare al momento dell'eccezione. Questo perché le promesse non possono essere attese o avere gestori di errori aggiunti fino a quando l'eccezione non si è già verificata. Vediamo l'esempio precedente:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
In questo esempio, outer()
chiama prima inner()
, che genera immediatamente un'eccezione. Da ciò il debugger può concludere che inner()
restituirà una promessa rifiutata, ma al momento non è in attesa o non gestisce la promessa. Il debugger può supporre che outer()
lo attenda e che lo faccia nel blocco try
corrente e quindi gestirlo, ma non può esserne certo finché non viene restituita la promessa rifiutata e non viene raggiunta l'istruzione await
.
Il debugger non può garantire che le previsioni di rilevamento siano accurate, ma utilizza una serie di approcci euristici per i pattern di codifica comuni per fare previsioni corrette. Per comprendere questi pattern, è utile sapere come funzionano le promesse.
In V8, un Promise
JavaScript è rappresentato come un oggetto che può trovarsi in uno dei tre stati: soddisfatto, rifiutato o in attesa. Se una promessa è nello stato soddisfatta e chiami il metodo .then()
, viene creata una nuova promessa in attesa e viene pianificata una nuova attività di reazione alla promessa che eseguirà l'handler e poi imposterà la promessa su soddisfatta con il risultato dell'handler o su rifiutata se l'handler genera un'eccezione. Lo stesso accade se chiami il metodo .catch()
su una promessa rifiutata. Al contrario, chiamare .then()
su una promessa rifiutata o .catch()
su una promessa soddisfatta restituirà una promessa nello stesso stato e non eseguirà il gestore.
Una promessa in attesa contiene un elenco di reazioni in cui ogni oggetto reazione contiene un gestore di adempimento o un gestore di rifiuto (o entrambi) e una promessa di reazione. Pertanto, l'chiamata di .then()
su una promessa in attesa aggiungerà una reazione con un gestore soddisfatto e una nuova promessa in attesa per la promessa di reazione, che .then()
restituirà. La chiamata a .catch()
aggiungerà una reazione simile, ma con un gestore di rifiuto. La chiamata a .then()
con due argomenti crea una reazione con entrambi gli handler, mentre la chiamata a .finally()
o l'attesa della promessa aggiunge una reazione con due handler che sono funzioni predefinite specifiche per l'implementazione di queste funzionalità.
Quando la promessa in attesa viene infine soddisfatta o rifiutata, i job di reazione vengono pianificati per tutti i relativi gestori soddisfatti o per tutti i relativi gestori rifiutati. Le promesse di reazione corrispondenti verranno quindi aggiornate, attivando potenzialmente i relativi job di reazione.
Esempi
Considera il seguente codice:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Potrebbe non essere evidente che questo codice coinvolge tre oggetti Promise
distinti. Il codice riportato sopra è equivalente al seguente:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
In questo esempio, vengono eseguiti i seguenti passaggi:
- Viene chiamato il costruttore
Promise
. - Viene creato un nuovo
Promise
in attesa. - La funzione anonima viene eseguita.
- Viene lanciata un'eccezione. A questo punto, il debugger deve decidere se interrompersi o meno.
- Il costruttore della promessa intercetta questa eccezione e modifica lo stato della promessa in
rejected
con il valore impostato sull'errore generato. Restituisce questa promessa, che viene memorizzata inpromise1
. .then()
non pianifica alcun job di reazione perchépromise1
è nello statorejected
. Viene restituita una nuova promessa (promise2
), che è anche nello stato rifiutato con lo stesso errore..catch()
pianifica un job di reazione con il gestore fornito e una nuova promessa di reazione in attesa, che viene restituita comepromise3
. A questo punto il debugger sa che l'errore verrà gestito.- Quando viene eseguita l'attività di reazione, l'handler viene restituito normalmente e lo stato di
promise3
viene modificato infulfilled
.
L'esempio seguente ha una struttura simile, ma l'esecuzione è molto diversa:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
Ciò equivale 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;
In questo esempio, vengono eseguiti i seguenti passaggi:
- Un
Promise
viene creato nello statofulfilled
e archiviato inpromise1
. - Un'attività di reazione con promessa viene pianificata con la prima funzione anonima e la sua promessa di reazione
(pending)
viene restituita comepromise2
. - A
promise2
viene aggiunta una reazione con un gestore soddisfatto e la relativa promessa di reazione, che viene restituita comepromise3
. - A
promise3
viene aggiunta una reazione con un gestore rifiutato e un'altra promessa di reazione, che viene restituita comepromise4
. - Viene eseguita l'attività di reazione pianificata nel passaggio 2.
- Il gestore genera un'eccezione. A questo punto il debugger deve decidere se interrompersi o meno. Al momento, l'handler è l'unico codice JavaScript in esecuzione.
- Poiché l'attività termina con un'eccezione, la promessa di reazione associata (
promise2
) viene impostata sullo stato rifiutato con il valore impostato sull'errore generato. - Poiché
promise2
aveva una reazione e questa reazione non aveva un gestore rifiutato, la relativa promessa di reazione (promise3
) è impostata anche surejected
con lo stesso errore. - Poiché
promise3
aveva una reazione e questa reazione aveva un gestore rifiutato, viene pianificata un'attività di reazione alla promessa con questo gestore e la relativa promessa di reazione (promise4
). - Quando viene eseguita l'attività di reazione, il gestore restituisce normalmente e lo stato di
promise4
viene modificato in soddisfatto.
Metodi per la previsione delle catture
Esistono due potenziali fonti di informazioni per la previsione delle catture. Uno è lo stack di chiamate. Questo è corretto per le eccezioni sincrone: il debugger può esaminare lo stack di chiamate nello stesso modo in cui lo fa il codice di eliminazione delle eccezioni e si arresta se trova un frame in un blocco try...catch
. Per le promesse o le eccezioni rifiutate nei costruttori di promesse o nelle funzioni asincrone che non sono mai state sospese, il debugger si basa anche sullo stack di chiamate, ma in questo caso la sua previsione non può essere affidabile in tutti i casi. Questo perché, anziché generare un'eccezione per il gestore più vicino, il codice asincrono restituirà un'eccezione rifiutata e il debugger deve fare alcune supposizioni su cosa farà il chiamante con l'eccezione.
Innanzitutto, il debugger presuppone che una funzione che riceve una promessa restituita sia probabile che restituisca quella promessa o una promessa derivata, in modo che le funzioni asincrone più in alto nello stack abbiano la possibilità di attendere. In secondo luogo, il debugger presuppone che, se una promessa viene restituita a una funzione asincrona, verrà presto attesa senza prima entrare o uscire da un blocco try...catch
. Nessuna di queste ipotesi è garantita come corretta, ma sono sufficienti per fare le previsioni corrette per i pattern di codifica più comuni con funzioni asincrone. Nella versione 125 di Chrome abbiamo aggiunto un'altra euristica: il debugger controlla se un chiamatore sta per chiamare .catch()
sul valore che verrà restituito (o .then()
con due argomenti o una catena di chiamate a .then()
o .finally()
seguita da un .catch()
o un .then()
con due argomenti). In questo caso, il debugger presume che si tratti dei metodi della promessa che stiamo tracciando o di uno correlato, quindi il rifiuto verrà rilevato.
La seconda fonte di informazioni è l'albero delle reazioni alle promesse. Il debugger inizia con una promessa principale. A volte si tratta di una promessa per cui è stato appena chiamato il metodo reject()
. Più comunemente, quando si verifica un'eccezione o un rifiuto durante un job di reazione alla promessa e nulla nello stack delle chiamate sembra rilevarlo, il debugger esegue la traccia dalla promessa associata alla reazione. Il debugger esamina tutte le reazioni alla promessa in attesa e verifica se sono presenti gestori di rifiuto. Se nessuna reazione soddisfa questa condizione, viene esaminata la promessa di reazione e viene eseguita una ricerca ricorsiva a partire da questa. Se tutte le reazioni portano a un gestore del rifiuto, il debugger considera che il rifiuto della promessa sia stato rilevato. Esistono alcuni casi speciali da considerare, ad esempio non viene conteggiato il gestore di rifiuto integrato per una chiamata .finally()
.
L'albero di reazione alla promessa fornisce una fonte di informazioni generalmente attendibile, se le informazioni sono disponibili. In alcuni casi, ad esempio in una chiamata a Promise.reject()
o in un costruttore Promise
o in una funzione asincrona che non ha ancora aspettato nulla, non ci saranno reazioni da tracciare e il debugger dovrà fare affidamento solo sullo stack di chiamate. In altri casi, l'albero di reazione della promessa di solito contiene gli handler necessari per dedurre la previsione di cattura, ma è sempre possibile che in un secondo momento vengano aggiunti altri handler che cambieranno l'eccezione da rilevata a non rilevata o viceversa. Esistono anche promesse come quelle create da Promise.all/any/race
, in cui altre promesse del gruppo possono influire sul modo in cui viene gestito un rifiuto. Per questi metodi, il debugger presuppone che un rifiuto della promessa verrà inoltrato se la promessa è ancora in attesa.
Dai un'occhiata ai seguenti due esempi:
Sebbene questi due esempi di eccezioni rilevate siano simili, richiedono heurismi di previsione di rilevamento molto diversi. Nel primo esempio viene creata una promessa risolta, quindi viene pianificato un job di reazione per .then()
che genera un'eccezione, quindi .catch()
viene chiamato per associare un gestore di rifiuto alla promessa di reazione. Quando viene eseguita l'attività di reazione, viene lanciata l'eccezione e la struttura ad albero della reazione alla promessa conterrà il gestore di blocco, pertanto verrà rilevata come rilevata. Nel secondo esempio, la promessa viene rifiutata immediatamente prima dell'esecuzione del codice per aggiungere un gestore di errori, pertanto non sono presenti gestori di rifiuto nell'albero di reazione della promessa. Il debugger deve esaminare lo stack di chiamate, ma non sono presenti blocchi try...catch
. Per prevedere correttamente questo, il debugger esegue la scansione prima della posizione corrente nel codice per trovare la chiamata a .catch()
e, in base a ciò, presume che il rifiuto verrà gestito alla fine.
Riepilogo
Ci auguriamo che questa spiegazione abbia fatto luce su come funziona la previsione delle catture in Chrome DevTools, sui suoi punti di forza e sulle sue limitazioni. Se riscontri problemi di debug dovuti a previsioni errate, prendi in considerazione queste opzioni:
- Modifica il pattern di codifica in modo che sia più facile da prevedere, ad esempio utilizzando funzioni asincrone.
- Seleziona l'interruzione in caso di tutte le eccezioni se DevTools non si arresta quando dovrebbe.
- Utilizza un punto di interruzione "Non mettere mai in pausa qui" o un punto di interruzione condizionale se il debugger si arresta in un punto indesiderato.
Ringraziamenti
La nostra gratitudine più profonda va a Sofia Emelianova e Jecelyn Yeen per la loro preziosa assistenza nella modifica di questo post.