Prediksi tangkapan di Chrome DevTools: Mengapa sulit dan cara membuatnya lebih baik

Eric Leese
Eric Leese

Men-debug pengecualian di aplikasi web tampaknya sederhana: jeda eksekusi saat terjadi masalah dan selidiki. Namun, sifat asinkron JavaScript membuat hal ini menjadi sangat kompleks. Bagaimana Chrome DevTools dapat mengetahui kapan dan di mana harus menjeda saat pengecualian muncul melalui promise dan fungsi asinkron?

Postingan ini membahas tantangan prediksi tangkapan – kemampuan DevTools untuk mengantisipasi apakah pengecualian akan tertangkap nanti dalam kode Anda. Kita akan mempelajari alasan proses ini sangat rumit dan bagaimana peningkatan terbaru di V8 (mesin JavaScript yang mendukung Chrome) membuatnya lebih akurat, sehingga menghasilkan pengalaman proses debug yang lebih lancar.

Pentingnya prediksi tangkapan 

Di Chrome DevTools, Anda memiliki opsi untuk menjeda eksekusi kode hanya untuk pengecualian yang tidak tertangkap, dengan melewati pengecualian yang tertangkap. 

Chrome DevTools menyediakan opsi terpisah untuk menjeda pada pengecualian yang tertangkap atau tidak tertangkap

Di balik layar, debugger akan langsung berhenti saat terjadi pengecualian untuk mempertahankan konteks. Ini adalah prediksi karena, pada saat ini, tidak mungkin untuk mengetahui dengan pasti apakah pengecualian akan tertangkap atau tidak nanti dalam kode, terutama dalam skenario asinkron. Ketidakpastian ini berasal dari kesulitan yang melekat dalam memprediksi perilaku program, mirip dengan Masalah Penghentian.

Perhatikan contoh berikut: di mana debugger harus dijeda? (Cari jawabannya di bagian berikutnya.)

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

Menjeda pengecualian di debugger dapat mengganggu dan menyebabkan gangguan yang sering terjadi serta melompat ke kode yang tidak dikenal. Untuk mengurangi hal ini, Anda dapat memilih untuk hanya men-debug pengecualian yang tidak tertangkap, yang lebih cenderung menandakan bug sebenarnya. Namun, hal ini bergantung pada akurasi prediksi tangkapan.

Prediksi yang salah menyebabkan frustrasi:

  • Negatif palsu (memprediksi "tidak tertangkap" saat akan tertangkap). Perhentian yang tidak diperlukan di debugger.
  • Positif palsu (memprediksi "tertangkap" saat tidak akan tertangkap). Peluang yang terlewatkan untuk menangkap error kritis, yang berpotensi memaksa Anda men-debug semua pengecualian, termasuk yang diharapkan.

Metode lain untuk mengurangi gangguan proses debug adalah dengan menggunakan daftar abaikan, yang mencegah jeda pada pengecualian dalam kode pihak ketiga yang ditentukan.  Namun, prediksi tangkapan yang akurat tetap penting di sini. Jika pengecualian yang berasal dari kode pihak ketiga lolos dan memengaruhi kode Anda sendiri, Anda harus dapat men-debugnya.

Cara kerja kode asinkron

Promise, async, dan await, serta pola asinkron lainnya dapat menyebabkan skenario saat pengecualian atau penolakan, sebelum ditangani, dapat mengambil jalur eksekusi yang sulit ditentukan pada saat pengecualian ditampilkan. Hal ini karena promise mungkin tidak ditunggu atau pengendali tangkapan tidak ditambahkan hingga setelah pengecualian terjadi. Mari kita lihat contoh sebelumnya:

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

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

Dalam contoh ini, outer() pertama-tama memanggil inner() yang langsung menampilkan pengecualian. Dari sini, debugger dapat menyimpulkan bahwa inner() akan menampilkan promise yang ditolak, tetapi saat ini tidak ada yang menunggu atau menangani promise tersebut. Debugger dapat menebak bahwa outer() mungkin akan menunggunya dan menebak bahwa outer() akan melakukannya di blok try saat ini sehingga dapat menanganinya, tetapi debugger tidak dapat memastikan hal ini sampai setelah promise yang ditolak ditampilkan dan pernyataan await akhirnya tercapai.

Debugger tidak dapat menawarkan jaminan bahwa prediksi tangkapan akan akurat, tetapi menggunakan berbagai heuristik untuk pola coding umum agar dapat memprediksi dengan benar. Untuk memahami pola ini, sebaiknya pelajari cara kerja promise.

Di V8, Promise JavaScript direpresentasikan sebagai objek yang dapat berada dalam salah satu dari tiga status: terpenuhi, ditolak, atau tertunda. Jika promise berada dalam status terpenuhi dan Anda memanggil metode .then(), promise tertunda baru akan dibuat dan tugas reaksi promise baru akan dijadwalkan yang akan menjalankan pengendali, lalu menetapkan promise ke terpenuhi dengan hasil pengendali atau menetapkannya ke ditolak jika pengendali menampilkan pengecualian. Hal yang sama terjadi jika Anda memanggil metode .catch() pada promise yang ditolak. Sebaliknya, memanggil .then() pada promise yang ditolak atau .catch() pada promise yang terpenuhi akan menampilkan promise dalam status yang sama dan tidak menjalankan pengendali. 

Promise yang tertunda berisi daftar reaksi dengan setiap objek reaksi berisi pengendali fulfillment atau pengendali penolakan (atau keduanya) dan promise reaksi. Jadi, memanggil .then() pada promise tertunda akan menambahkan reaksi dengan pengendali yang terpenuhi serta promise tertunda baru untuk promise reaksi, yang akan ditampilkan .then(). Memanggil .catch() akan menambahkan reaksi serupa, tetapi dengan pengendali penolakan. Memanggil .then() dengan dua argumen akan membuat reaksi dengan kedua pengendali, dan memanggil .finally() atau menunggu promise akan menambahkan reaksi dengan dua pengendali yang merupakan fungsi bawaan khusus untuk menerapkan fitur ini.

Jika promise yang tertunda akhirnya terpenuhi atau ditolak, tugas reaksi akan dijadwalkan untuk semua pengendali yang terpenuhi atau semua pengendali yang ditolak. Promise reaksi yang sesuai kemudian akan diperbarui, yang berpotensi memicu tugas reaksinya sendiri.

Contoh

Pertimbangkan kode berikut:

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

Mungkin tidak jelas bahwa kode ini melibatkan tiga objek Promise yang berbeda. Kode di atas setara dengan kode berikut:

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

Dalam contoh ini, langkah-langkah berikut terjadi:

  1. Konstruktor Promise dipanggil.
  2. Promise tertunda baru dibuat.
  3. Fungsi anonim dijalankan.
  4. Pengecualian ditampilkan. Pada tahap ini, debugger perlu memutuskan apakah akan berhenti atau tidak.
  5. Konstruktor promise menangkap pengecualian ini, lalu mengubah status janjinya menjadi rejected dengan nilainya ditetapkan ke error yang ditampilkan. Metode ini menampilkan promise ini, yang disimpan di promise1.
  6. .then() tidak menjadwalkan tugas reaksi karena promise1 dalam status rejected. Sebagai gantinya, promise baru (promise2) akan ditampilkan, yang juga dalam status ditolak dengan error yang sama.
  7. .catch() menjadwalkan tugas reaksi dengan pengendali yang disediakan dan promise reaksi tertunda baru, yang ditampilkan sebagai promise3. Pada tahap ini, debugger mengetahui bahwa error akan ditangani.
  8. Saat tugas reaksi berjalan, pengendali akan ditampilkan secara normal dan status promise3 akan diubah menjadi fulfilled.

Contoh berikutnya memiliki struktur yang serupa, tetapi eksekusinya sangat berbeda:

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

Hal ini setara dengan:

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;

Dalam contoh ini, langkah-langkah berikut terjadi:

  1. Promise dibuat dalam status fulfilled dan disimpan di promise1.
  2. Tugas reaksi promise dijadwalkan dengan fungsi anonim pertama dan promise reaksi (pending)-nya ditampilkan sebagai promise2.
  3. Reaksi ditambahkan ke promise2 dengan pengendali yang terpenuhi dan promise reaksinya, yang ditampilkan sebagai promise3.
  4. Reaksi ditambahkan ke promise3 dengan pengendali yang ditolak dan promise reaksi lain, yang ditampilkan sebagai promise4.
  5. Tugas reaksi yang dijadwalkan pada langkah 2 akan dijalankan.
  6. Pengendali menampilkan pengecualian. Pada tahap ini, debugger perlu memutuskan apakah akan berhenti atau tidak. Saat ini, pengendali adalah satu-satunya kode JavaScript yang berjalan.
  7. Karena tugas berakhir dengan pengecualian, promise reaksi terkait (promise2) ditetapkan ke status ditolak dengan nilainya ditetapkan ke error yang ditampilkan.
  8. Karena promise2 memiliki satu reaksi, dan reaksi tersebut tidak memiliki pengendali yang ditolak, promise reaksinya (promise3) juga ditetapkan ke rejected dengan error yang sama.
  9. Karena promise3 memiliki satu reaksi, dan reaksi tersebut memiliki pengendali yang ditolak, tugas reaksi promise dijadwalkan dengan pengendali tersebut dan promise reaksinya (promise4).
  10. Saat tugas reaksi tersebut berjalan, pengendali akan ditampilkan secara normal dan status promise4 akan diubah menjadi terpenuhi.

Metode untuk prediksi tangkapan

Ada dua potensi sumber informasi untuk prediksi tangkapan. Salah satunya adalah stack panggilan. Hal ini valid untuk pengecualian sinkron: debugger dapat menelusuri stack panggilan dengan cara yang sama seperti kode unwinding pengecualian dan berhenti jika menemukan frame yang berada dalam blok try...catch. Untuk promise atau pengecualian yang ditolak dalam konstruktor promise atau dalam fungsi asinkron yang belum pernah ditangguhkan, debugger juga mengandalkan stack panggilan, tetapi dalam hal ini, prediksinya tidak dapat diandalkan dalam semua kasus. Hal ini karena kode asinkron akan menampilkan pengecualian yang ditolak, bukan menampilkan pengecualian ke pengendali terdekat, dan debugger harus membuat beberapa asumsi tentang apa yang akan dilakukan pemanggil dengan pengecualian tersebut.

Pertama, debugger mengasumsikan bahwa fungsi yang menerima promise yang ditampilkan kemungkinan akan menampilkan promise tersebut atau promise turunan sehingga fungsi asinkron di stack yang lebih tinggi akan memiliki peluang untuk menunggunya. Kedua, debugger mengasumsikan bahwa jika promise ditampilkan ke fungsi asinkron, promise tersebut akan segera menunggunya tanpa terlebih dahulu memasuki atau keluar dari blok try...catch. Tidak satu pun dari asumsi ini yang dijamin benar, tetapi cukup untuk membuat prediksi yang benar untuk pola coding yang paling umum dengan fungsi asinkron. Di Chrome versi 125, kami menambahkan heuristik lain: debugger memeriksa apakah pemanggil akan memanggil .catch() pada nilai yang akan ditampilkan (atau .then() dengan dua argumen, atau rantai panggilan ke .then() atau .finally() diikuti dengan .catch() atau .then() dua argumen). Dalam hal ini, debugger mengasumsikan bahwa ini adalah metode pada promise yang kita lacak atau yang terkait dengannya, sehingga penolakan akan tertangkap.

Sumber informasi kedua adalah hierarki reaksi promise. Debugger dimulai dengan promise root. Terkadang ini adalah promise yang metode reject()-nya baru saja dipanggil. Lebih umum, saat pengecualian atau penolakan terjadi selama tugas reaksi promise, dan tidak ada yang muncul di stack panggilan untuk menangkapnya, debugger akan melacak dari promise yang terkait dengan reaksi. Debugger melihat semua reaksi pada promise yang tertunda dan melihat apakah reaksi tersebut memiliki pengendali penolakan. Jika ada reaksi yang tidak, fungsi ini akan melihat promise reaksi dan melacaknya secara rekursif. Jika semua reaksi pada akhirnya mengarah ke pengendali penolakan, debugger akan menganggap penolakan promise tertangkap. Ada beberapa kasus khusus yang harus dibahas, misalnya, tidak menghitung pengendali penolakan bawaan untuk panggilan .finally().

Hierarki reaksi promise biasanya memberikan sumber informasi yang andal jika informasi tersebut ada. Dalam beberapa kasus, seperti panggilan ke Promise.reject() atau dalam konstruktor Promise atau dalam fungsi asinkron yang belum menunggu apa pun, tidak akan ada reaksi untuk dilacak dan debugger harus mengandalkan stack panggilan saja. Dalam kasus lain, hierarki reaksi promise biasanya tidak berisi pengendali yang diperlukan untuk menyimpulkan prediksi tangkapan, tetapi selalu ada kemungkinan bahwa lebih banyak pengendali akan ditambahkan nanti yang akan mengubah pengecualian dari tertangkap menjadi tidak tertangkap atau sebaliknya. Ada juga promise seperti yang dibuat oleh Promise.all/any/race, dengan promise lain dalam grup dapat memengaruhi cara penolakan ditangani. Untuk metode ini, debugger mengasumsikan penolakan promise akan diteruskan jika promise masih tertunda.

Lihat dua contoh berikut:

Dua contoh untuk prediksi tangkapan

Meskipun kedua contoh pengecualian yang tertangkap ini terlihat serupa, keduanya memerlukan heuristik prediksi penangkapan yang sangat berbeda. Pada contoh pertama, promise yang di-resolve dibuat, lalu tugas reaksi untuk .then() dijadwalkan yang akan menampilkan pengecualian, lalu .catch() dipanggil untuk melampirkan pengendali penolakan ke promise reaksi. Saat tugas reaksi dijalankan, pengecualian akan ditampilkan, dan hierarki reaksi promise akan berisi pengendali tangkapan, sehingga akan terdeteksi sebagai tertangkap. Pada contoh kedua, promise segera ditolak sebelum kode untuk menambahkan pengendali tangkapan dijalankan, sehingga tidak ada pengendali penolakan di hierarki reaksi promise. Debugger harus melihat stack panggilan, tetapi tidak ada blok try...catch juga. Untuk memprediksinya dengan benar, debugger akan memindai di depan lokasi saat ini dalam kode untuk menemukan panggilan ke .catch(), dan mengasumsikan bahwa penolakan pada akhirnya akan ditangani.

Ringkasan

Semoga penjelasan ini telah menjelaskan cara kerja prediksi tangkapan di Chrome DevTools, kelebihannya, dan keterbatasannya. Jika Anda mengalami masalah proses debug karena prediksi yang salah, pertimbangkan opsi berikut:

  • Ubah pola coding menjadi sesuatu yang lebih mudah diprediksi, seperti menggunakan fungsi asinkron.
  • Pilih untuk berhenti pada semua pengecualian jika DevTools gagal berhenti pada waktu yang seharusnya.
  • Gunakan titik henti sementara "Never pause here" atau titik henti sementara bersyarat jika debugger berhenti di tempat yang tidak Anda inginkan.

Ucapan terima kasih

Terima kasih banyak kepada Sofia Emelianova dan Jecelyn Yeen atas bantuan mereka yang sangat berharga dalam mengedit postingan ini.