Men-debug kebocoran memori di WebAssembly menggunakan Emscripten

Meskipun JavaScript cukup memaafkan pembersihan setelah dirinya sendiri, bahasa statis jelas bukan ...

Squoosh.app adalah PWA yang menggambarkan betapa berbedanya codec gambar dan setelan dapat meningkatkan ukuran file gambar tanpa memengaruhi kualitas secara signifikan. Namun, {i>metadata<i} demo teknis yang menunjukkan bagaimana Anda dapat mengambil {i>library<i} yang ditulis dalam C++ atau Rust dan membawanya ke web.

Mampu mentransfer kode dari ekosistem yang ada sangat berharga, tetapi ada beberapa kunci perbedaan antara bahasa statis dan JavaScript. Salah satunya ada di tentang manajemen memori.

Meskipun JavaScript cukup memaafkan dalam membersihkan setelah dirinya sendiri, bahasa statis seperti itu tentu saja tidak. Anda perlu secara eksplisit meminta alokasi memori baru dan Anda benar-benar perlu pastikan Anda mengirimkannya kembali setelah itu, dan tidak pernah menggunakannya lagi. Jika itu tidak terjadi, Anda akan mendapatkan kebocoran... dan itu sebenarnya terjadi cukup sering. Mari kita lihat bagaimana Anda dapat men-debug kebocoran memori tersebut dan, lebih baik lagi, bagaimana Anda dapat merancang kode Anda untuk menghindarinya di lain waktu.

Pola mencurigakan

Baru-baru ini, ketika mulai mengerjakan Squoosh, saya tidak bisa tidak melihat sebuah pola yang menarik di Wrapper codec C++. Mari kita lihat wrapper ImageQuant sebagai contoh (dikurangi untuk hanya menampilkan bagian pembuatan dan dealokasi 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 (yah, 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 menemukan masalah? Petunjuk: adalah use-after-free, tetapi dalam JavaScript!

Di Emscripten, typed_memory_view menampilkan Uint8Array JavaScript yang didukung oleh WebAssembly (Wasm) buffer memori, dengan byteOffset dan byteLength disetel ke pointer dan panjang yang diberikan. Utama adalah bahwa ini adalah tampilan TypedArray ke dalam buffer memori WebAssembly, bukan Salinan data yang dimiliki 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 dilihat oleh Uint8Array dapat ditimpa dengan data arbitrer oleh panggilan mendatang ke Wasm.

Atau, beberapa implementasi free bahkan mungkin memutuskan untuk langsung mengosongkan memori yang dikosongkan. Tujuan free yang digunakan Emscripten tidak melakukan hal itu, tetapi kita mengandalkan detail implementasi di sini yang tidak dapat dijamin.

Atau, bahkan jika memori di belakang pointer dipertahankan, alokasi baru mungkin perlu meningkatkan Memori WebAssembly. Saat WebAssembly.Memory dikembangkan melalui JavaScript API, atau terkait memory.grow, akan membatalkan ArrayBuffer yang ada dan, secara transitif, semua tampilan didukung olehnya.

Izinkan saya menggunakan 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 secara eksplisit memanggil Wasm lagi antara free_result dan new Uint8ClampedArray, pada suatu saat kita mungkin menambahkan dukungan multithreading ke codec. Dalam hal ini, dapat berupa thread yang sama sekali berbeda yang menimpa data sebelum kita berhasil mengkloningnya.

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 tepat untuk mencoba Emscripten sanitizers yang baru(ish) dukungan yang telah ditambahkan tahun lalu dan dipresentasikan dalam pembicaraan WebAssembly di Chrome Dev Summit:

Dalam hal ini, kita tertarik pada AddressSanitizer, yang dapat mendeteksi berbagai masalah terkait pointer dan memori. Untuk menggunakannya, kita perlu mengompilasi ulang codec kita 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 memori kebocoran informasi. Karena kita menggunakan ImageQuant sebagai library dan bukan program, tidak ada "titik keluar" pada pukul yang Emscripten dapat secara otomatis memvalidasi bahwa semua memori telah dibebaskan.

Sebagai gantinya, untuk kasus seperti ini, LeakSanitizer (disertakan dalam AddressSanitizer) menyediakan fungsi __lsan_do_leak_check dan __lsan_do_recoverable_leak_check, yang dapat dipanggil secara manual kapan pun kita mengharapkan semua memori akan dibebaskan dan ingin memvalidasi menggunakan asumsi. __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, ketika Anda ingin mencetak kebocoran ke konsol, tetapi tetap menjaga aplikasi tetap berjalan.

Mari kita ekspos helper kedua itu 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. Proses ini dilakukan dari Sisi JavaScript, bukan C++, membantu memastikan bahwa semua cakupan telah keluar 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
  );
}

Langkah 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; berasal dari fungsi GenericBindingType RawImage ::toWireType

Beberapa bagian dari stacktrace masih terlihat tidak jelas saat mengarah ke internal Emscripten, tetapi kita dapat beri tahu bahwa kebocoran berasal dari konversi RawImage ke "jenis kabel" (ke nilai JavaScript) dengan Timbul. Bahkan, jika kita melihat kodenya, kita bisa lihat bahwa kita menampilkan instance C++ RawImage ke JavaScript, tetapi kita tidak pernah menghapusnya di kedua sisi.

Sebagai pengingat, saat ini tidak ada integrasi pembersihan sampah memori antara JavaScript dan WebAssembly, meskipun salah satunya sedang dikembangkan. Sebagai gantinya, Anda memiliki untuk mengosongkan memori dan memanggil {i> destructor<i} dari sisi JavaScript secara manual setelah Anda selesai . Khusus untuk Embind, laman resmi dokumen sarankan untuk memanggil metode .delete() pada class C++ yang terekspos:

Kode JavaScript harus secara eksplisit menghapus semua handle objek C++ yang diterimanya, atau kode Emscripten heap akan berkembang tanpa batas.

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

Tentunya, 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 hilang seperti yang diharapkan.

Menemukan lebih banyak masalah terkait pembersih

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

Screenshot pesan

Di sini, kasusnya bukan kebocoran, tapi kita menulis ke memori di luar batas yang dialokasikan apabila

Dengan menggali kode MozJPEG, kita menemukan bahwa masalahnya di sini adalah jpeg_mem_dest— yang digunakan untuk mengalokasikan tujuan memori untuk JPEG—menggunakan kembali nilai outbuffer dan outsize saat 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 menginisialisasi salah satu variabel tersebut, yang berarti MozJPEG menulis menghasilkan alamat memori yang berpotensi acak yang kebetulan disimpan dalam variabel tersebut pada waktunya panggilan!

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

Tidak melakukan inisialisasi kedua variabel sebelum pemanggilan menyelesaikan masalah ini, dan sekarang kode mencapai pemeriksaan kebocoran memori. Untungnya, pemeriksaan berhasil, menunjukkan bahwa kita tidak memiliki kebocoran dalam codec ini.

Masalah terkait status bersama

...Atau kita?

Kita tahu bahwa binding codec menyimpan beberapa status serta hasil dalam statis variabel, 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 dari mereka diinisialisasi dengan lambat saat pertama kali dijalankan, lalu kemudian digunakan kembali dengan tidak benar di masa mendatang berlari? Lalu satu panggilan dengan pembersih udara tidak akan melaporkannya sebagai bermasalah.

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

Screenshot pesan

262.144 byte—tampaknya 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 sebelumnya—hal ini hanya mengosongkan struktur kompresi, meskipun struktur kompresi itu sudah mengetahui memori kita tujuan... Ya ampun.

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

Aku bisa terus memburu {i>bug<i} memori itu satu per satu, tapi saya pikir sekarang sudah cukup jelas bahwa pendekatan terhadap manajemen memori saat ini menyebabkan beberapa masalah sistematis yang buruk.

Beberapa kuman dapat langsung disemprotkan oleh pembersih. Sementara yang lainnya butuh trik rumit agar bisa ditangkap. Terakhir, terdapat masalah seperti di awal postingan bahwa, seperti yang bisa kita lihat di log, sama sekali tidak tertangkap oleh pembersih. Alasannya adalah bahwa penyalahgunaan yang sebenarnya terjadi pada sisi JavaScript, yang tidak terlihat oleh pembersih udara. Masalah-masalah itu akan terungkap hanya dalam produksi atau setelah perubahan kode yang tampak tidak terkait pada kode di masa mendatang.

Membuat wrapper yang aman

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

Pertama-tama, mari kita perbaiki masalah {i>use-after-free<i} dari awal postingan. Untuk itu, kita perlu untuk 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, pastikan kita tidak membagikan status apa pun dalam variabel global di antara pemanggilan. Ini keduanya akan memperbaiki beberapa masalah yang pernah kami alami, serta akan mempermudah penggunaan codec di lingkungan multi-thread di masa mendatang.

Untuk melakukannya, kita memfaktorkan ulang wrapper C++ untuk memastikan bahwa setiap panggilan ke fungsi mengelola kodenya sendiri data menggunakan variabel lokal. Kemudian, kita dapat mengubah tanda tangan fungsi free_result menjadi 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);
}

Tapi, karena kita sudah menggunakan Embind di Emscripten untuk berinteraksi dengan JavaScript, kita mungkin membuat API lebih aman dengan menyembunyikan detail manajemen memori C++!

Untuk itu, mari pindahkan bagian new Uint8ClampedArray(…) dari JavaScript ke sisi C++ dengan Timbul. 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 bahwa, dengan satu perubahan, kita berdua memastikan bahwa array byte yang dihasilkan dimiliki oleh JavaScript dan tidak didukung oleh memori WebAssembly, dan hapus wrapper RawImage sebelumnya yang bocor berikutnya

Sekarang JavaScript tidak perlu lagi mengkhawatirkan pengosongan data, dan dapat menggunakan hasil seperti objek pengumpulan sampah lainnya:

  // 

  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 pada 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 kita menjadi lebih bersih dan lebih aman pada saat yang sama.

Setelah ini saya melakukan beberapa perbaikan kecil lebih lanjut pada kode wrapper ImageQuant dan direplikasi perbaikan manajemen memori untuk codec lain. Jika Anda tertarik untuk lebih detail lebih lanjut, Anda dapat melihat PR yang dihasilkan di sini: Perbaikan memori untuk C++ codec.

Poin-poin penting

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

  • Jangan gunakan tampilan memori yang didukung oleh WebAssembly—apa pun bahasanya—di luar bahasa pemanggilan tunggal. Kamu tidak bisa mengandalkan mereka untuk bertahan hidup lebih lama dari itu, dan kamu tidak akan bisa menangkap {i>bug<i} ini dengan cara konvensional, jadi jika Anda perlu menyimpan data untuk nanti, salin ke sisi JavaScript dan menyimpannya di sana.
  • Jika memungkinkan, gunakan bahasa manajemen memori yang aman atau, setidaknya, wrapper jenis yang aman, bukan yang beroperasi pada pointer mentah secara langsung. Ini tidak akan menghindarkan Anda dari bug di JavaScript GDN WebAssembly tapi setidaknya hal ini akan mengurangi kemunculan bug yang berdiri sendiri oleh kode bahasa statis.
  • Apa pun bahasa yang Anda gunakan, jalankan kode dengan pembersih selama pengembangan. Kode ini dapat membantu menangkap tidak hanya masalah dalam kode bahasa statis, tetapi juga beberapa masalah di seluruh JavaScript Menjadikan Batas WebAssembly, seperti lupa memanggil .delete() atau meneruskan pointer yang tidak valid dari di sisi JavaScript.
  • Jika memungkinkan, hindari mengekspos data dan objek yang tidak dikelola dari WebAssembly ke JavaScript sepenuhnya. JavaScript adalah bahasa pemrograman sampah, dan manajemen memori manual tidak umum di dalamnya. Hal ini dapat dianggap sebagai kebocoran abstraksi dari model memori bahasa yang digunakan WebAssembly dibangun, dan pengelolaan yang salah mudah diabaikan di codebase JavaScript.
  • Hal ini mungkin sudah jelas, tetapi, seperti di codebase lainnya, hindari menyimpan status yang dapat diubah di variabel. Anda tidak ingin men-debug masalah dengan penggunaan kembali di berbagai pemanggilan atau bahkan utas, jadi sebaiknya usahakan untuk tetap mandiri.