Men-debug kebocoran memori di WebAssembly menggunakan Emscripten

Meskipun JavaScript cukup toleran dalam membersihkan dirinya sendiri, bahasa statis jelas tidak…

Squoosh.app adalah PWA yang menggambarkan seberapa banyak codec dan setelan gambar yang berbeda dapat meningkatkan ukuran file gambar tanpa memengaruhi kualitas secara signifikan. Namun, ini juga adalah demo teknis yang menunjukkan cara mengambil library yang ditulis dalam C++ atau Rust dan membawanya ke web.

Kemampuan untuk mem-porting kode dari ekosistem yang ada sangatlah berharga, tetapi ada beberapa perbedaan utama antara bahasa statis tersebut dan JavaScript. Salah satunya adalah dalam pendekatan yang berbeda terhadap pengelolaan memori.

Meskipun JavaScript cukup toleran dalam membersihkan dirinya sendiri, bahasa statis tersebut pasti tidak. Anda harus meminta memori yang dialokasikan baru secara eksplisit dan Anda benar-benar harus memastikan bahwa Anda mengembalikannya setelah itu, dan tidak pernah menggunakannya lagi. Jika hal itu tidak terjadi, Anda akan mengalami kebocoran… dan hal ini sebenarnya terjadi cukup sering. Mari kita lihat cara men-debug kebocoran memori tersebut dan, lebih baik lagi, cara mendesain kode untuk menghindarinya di lain waktu.

Pola yang mencurigakan

Baru-baru ini, saat mulai mengerjakan Squoosh, saya tidak dapat tidak memperhatikan pola yang menarik dalam wrapper codec C++. Mari kita lihat wrapper ImageQuant sebagai contoh (dikurangi untuk hanya menampilkan bagian pembuatan dan pengalokasian ulang objek):

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript (atau TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

Apakah Anda melihat masalah? Petunjuk: ini adalah use-after-free, tetapi dalam JavaScript.

Di Emscripten, typed_memory_view menampilkan Uint8Array JavaScript yang didukung oleh buffering memori WebAssembly (Wasm), dengan byteOffset dan byteLength ditetapkan ke pointer dan panjang yang diberikan. Poin utama adalah bahwa ini adalah tampilan TypedArray ke buffer memori WebAssembly, bukan salinan data milik JavaScript.

Saat kita memanggil free_result dari JavaScript, fungsi ini akan memanggil fungsi C standar free untuk menandai memori ini sebagai tersedia untuk alokasi mendatang, yang berarti data yang ditunjuk oleh tampilan Uint8Array kita, dapat ditimpa dengan data arbitrer oleh panggilan mendatang ke Wasm.

Atau, beberapa implementasi free bahkan mungkin memutuskan untuk langsung mengisi memori yang dibebaskan dengan nol. free yang digunakan Emscripten tidak melakukannya, tetapi kita mengandalkan detail implementasi di sini yang tidak dapat dijamin.

Atau, meskipun memori di belakang pointer dipertahankan, alokasi baru mungkin perlu meningkatkan memori WebAssembly. Saat WebAssembly.Memory dikembangkan melalui JavaScript API, atau petunjuk memory.grow yang sesuai, WebAssembly.Memory akan membatalkan ArrayBuffer yang ada dan, secara transitif, tampilan apa pun yang didukung olehnya.

Mari kita gunakan konsol DevTools (atau Node.js) untuk menunjukkan perilaku ini:

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

Terakhir, meskipun kita tidak memanggil Wasm secara eksplisit lagi antara free_result dan new Uint8ClampedArray, pada suatu saat kita mungkin menambahkan dukungan multithreading ke codec. Dalam hal ini, thread yang benar-benar berbeda dapat menimpa data tepat sebelum kita berhasil meng-clone-nya.

Mencari bug memori

Untuk berjaga-jaga, saya memutuskan untuk melakukan lebih lanjut dan memeriksa apakah kode ini menunjukkan masalah dalam praktiknya. Ini sepertinya merupakan kesempatan yang tepat untuk mencoba dukungan pembersih Emscripten yang baru-baru ini ditambahkan tahun lalu dan dipresentasikan dalam diskusi WebAssembly kami di Chrome Dev Summit:

Dalam hal ini, kita tertarik dengan AddressSanitizer, yang dapat mendeteksi berbagai masalah terkait pointer dan memori. Untuk menggunakannya, kita perlu mengompilasi ulang codec dengan -fsanitize=address:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

Tindakan ini akan otomatis mengaktifkan pemeriksaan keamanan pointer, tetapi kita juga ingin menemukan potensi kebocoran memori. Karena kita menggunakan ImageQuant sebagai library, bukan program, tidak ada "titik keluar" tempat Emscripten dapat memvalidasi secara otomatis bahwa semua memori telah dibebaskan.

Sebagai gantinya, untuk kasus tersebut, LeakSanitizer (disertakan dalam AddressSanitizer) menyediakan fungsi __lsan_do_leak_check dan __lsan_do_recoverable_leak_check, yang dapat dipanggil secara manual setiap kali kita mengharapkan semua memori dibebaskan dan ingin memvalidasi asumsi tersebut. __lsan_do_leak_check dimaksudkan untuk digunakan di akhir aplikasi yang sedang berjalan, saat Anda ingin membatalkan proses jika kebocoran terdeteksi, sedangkan __lsan_do_recoverable_leak_check lebih cocok untuk kasus penggunaan library seperti kita, saat Anda ingin mencetak kebocoran ke konsol, tetapi tetap jalankan aplikasi.

Mari kita ekspos helper kedua tersebut melalui Embind sehingga kita dapat memanggilnya dari JavaScript kapan saja:

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

Dan panggil dari sisi JavaScript setelah kita selesai dengan gambar. Melakukannya dari sisi JavaScript, bukan C++, membantu memastikan bahwa semua cakupan telah keluar dan semua objek C++ sementara telah dibebaskan pada saat kita menjalankan pemeriksaan tersebut:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

Tindakan ini akan memberi kita laporan seperti berikut di konsol:

Screenshot pesan

Uh-oh, ada beberapa kebocoran kecil, tetapi stacktrace tidak terlalu membantu karena semua nama fungsi teracak. Mari kita kompilasi ulang dengan info proses debug dasar untuk mempertahankannya:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

Tampilannya akan jauh lebih baik:

Screenshot pesan yang bertuliskan &#39;Kebocoran langsung 12 byte&#39; yang berasal dari fungsi GenericBindingType RawImage ::toWireType

Beberapa bagian stacktrace masih terlihat tidak jelas karena mengarah ke internal Emscripten, tetapi kita dapat mengetahui bahwa kebocoran berasal dari konversi RawImage ke "jenis kabel" (ke nilai JavaScript) oleh Embind. Memang, saat melihat kode, kita dapat melihat bahwa kita menampilkan instance C++ RawImage ke JavaScript, tetapi kita tidak pernah membebaskannya di kedua sisi.

Sebagai pengingat, saat ini tidak ada integrasi pembersihan sampah antara JavaScript dan WebAssembly, meskipun integrasi sedang dikembangkan. Sebagai gantinya, Anda harus membebaskan memori secara manual dan memanggil destruktor dari sisi JavaScript setelah selesai dengan objek. Khusus untuk Embind, dokumen resmi menyarankan untuk memanggil metode .delete() pada class C++ yang diekspos:

Kode JavaScript harus menghapus secara eksplisit handle objek C++ yang telah diterima, atau heap Emscripten akan terus bertambah.

var x = new Module.MyClass;
x.method();
x.delete();

Memang, saat kita melakukannya di JavaScript untuk class kita:

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

Kebocoran akan hilang seperti yang diharapkan.

Menemukan lebih banyak masalah dengan pembersih

Mem-build codec Squoosh lainnya dengan pembersih mengungkapkan masalah serupa serta beberapa masalah baru. Misalnya, saya mendapatkan error ini dalam binding MozJPEG:

Screenshot pesan

Di sini, ini bukan kebocoran, tetapi kita menulis ke memori di luar batas yang dialokasikan 😱

Setelah mempelajari kode MozJPEG, kami mendapati bahwa masalahnya adalah jpeg_mem_dest—fungsi yang kita gunakan untuk mengalokasikan tujuan memori untuk JPEG—menggunakan kembali nilai outbuffer dan outsize yang ada jika nilainya bukan nol:

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

Namun, kita memanggilnya tanpa melakukan inisialisasi pada salah satu variabel tersebut, yang berarti MozJPEG menulis hasilnya ke alamat memori yang berpotensi acak yang kebetulan disimpan dalam variabel tersebut pada saat panggilan.

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

Melakukan inisialisasi nol pada kedua variabel sebelum pemanggilan akan menyelesaikan masalah ini, dan sekarang kode mencapai pemeriksaan kebocoran memori. Untungnya, pemeriksaan berhasil lulus, yang menunjukkan bahwa kita tidak memiliki kebocoran dalam codec ini.

Masalah terkait status bersama

…Atau apakah kita melakukannya?

Kita tahu bahwa binding codec menyimpan beberapa status serta menghasilkan variabel statis global, dan MozJPEG memiliki beberapa struktur yang sangat rumit.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

Bagaimana jika beberapa di antaranya diinisialisasi secara lambat pada pengoperasian pertama, lalu digunakan kembali dengan tidak benar pada pengoperasian mendatang? Kemudian, satu panggilan dengan pembersih tidak akan melaporkannya sebagai bermasalah.

Mari kita coba dan proses gambar beberapa kali dengan mengklik secara acak pada berbagai tingkat kualitas di UI. Memang, sekarang kita mendapatkan laporan berikut:

Screenshot pesan

262.144 byte—sepertinya seluruh gambar sampel bocor dari jpeg_finish_compress.

Setelah memeriksa dokumen dan contoh resmi, ternyata jpeg_finish_compress tidak membebaskan memori yang dialokasikan oleh panggilan jpeg_mem_dest sebelumnya—hanya membebaskan struktur kompresi, meskipun struktur kompresi tersebut sudah mengetahui tujuan memori kita… Sungguh merepotkan.

Kita dapat memperbaikinya dengan mengosongkan data secara manual dalam fungsi free_result:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

Saya dapat terus memburu bug memori tersebut satu per satu, tetapi saya rasa sekarang sudah cukup jelas bahwa pendekatan saat ini untuk pengelolaan memori menyebabkan beberapa masalah sistematis yang buruk.

Beberapa di antaranya dapat langsung terdeteksi oleh pembersih. Lainnya memerlukan trik rumit untuk ditangkap. Terakhir, ada masalah seperti di awal postingan yang, seperti yang dapat kita lihat dari log, tidak terdeteksi sama sekali oleh sanitizer. Alasannya adalah penyalahgunaan yang sebenarnya terjadi di sisi JavaScript, yang tidak dapat dilihat oleh pembersih. Masalah tersebut hanya akan muncul dalam produksi atau setelah perubahan yang tampaknya tidak terkait pada kode di masa mendatang.

Membuat wrapper yang aman

Mari kita mundur beberapa langkah, dan perbaiki semua masalah ini dengan menyusun ulang kode dengan cara yang lebih aman. Saya akan menggunakan wrapper ImageQuant sebagai contoh lagi, tetapi aturan pemfaktoran ulang yang serupa berlaku untuk semua codec, serta codebase serupa lainnya.

Pertama-tama, mari kita perbaiki masalah use-after-free dari awal postingan. Untuk itu, kita perlu meng-clone data dari tampilan yang didukung WebAssembly sebelum menandainya sebagai gratis di sisi JavaScript:

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

Sekarang, mari kita pastikan bahwa kita tidak membagikan status apa pun dalam variabel global di antara pemanggilan. Tindakan ini akan memperbaiki beberapa masalah yang telah kita lihat, serta akan mempermudah penggunaan codec di lingkungan multi-thread pada masa mendatang.

Untuk melakukannya, kita memfaktorkan ulang wrapper C++ untuk memastikan bahwa setiap panggilan ke fungsi mengelola datanya sendiri menggunakan variabel lokal. Kemudian, kita dapat mengubah tanda tangan fungsi free_result untuk menerima kembali pointer:

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // …
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

Namun, karena kita sudah menggunakan Embind di Emscripten untuk berinteraksi dengan JavaScript, sebaiknya kita membuat API lebih aman dengan menyembunyikan detail pengelolaan memori C++ sepenuhnya.

Untuk itu, mari kita pindahkan bagian new Uint8ClampedArray(…) dari JavaScript ke sisi C++ dengan Embind. Kemudian, kita dapat menggunakannya untuk meng-clone data ke dalam memori JavaScript bahkan sebelum kembali dari fungsi:

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/* … */) {
val quantize(/* … */) {
  // …
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

Perhatikan bagaimana, dengan satu perubahan, kita memastikan bahwa array byte yang dihasilkan dimiliki oleh JavaScript dan tidak didukung oleh memori WebAssembly, dan juga menghapus wrapper RawImage yang bocor sebelumnya.

Sekarang JavaScript tidak perlu lagi khawatir untuk mengosongkan data, dan dapat menggunakan hasilnya seperti objek lain yang dikumpulkan sampah:

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

Hal ini juga berarti kita tidak perlu lagi binding free_result kustom di sisi C++:

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

Secara keseluruhan, kode wrapper kami menjadi lebih bersih dan aman secara bersamaan.

Setelah itu, saya melakukan beberapa peningkatan kecil lebih lanjut pada kode wrapper ImageQuant dan mereplikasi perbaikan pengelolaan memori serupa untuk codec lainnya. Jika tertarik dengan detail selengkapnya, Anda dapat melihat PR yang dihasilkan di sini: Perbaikan memori untuk codec C++.

Poin-poin penting

Pelajaran apa yang dapat kita pelajari dan bagikan dari pemfaktoran ulang ini yang dapat diterapkan ke codebase lain?

  • Jangan gunakan tampilan memori yang didukung oleh WebAssembly—terlepas dari bahasa yang digunakan untuk mem-build-nya—di luar satu pemanggilan. Anda tidak dapat mengandalkan data tersebut untuk bertahan lebih lama dari itu, dan Anda tidak akan dapat menangkap bug ini dengan cara konvensional, jadi jika Anda perlu menyimpan data untuk nanti, salin ke sisi JavaScript dan simpan di sana.
  • Jika memungkinkan, gunakan bahasa pengelolaan memori yang aman atau, setidaknya, wrapper jenis yang aman, bukan beroperasi pada pointer mentah secara langsung. Hal ini tidak akan menyelamatkan Anda dari bug di batas JavaScript ↔ WebAssembly, tetapi setidaknya akan mengurangi platform untuk bug yang berdiri sendiri oleh kode bahasa statis.
  • Apa pun bahasa yang Anda gunakan, jalankan kode dengan pembersih selama pengembangan—pembersih dapat membantu mendeteksi tidak hanya masalah dalam kode bahasa statis, tetapi juga beberapa masalah di seluruh batas JavaScript ↔ WebAssembly, seperti lupa memanggil .delete() atau meneruskan pointer yang tidak valid dari sisi JavaScript.
  • Jika memungkinkan, hindari mengekspos data dan objek yang tidak dikelola dari WebAssembly ke JavaScript sepenuhnya. JavaScript adalah bahasa yang melakukan pembersihan sampah, dan pengelolaan memori manual tidak umum di dalamnya. Hal ini dapat dianggap sebagai kebocoran abstraksi model memori bahasa tempat WebAssembly Anda di-build, dan pengelolaan yang salah mudah diabaikan dalam codebase JavaScript.
  • Hal ini mungkin sudah jelas, tetapi, seperti di codebase lainnya, hindari menyimpan status yang dapat diubah dalam variabel global. Anda tidak ingin men-debug masalah terkait penggunaan ulang di berbagai pemanggilan atau bahkan thread, jadi sebaiknya pertahankan semandiri mungkin.