Vervollständigungen in den Chrome-Entwicklertools: Warum es schwierig ist und wie man es verbessern kann

Eric Leese
Eric Leese

Das Beheben von Ausnahmen in Webanwendungen scheint einfach: Wenn etwas schiefgeht, unterbrechen Sie die Ausführung und untersuchen Sie das Problem. Aufgrund der asynchronen Natur von JavaScript ist dies jedoch überraschend komplex. Woher wissen die Chrome-Entwicklertools, wann und wo sie pausieren müssen, wenn Ausnahmen durch Versprechen und asynchrone Funktionen fliegen?

In diesem Beitrag geht es um die Herausforderungen der Ausnahmevorhersage – die Möglichkeit von DevTools, vorherzusagen, ob eine Ausnahme später in Ihrem Code abgefangen wird. Wir sehen uns an, warum das so schwierig ist und wie die jüngsten Verbesserungen an V8 (der JavaScript-Engine, die Chrome antreibt) zu einer genaueren Fehlerbehebung führen.

Warum die Vorhersage von Zugriffen wichtig ist 

In den Chrome-Entwicklertools können Sie die Codeausführung nur für nicht abgefangene Ausnahmen pausieren und solche, die abgefangen wurden, überspringen. 

Die Chrome-Entwicklertools bieten separate Optionen zum Pausieren bei aufgefangenen oder nicht aufgefangenen Ausnahmen.

Im Hintergrund wird der Debugger sofort angehalten, wenn eine Ausnahme auftritt, um den Kontext beizubehalten. Es handelt sich um eine Vorhersage, da derzeit nicht mit Sicherheit gesagt werden kann, ob die Ausnahme später im Code abgefangen wird oder nicht, insbesondere in asynchronen Szenarien. Diese Unsicherheit rührt von der inhärenten Schwierigkeit her, das Programmverhalten vorherzusagen, ähnlich wie beim Halting-Problem.

Betrachten Sie das folgende Beispiel: Wo sollte der Debugger anhalten? (Die Antwort finden Sie im nächsten Abschnitt.)

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?
  }
}

Das Pausieren bei Ausnahmen in einem Debugger kann störend sein und zu häufigen Unterbrechungen und Sprüngen zu unbekanntem Code führen. Sie können dies vermeiden, indem Sie nur nicht abgefangene Ausnahmen beheben, die mit größerer Wahrscheinlichkeit auf tatsächliche Fehler hinweisen. Dies hängt jedoch von der Genauigkeit der Fangvorhersage ab.

Falsche Vorhersagen führen zu Frustration:

  • Falsch negative Ergebnisse (Vorhersage „nicht erkannt“, obwohl es erkannt wird) Unnötige Unterbrechungen im Debugger.
  • Falsch positive Ergebnisse (Vorhersage von „erwischt“, wenn es nicht passiert) Verpasste Chancen, kritische Fehler zu erkennen, was Sie möglicherweise dazu zwingt, alle Ausnahmen zu beheben, einschließlich der erwarteten.

Eine weitere Methode, um Unterbrechungen beim Debuggen zu reduzieren, ist die Verwendung der Ignorierliste. Damit werden Unterbrechungen bei Ausnahmen in bestimmten Drittanbietercodes verhindert. Eine genaue Vorhersage der Anzahl der gefangenen Fische ist jedoch weiterhin entscheidend. Wenn eine Ausnahme, die aus Drittanbietercode stammt, Ihren eigenen Code beeinflusst, sollten Sie sie beheben können.

Funktionsweise von asynchronem Code

Versprechen, async und await sowie andere asynchrone Muster können zu Szenarien führen, in denen eine Ausnahme oder Ablehnung vor der Verarbeitung einen Ausführungspfad durchläuft, der zum Zeitpunkt des Auslösens der Ausnahme schwer zu bestimmen ist. Das liegt daran, dass Promises möglicherweise erst nach dem Auftreten der Ausnahme erwartet oder ihnen erst dann Catch-Handler hinzugefügt werden. Sehen wir uns unser vorheriges Beispiel an:

async function inner() {
  throw new Error();
}

async function outer() {
  try {
    const promise = inner();
    // ...
    await promise;
  } catch (e) {
    // ...
  }
}

In diesem Beispiel ruft outer() zuerst inner() auf, was sofort eine Ausnahme auslöst. Daraus kann der Debugger schließen, dass inner() ein abgelehntes Versprechen zurückgibt, aber derzeit nichts auf dieses Versprechen wartet oder es anderweitig verarbeitet. Der Debugger kann vermuten, dass outer() wahrscheinlich darauf wartet und dies im aktuellen try-Block tut, und es daher verarbeiten. Er kann sich jedoch erst sicher sein, nachdem das abgelehnte Versprechen zurückgegeben und die await-Anweisung schließlich erreicht wurde.

Der Debugger kann nicht garantieren, dass die Vorhersagen korrekt sind. Er verwendet jedoch eine Vielzahl von Heuristiken für gängige Codierungsmuster, um korrekte Vorhersagen zu treffen. Um diese Muster zu verstehen, ist es hilfreich, zu erfahren, wie Versprechen funktionieren.

In V8 wird ein JavaScript-Promise als Objekt dargestellt, das sich in einem von drei Status befinden kann: erfüllt, abgelehnt oder ausstehend. Wenn ein Versprechen den Status „erledigt“ hat und Sie die Methode .then() aufrufen, wird ein neues ausstehendes Versprechen erstellt und eine neue Aufgabe für die Reaktion auf das Versprechen geplant. Dabei wird der Handler ausgeführt und das Versprechen dann mit dem Ergebnis des Handlers auf „erledigt“ gesetzt oder auf „abgelehnt“, wenn der Handler eine Ausnahme auslöst. Dasselbe passiert, wenn Sie die .catch()-Methode auf ein abgelehntes Versprechen anwenden. Wenn Sie dagegen .then() für ein abgelehntes Versprechen oder .catch() für ein erfülltes Versprechen aufrufen, wird ein Versprechen im selben Status zurückgegeben und der Handler nicht ausgeführt. 

Ein ausstehendes Versprechen enthält eine Reaktionsliste, in der jedes Reaktionsobjekt einen Fulfill-Handler oder einen Reject-Handler (oder beides) und ein Reaktionsversprechen enthält. Wenn Sie .then() auf ein ausstehendes Versprechen anwenden, wird eine Reaktion mit einem erfüllten Handler sowie ein neues ausstehendes Versprechen für das Reaktionsversprechen hinzugefügt, das .then() zurückgibt. Wenn du .catch() aufrufst, wird eine ähnliche Reaktion mit einem Ablehnungs-Handler hinzugefügt. Wenn du .then() mit zwei Argumenten aufrufst, wird eine Reaktion mit beiden Handlern erstellt. Wenn du .finally() aufrufst oder auf das Versprechen wartest, wird eine Reaktion mit zwei Handlern hinzugefügt, die integrierte Funktionen sind, die speziell für die Implementierung dieser Funktionen entwickelt wurden.

Wenn das ausstehende Versprechen schließlich erfüllt oder abgelehnt wird, werden Reaktionsjobs entweder für alle erfüllten oder alle abgelehnten Handler geplant. Die entsprechenden Reaktionsversprechen werden dann aktualisiert, was möglicherweise eigene Reaktionsjobs auslöst.

Beispiele

Betrachten wir den folgenden Code:

return new Promise(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Es ist nicht unbedingt offensichtlich, dass dieser Code drei verschiedene Promise-Objekte enthält. Der obige Code entspricht dem folgenden Code:

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 diesem Beispiel werden die folgenden Schritte ausgeführt:

  1. Der Konstruktor von Promise wird aufgerufen.
  2. Es wird eine neue ausstehende Promise erstellt.
  3. Die anonyme Funktion wird ausgeführt.
  4. Es wird eine Ausnahme ausgelöst. An dieser Stelle muss der Debugger entscheiden, ob er anhalten soll oder nicht.
  5. Der Promise-Konstruktor fängt diese Ausnahme ab und ändert dann den Status des Promises in rejected, wobei der Wert auf den aufgetretenen Fehler festgelegt wird. Es gibt dieses Versprechen zurück, das in promise1 gespeichert wird.
  6. .then() plant keinen Reaktionsjob, da promise1 sich im Status rejected befindet. Stattdessen wird ein neues Versprechen (promise2) zurückgegeben, das sich ebenfalls im Status „Abgelehnt“ mit demselben Fehler befindet.
  7. .catch() plant einen Reaktionsjob mit dem bereitgestellten Handler und einem neuen ausstehenden Reaktionsversprechen, das als promise3 zurückgegeben wird. An diesem Punkt weiß der Debugger, dass der Fehler behandelt wird.
  8. Wenn die Reaktionsaufgabe ausgeführt wird, gibt der Handler normal zurück und der Status von promise3 wird in fulfilled geändert.

Das nächste Beispiel hat eine ähnliche Struktur, die Ausführung ist jedoch ganz anders:

return Promise.resolve()
    .then(() => {throw new Error();})
    .then(() => console.log('Never happened'))
    .catch(() => console.log('Caught'));

Dies entspricht:

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 diesem Beispiel werden die folgenden Schritte ausgeführt:

  1. Eine Promise wird im Bundesstaat fulfilled erstellt und in promise1 gespeichert.
  2. Eine Promise-Reaktionsaufgabe wird mit der ersten anonymen Funktion geplant und das (pending)-Reaktionsversprechen wird als promise2 zurückgegeben.
  3. promise2 wird eine Reaktion mit einem erfüllten Handler und dem Reaktionsversprechen hinzugefügt, das als promise3 zurückgegeben wird.
  4. promise3 wird eine Reaktion mit einem abgelehnten Handler und einem weiteren Reaktionsversprechen hinzugefügt, das als promise4 zurückgegeben wird.
  5. Die in Schritt 2 geplante Reaktionsaufgabe wird ausgeführt.
  6. Der Handler löst eine Ausnahme aus. An dieser Stelle muss der Debugger entscheiden, ob er anhalten soll oder nicht. Derzeit ist der Handler der einzige ausgeführte JavaScript-Code.
  7. Da die Aufgabe mit einer Ausnahme endet, wird das zugehörige Reaktionsversprechen (promise2) in den Status „Abgelehnt“ gesetzt und der Wert auf den aufgetretenen Fehler festgelegt.
  8. Da promise2 eine Reaktion hatte und für diese Reaktion kein abgelehnter Handler vorhanden war, wird das Reaktionsversprechen (promise3) ebenfalls auf rejected mit demselben Fehler gesetzt.
  9. Da promise3 eine Reaktion hatte und diese Reaktion einen abgelehnten Handler hatte, wird eine Promise-Reaktionsaufgabe mit diesem Handler und seinem Reaktionsversprechen (promise4) geplant.
  10. Wenn diese Reaktionsaufgabe ausgeführt wird, gibt der Handler normal zurück und der Status von promise4 wird in „erfüllt“ geändert.

Methoden zur Fangvorhersage

Es gibt zwei mögliche Informationsquellen für die Fangvorhersage. Eine davon ist der Aufrufstack. Das ist für synchrone Ausnahmen sinnvoll: Der Debugger kann den Aufrufstapel auf die gleiche Weise durchgehen wie der Code zum Aufheben der Ausnahme und stoppt, wenn er einen Frame findet, in dem er sich in einem try...catch-Block befindet. Bei abgelehnten Versprechen oder Ausnahmen in Promise-Konstruktoren oder in asynchronen Funktionen, die nie angehalten wurden, stützt sich der Debugger ebenfalls auf den Aufrufstapel. In diesem Fall ist seine Vorhersage jedoch nicht in allen Fällen zuverlässig. Das liegt daran, dass asynchroner Code anstelle einer Ausnahme an den nächsten Handler eine abgelehnte Ausnahme zurückgibt. Der Debugger muss dann einige Annahmen darüber treffen, was der Aufrufer damit tun wird.

Erstens geht der Debugger davon aus, dass eine Funktion, die ein zurückgegebenes Promise empfängt, dieses Promise oder ein abgeleitetes Promise zurückgibt, damit asynchrone Funktionen weiter oben im Stack die Möglichkeit haben, darauf zu warten. Zweitens geht der Debugger davon aus, dass, wenn ein Versprechen an eine asynchrone Funktion zurückgegeben wird, es bald darauf gewartet wird, ohne dass zuerst ein try...catch-Block betreten oder verlassen wird. Keine dieser Annahmen ist garantiert richtig, aber sie reichen aus, um die richtigen Vorhersagen für die gängigsten Codierungsmuster mit asynchronen Funktionen zu treffen. In Chrome-Version 125 haben wir eine weitere Heuristik hinzugefügt: Der Debugger prüft, ob ein aufgerufener Code .catch() auf den zurückgegebenen Wert anwenden wird (oder .then() mit zwei Argumenten oder eine Kette von Aufrufen von .then() oder .finally() gefolgt von .catch() oder .then() mit zwei Argumenten). In diesem Fall geht der Debugger davon aus, dass es sich um die Methoden des Promises handelt, das wir erfassen, oder um eine damit zusammenhängende Methode. Die Ablehnung wird daher abgefangen.

Die zweite Informationsquelle ist der Baum der Versprechensreaktionen. Der Debugger beginnt mit einem Stammversprechen. Manchmal ist das ein Versprechen, dessen reject()-Methode gerade aufgerufen wurde. Häufiger tritt eine Ausnahme oder Ablehnung während eines Promise-Reaktionsjobs auf und nichts im Aufrufstapel scheint sie zu erfassen. In diesem Fall wird der Debugger vom mit der Reaktion verknüpften Promise aus gestartet. Der Debugger prüft alle Reaktionen auf das ausstehende Versprechen und sieht nach, ob sie Ablehnungs-Handler haben. Wenn das nicht der Fall ist, wird das Reaktionsversprechen geprüft und rekursiv zurückverfolgt. Wenn alle Reaktionen letztendlich zu einem Ablehnungs-Handler führen, betrachtet der Debugger die Ablehnung des Versprechens als abgefangen. Es gibt einige Sonderfälle, die berücksichtigt werden müssen, z. B. das Nichtzählen des integrierten Ablehnungshandlers für einen .finally()-Aufruf.

Der Reaktionsbaum für Versprechen ist in der Regel eine zuverlässige Informationsquelle, sofern die Informationen vorhanden sind. In einigen Fällen, z. B. bei einem Aufruf von Promise.reject() oder in einem Promise-Konstruktor oder in einer asynchronen Funktion, die noch nichts abgewartet hat, gibt es keine Reaktionen zu erfassen und der Debugger muss sich ausschließlich auf den Aufrufstapel verlassen. In anderen Fällen enthält der Promise-Reaktionsbaum in der Regel die Handler, die für die Ableitung der Vorhersage erforderlich sind. Es ist jedoch immer möglich, dass später weitere Handler hinzugefügt werden, die die Ausnahme von „gefangen“ in „nicht gefangen“ oder umgekehrt ändern. Es gibt auch Versprechen wie die von Promise.all/any/race, bei denen sich andere Versprechen in der Gruppe auf die Behandlung einer Ablehnung auswirken können. Bei diesen Methoden geht der Debugger davon aus, dass eine Ablehnung des Versprechens weitergeleitet wird, wenn das Versprechen noch ausstehend ist.

Sehen Sie sich die folgenden beiden Beispiele an:

Zwei Beispiele für die Fangvorhersage

Diese beiden Beispiele für aufgetretene Ausnahmen sehen zwar ähnlich aus, erfordern aber ganz unterschiedliche Heuristiken für die Vorhersage von Aufzeichnungen. Im ersten Beispiel wird ein erfülltes Versprechen erstellt, dann wird ein Reaktionsjob für .then() geplant, der eine Ausnahme auslöst. Anschließend wird .catch() aufgerufen, um dem Reaktionsversprechen einen Ablehnungs-Handler hinzuzufügen. Wenn die Reaktionsaufgabe ausgeführt wird, wird die Ausnahme ausgelöst und der Reaktionsbaum des Versprechens enthält den Catch-Handler, sodass die Ausnahme erkannt wird. Im zweiten Beispiel wird das Versprechen sofort abgelehnt, bevor der Code zum Hinzufügen eines Catch-Handlers ausgeführt wird. Daher gibt es im Reaktionsbaum des Versprechens keine Ablehnungs-Handler. Der Debugger muss sich den Aufrufstapel ansehen, aber es gibt auch keine try...catch-Blöcke. Um dies richtig vorherzusagen, sucht der Debugger vor dem aktuellen Code-Speicherort nach dem Aufruf von .catch() und geht davon aus, dass die Ablehnung letztendlich verarbeitet wird.

Zusammenfassung

Wir hoffen, dass diese Erklärung Ihnen einen Einblick in die Funktionsweise der Fehlervorhersage in den Chrome-Entwicklertools, ihre Stärken und ihre Einschränkungen gegeben hat. Wenn Sie aufgrund von falschen Vorhersagen Probleme bei der Fehlerbehebung haben, haben Sie folgende Möglichkeiten:

  • Ändern Sie das Codierungsmuster in etwas, das einfacher vorhersehbar ist, z. B. durch die Verwendung von asynchronen Funktionen.
  • Wählen Sie diese Option aus, wenn die Entwicklertools nicht wie vorgesehen beendet werden.
  • Verwenden Sie einen Haltepunkt vom Typ „Hier nie anhalten“ oder einen bedingten Haltepunkt, wenn der Debugger an einer Stelle anhält, an der er das nicht soll.

Danksagungen

Unser besonderer Dank geht an Sofia Emelianova und Jecelyn Yeen für ihre wertvolle Hilfe bei der Bearbeitung dieses Beitrags.