Men-debug kebocoran memori di WebAssembly menggunakan Emscripten

Meskipun JavaScript cukup mudah dibersihkan, bahasa statis jelas tidak...

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

Kemampuan mentransfer kode dari ekosistem yang ada sangatlah berharga, tetapi ada beberapa perbedaan utama antara bahasa statis dan JavaScript tersebut. Salah satunya adalah pendekatan mereka yang berbeda terhadap manajemen memori.

Meskipun JavaScript cukup mudah dibersihkan, bahasa statis seperti itu jelas tidak. Anda harus secara eksplisit meminta memori baru yang dialokasikan dan benar-benar harus memastikan Anda mengembalikan memori setelahnya, dan tidak pernah menggunakannya lagi. Jika itu tidak terjadi, Anda mengalami kebocoran... dan itu benar-benar terjadi secara teratur. Mari kita lihat bagaimana Anda dapat men-debug kebocoran memori tersebut, dan lebih baik lagi, bagaimana Anda dapat mendesain kode untuk menghindarinya pada lain waktu.

Pola mencurigakan

Baru-baru ini, saat mulai mengerjakan Squoosh, saya melihat pola yang menarik dalam wrapper codec C++. Mari kita lihat wrapper ImageQuant sebagai contoh (dikurangi sehingga hanya menampilkan pembuatan objek dan dealokasi bagian):

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 (baik, 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 digunakan setelah gratis, tetapi dalam JavaScript.

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

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

Atau, beberapa implementasi free bahkan mungkin memutuskan untuk langsung mengisi nol memori yang dibebaskan. free yang digunakan Emscripten tidak melakukannya, tetapi di sini kami mengandalkan detail implementasi 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, tindakan ini akan membatalkan ArrayBuffer yang ada dan, secara transitif, semua tampilan yang didukung olehnya.

Saya akan menggunakan konsol DevTools (atau Node.js) untuk mendemonstrasikan 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 secara eksplisit memanggil Wasm lagi antara free_result dan new Uint8ClampedArray, pada waktu tertentu kita mungkin akan menambahkan dukungan multithreading ke codec kita. Dalam hal ini, thread tersebut dapat berupa thread yang sama sekali berbeda, yang menimpa data tepat sebelum kita berhasil meng-clone data tersebut.

Mencari bug memori

Untuk berjaga-jaga, saya telah memutuskan untuk melangkah lebih jauh dan memeriksa apakah kode ini menunjukkan masalah dalam praktiknya. Sepertinya ini adalah kesempatan yang sempurna untuk mencoba dukungan sanitizer Empscripten yang baru(ish) yang telah ditambahkan tahun lalu dan dipresentasikan dalam diskusi WebAssembly di Chrome Dev Summit:

Dalam hal ini, kita ingin menggunakan 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

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 otomatis memvalidasi bahwa semua memori telah dibebaskan.

Sebagai gantinya, untuk kasus semacam ini, 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 berjalan, saat Anda ingin membatalkan proses jika ada kebocoran yang terdeteksi, sedangkan __lsan_do_recoverable_leak_check lebih cocok untuk kasus penggunaan library seperti milik kita, saat Anda ingin mencetak kebocoran ke konsol, tetapi tetap memastikan aplikasi tetap berjalan.

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. Melakukan hal ini dari sisi JavaScript, bukan dari C++, membantu memastikan bahwa semua cakupan telah dikeluarkan dan semua objek C++ sementara dibebaskan pada saat kami 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 memberi kita laporan seperti berikut di konsol:

Screenshot pesan

Aduh, ada beberapa kebocoran kecil, tetapi stacktrace tidak terlalu membantu karena semua nama fungsi rusak. 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

Ini terlihat 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 memori antara JavaScript dan WebAssembly, meskipun salah satunya sedang dikembangkan. Sebaliknya, Anda harus mengosongkan memori dan memanggil destruktor secara manual dari sisi JavaScript setelah selesai dengan objek. Khusus untuk Embind, dokumen resmi menyarankan untuk memanggil metode .delete() pada class C++ yang terekspos:

Kode JavaScript harus secara eksplisit menghapus setiap objek C++ yang menanganinya yang diterima, atau heap Emscripten akan bertambah tanpa batas.

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

Memang, saat 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 terkait pembersih udara

Membuat codec Squoosh lainnya dengan pembersih akan mengungkap masalah serupa dan juga beberapa masalah baru. Misalnya, saya mendapatkan error ini di binding MozJPEG:

Screenshot pesan

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

Menggali kode MozJPEG, kami menemukan bahwa masalahnya di sini adalah jpeg_mem_dest—fungsi yang kita gunakan untuk mengalokasikan tujuan memori untuk JPEG—menggunakan kembali nilai outbuffer dan outsize yang ada saat bernilai 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, kami akan memanggilnya tanpa menginisialisasi salah satu variabel tersebut, yang berarti MozJPEG akan menulis hasilnya ke alamat memori berpotensi acak yang kebetulan tersimpan dalam variabel tersebut pada saat panggilan.

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

Inisialisasi kedua variabel sebelum pemanggilan 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 kita begitu?

Kita tahu bahwa binding codec menyimpan beberapa status serta hasil dalam 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 layanan tersebut diinisialisasi dengan lambat saat pertama kali dijalankan, lalu digunakan kembali dengan tidak benar pada proses selanjutnya? Satu panggilan dengan pembersih udara tidak akan melaporkannya sebagai bermasalah.

Mari kita coba dan proses gambar beberapa kali dengan mengklik secara acak pada tingkat kualitas yang berbeda di UI. Kini kita mendapatkan laporan berikut:

Screenshot pesan

262.144 byte—sepertinya seluruh gambar contoh bocor dari jpeg_finish_compress.

Setelah memeriksa dokumen dan contoh resmi, ternyata jpeg_finish_compress tidak mengosongkan memori yang dialokasikan oleh panggilan jpeg_mem_dest kita sebelumnya—ini hanya membebaskan struktur kompresi, meskipun struktur kompresi sudah mengetahui tentang tujuan memori kita... Yah.

Kita dapat memperbaikinya dengan mengosongkan data secara manual di 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 bisa terus memburu bug memori itu satu per satu, tetapi menurut saya sudah cukup jelas bahwa pendekatan pengelolaan memori saat ini menyebabkan beberapa masalah sistematis yang jahat.

Beberapa di antaranya dapat langsung ditangkap oleh pembersih. Yang lainnya membutuhkan trik rumit agar dapat ditangkap. Terakhir, ada masalah di awal postingan yang, seperti yang kita lihat dari log, tidak tertangkap sama sekali oleh sanitizer. Alasannya adalah penyalahgunaan yang sebenarnya terjadi di sisi JavaScript, yang membuat sanitizer tidak memiliki visibilitas. 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 merestrukturisasi kode dengan cara yang lebih aman. Saya akan menggunakan wrapper ImageQuant sebagai contoh lagi, tetapi aturan pemfaktoran ulang serupa berlaku untuk semua codec, serta codebase serupa lainnya.

Pertama-tama, mari kita perbaiki masalah {i> Use-after-free<i} dari awal postingan. Untuk itu, kita perlu meng-clone data dari tampilan yang didukung WebAssembly sebelum menandainya sebagai bebas 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 pastikan bahwa kita tidak membagikan status apa pun dalam variabel global di antara pemanggilan. Hal ini akan memperbaiki beberapa masalah yang pernah kita lihat, serta akan mempermudah penggunaan codec kita di lingkungan multi-thread di 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 pointer kembali:

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, kita mungkin juga akan menjadikan API ini lebih aman dengan menyembunyikan detail pengelolaan memori C++ sepenuhnya.

Untuk itu, mari 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 ditampilkan 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;
}

Perlu diketahui, 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 mengkhawatirkan pengosongan data, dan dapat menggunakan hasilnya seperti objek lainnya yang dibersihkan sampah memori:

  // …

  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);
}

Ini juga berarti kita tidak lagi memerlukan 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 menjadi lebih bersih dan aman pada saat yang sama.

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

Takeaway

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—apa pun bahasa pembuatannya—di luar satu pemanggilan. Anda tidak dapat mengandalkannya untuk bertahan lebih lama dari itu, dan Anda tidak akan dapat menangkap bug ini secara 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 menghindarkan Anda dari bug pada batas JavaScript Referensi WebAssembly, tetapi setidaknya akan mengurangi kemunculan bug yang ada di kode bahasa statis.
  • Apa pun bahasa yang Anda gunakan, jalankan kode dengan sanitizer selama pengembangan. Fungsi ini tidak hanya dapat membantu menangkap masalah dalam kode bahasa statis, tetapi juga beberapa masalah di seluruh batas JavaScript terkini di 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 sama sekali. JavaScript adalah bahasa yang mengumpulkan sampah, dan manajemen memori manual tidak umum di dalamnya. Hal ini dapat dianggap sebagai kebocoran abstraksi model memori bahasa yang digunakan WebAssembly Anda, dan pengelolaan yang salah mudah diabaikan dalam codebase JavaScript.
  • Hal ini mungkin sudah jelas, tetapi, seperti pada codebase lainnya, hindari menyimpan status yang dapat diubah dalam variabel global. Anda tidak ingin men-debug masalah terkait penggunaan ulangnya di berbagai pemanggilan atau bahkan thread, jadi sebaiknya jaga agar tetap mandiri.