Emscripten'ı kullanarak WebAssembly'de bellek sızıntılarının hatalarını ayıklama

JavaScript kendini temizlemeyi oldukça affedebilir olsa da statik diller kesinlikle değildir...

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app, kaliteyi önemli ölçüde etkilemeden resim dosyası boyutunu ne kadar farklı resim codec'lerinin ve ayarlarının iyileştirebileceğini gösteren bir PWA'dır. Ancak bu, aynı zamanda C++ veya Rust ile yazılmış kitaplıkları alıp web'e nasıl taşıyacağınızı gösteren teknik bir demodur.

Mevcut ekosistemlerden kod taşıyabilmek çok değerlidir ancak bu statik diller ile JavaScript arasında bazı önemli farklar vardır. Bunlardan biri, bellek yönetimine farklı yaklaşımlar getirmesi.

JavaScript, temizlik işlemini kendi kendine kolayca yapabilir ancak bu tür statik diller kesinlikle kabul edilmez. Yeni bir tahsis edilmiş bellek istemeniz ve bu belleği daha sonra geri vermeniz ve bir daha kullanmamanız gerekir. Bu olmazsa hava kaçağı yaşarsınız ve bu oldukça sık gerçekleşir. Bu bellek sızıntılarını nasıl ayıklayabileceğinize ve daha da iyisi, bir dahaki sefere bunlardan kaçınmak için kodunuzu nasıl tasarlayabileceğinize göz atalım.

Şüpheli kalıp

Kısa süre önce, Squoosh üzerinde çalışmaya başlarken C++ codec sarmalayıcılarında ilginç bir kalıp fark ettim. Örnek olarak bir ImageQuant sarmalayıcısına bakalım (yalnızca nesne oluşturma ve ayırma bölümlerini gösterecek şekilde azaltılmıştır):

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

Bir sorun mu fark ettiniz? İpucu: Use-after-free, ancak JavaScript'te!

Emscripten'da typed_memory_view, WebAssembly (Wasm) bellek arabelleği tarafından desteklenen, byteOffset ve byteLength öğelerinin belirtilen işaretçi ve uzunluğa ayarlanmış bir JavaScript Uint8Array döndürür. Ana nokta, bunun verilerin JavaScript'e ait bir kopyası yerine WebAssembly bellek arabelleğine yapılan bir TypedArray görünümü olmasıdır.

JavaScript'ten free_result çağrısı yaptığımızda, bu belleği gelecekteki ayırmalar için kullanılabilir olarak işaretlemek üzere standart bir C işlevi free çağrısı yapar. Diğer bir deyişle, Uint8Array görünümlerimizin hedeflendiği veriler, ileride Wasm'a yapılan herhangi bir çağrıda rastgele verilerle üzerine yazılabilir.

Hatta bazı free uygulamaları, boşa çıkan belleği hemen doldurmaya bile karar verebilir. Emscripten'ın kullandığı free bunu yapmıyor ancak burada, garanti edilemeyen bir uygulama ayrıntısından yararlanıyoruz.

İşaretçinin arkasındaki bellek korunsa bile yeni ayırmanın WebAssembly belleğini artırması gerekebilir. WebAssembly.Memory, JavaScript API'si veya karşılık gelen memory.grow talimatı aracılığıyla büyütüldüğünde, mevcut ArrayBuffer ve geçişli olarak desteklediği tüm görünümleri geçersiz kılar.

Bu davranışı göstermek için DevTools (veya Node.js) konsolunu kullanayım:

> 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

Son olarak, free_result ile new Uint8ClampedArray tarihleri arasında Wasm'ı bir daha çağırmayacak olsak bile codec'lerimize çoklu iş parçacığı işleme desteği ekleyebiliriz. Bu durumda, biz klonlamayı başarmadan hemen önce verilerin üzerine yazan tamamen farklı bir iş parçacığı olabilir.

Bellek hataları aranıyor

Her ihtimale karşı, bu kodun pratikte herhangi bir sorun içerip içermediğini kontrol etmeye karar verdik. Bu, geçen yıl eklenen ve Chrome Geliştirici Zirvesi'ndeki WebAssembly konuşmamızda sunulan yeni Emscripten temizleyici desteğini denemek için mükemmel bir fırsat gibi görünüyor:

Bu örnekte, işaretçi ve bellekle ilgili çeşitli sorunları algılayabilen AddressSanitizer ile ilgileniyoruz. Kullanabilmek için codec'imizi -fsanitize=address ile yeniden derlememiz gerekir:

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

Bu, işaretçi güvenlik kontrollerini otomatik olarak etkinleştirir, ancak olası bellek sızıntılarını da bulmak isteriz. ImageQuant'ı program yerine kitaplık olarak kullandığımızdan, Emscripten'in tüm belleğin serbest bırakıldığını otomatik olarak doğrulayabileceği bir "çıkış noktası" yoktur.

Bunun yerine, bu tür durumlarda LeakSanitizer (AddressSanitizer'a dahildir), tüm belleğin serbest bırakılmasını beklediğimiz ve bu varsayımı doğrulamak istediğimizde manuel olarak çağrılabilecek __lsan_do_leak_check ve __lsan_do_recoverable_leak_check işlevlerini sağlar. __lsan_do_leak_check, çalışan bir uygulamanın sonunda, herhangi bir sızıntı algılanması durumunda işlemi iptal etmek istediğinizde kullanılması amaçlanmıştır. Öte yandan, __lsan_do_recoverable_leak_check kod sızıntılarını konsola yazdırmak ancak yine de uygulamayı çalışır durumda tutmak istediğinizde, bizimki gibi kitaplık kullanım alanları için daha uygundur.

İstediğimiz zaman JavaScript'ten çağırabilmemiz için bu ikinci yardımcıyı Embind ile gösterelim:

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

Resimle işimiz bittikten sonra da JavaScript tarafından çağıracağız. Bu işlemin C++ tarafından değil, JavaScript tarafından yapılması, kontrolleri çalıştırdığımızda tüm kapsamlardan çıkıldığından ve tüm geçici C++ nesnelerinin serbest bırakıldığından emin olmamıza yardımcı olur:

  // …

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

Bu işlem, konsolda bize aşağıdakine benzer bir rapor verir:

Mesajın ekran görüntüsü

Hay aksi, bazı küçük sızıntılar var ancak tüm işlev adları karıştığı için yığın izleme pek faydalı değil. Bunları korumak için temel hata ayıklama bilgileriyle yeniden derleyelim:

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

Bu seçenek çok daha iyi görünüyor:

GenelBindingType RawImage ::toWireType işlevinden gelen &quot;12 baytlık doğrudan sızıntı&quot; yazan mesajın ekran görüntüsü

Yığın izlemenin bazı bölümleri Emscripten'in dahili içeriklerine işaret ettiği için hâlâ belirsiz görünüyor. Ancak sızıntının Embind tarafından yapılan bir RawImage dönüşümden "tel türüne" (JavaScript değerine) geldiğini söyleyebiliriz. Aslında, koda baktığımızda, RawImage C++ örneğini JavaScript'e döndürdüğümüzü görebiliyoruz, ancak bunları iki tarafta da hiçbir zaman serbest bırakamıyoruz.

Geliştirme aşamasında olsa da şu anda JavaScript ve WebAssembly arasında atık toplama entegrasyonu bulunmadığını hatırlatmak isteriz. Bunun yerine, nesneyi bitirdikten sonra belleği manuel olarak boşaltmanız ve JavaScript tarafındaki çağrı yıkıcıları kullanmanız gerekir. Özellikle Embind için, resmi dokümanlar, açığa çıkan C++ sınıflarında bir .delete() yönteminin kullanılmasını önermektedir:

JavaScript kodunun aldığı tüm C++ nesne işleyicilerini açık bir şekilde silmesi gerekir. Aksi takdirde, Emscripten yığını süresiz olarak büyür.

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

Gerçekten de, bunu sınıfımız için JavaScript'te yaptığımızda:

  // …

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

Sızıntı beklendiği gibi ortadan kalkıyor.

Dezenfektanlarla ilgili diğer sorunları tespit etme

Squoosh codec'lerini sterilize edicilerle derlemek hem benzer sorunları hem de bazı yeni sorunları ortaya çıkarır. Örneğin, MozJPEG bağlamalarında şu hatayı aldım:

Mesajın ekran görüntüsü

Burada, bir sızıntı değil, belirlenen sınırların dışındaki bir anıya yazıyoruz 😳

MozJPEG kodunu incelediğimizde sorunun jpeg_mem_dest (JPEG için bellek hedefi tahsis etmek amacıyla kullandığımız işlev) sıfır olmayan outbuffer ve outsize değerlerini yeniden kullanması olduğunu tespit ettik:

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

Bununla birlikte, bu değişkenlerden herhangi birini başlatmadan çağırırız. Diğer bir deyişle MozJPEG, sonucu çağrı sırasında bu değişkenlerde depolanan, potansiyel olarak rastgele bir bellek adresine yazar.

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

Çağrıdan önce her iki değişkenin sıfırlanması bu sorunu çözer ve artık kod bunun yerine bir bellek sızıntısı denetimine ulaşır. Neyse ki denetim başarıyla geçer ve bu codec'te herhangi bir sızıntı olmadığı anlamına gelir.

Paylaşılan durumla ilgili sorunlar

...Yoksa öyle mi?

Codec bağlamalarımızın, durumun bir kısmını depoladığını ve genel statik değişkenlere neden olduğunu biliyoruz. MozJPEG'in ise bazı karmaşık yapılara sahip olduğunu biliyoruz.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Bunlardan bazıları ilk çalıştırmada geç çalıştırılırsa ve sonraki çalıştırmalarda uygun olmayan şekilde yeniden kullanılırsa ne olur? Bu durumda, dezenfektanla yapılan tek bir arama bunları sorunlu olarak bildirmez.

Kullanıcı arayüzünde farklı kalite seviyelerini rastgele tıklayarak resmi birkaç kez işlemeyi deneyelim. Aslında, şimdi aşağıdaki raporu alıyoruz:

Mesajın ekran görüntüsü

262.144 bayt: Görünüşe göre örnek resmin tamamı jpeg_finish_compress kaynağından sızdırılmış.

Belgeleri ve resmi örnekleri inceledikten sonra, jpeg_finish_compress ürününün önceki jpeg_mem_dest çağrımız tarafından ayrılan belleği boşaltmadığı ortaya çıktı. Sıkıştırma yapısı, bellek hedefimizi zaten biliyor olsa bile yalnızca sıkıştırma yapısını serbest bırakır. Ah.

Bu sorunu, verileri free_result işlevinde manuel olarak serbest bırakarak düzeltebiliriz:

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

Bu bellek hatalarını tek tek aramaya devam edebilirim ama bellek yönetimine mevcut yaklaşımın bazı berbat sistematik sorunlara yol açtığının artık yeterince açık olduğunu düşünüyorum.

Bazıları dezenfektana hemen bulaşabilir. Bazıları ise karmaşık numaraların yakalanmasını gerektirir. Son olarak, yazının başında, günlüklerden de gördüğümüz gibi, dezenfektan tarafından hiç tespit edilmeyen sorunlar var. Bunun nedeni, asıl yanlış kullanımın, dezenfektanın görünmediği JavaScript tarafında gerçekleşmesidir. Bu sorunlar, kendilerini yalnızca üretim aşamasında veya ileride kodda ilgisiz gibi görünebilecek değişikliklerden sonra ortaya çıkar.

Güvenli bir sarmalayıcı oluşturma

Gelin birkaç adım geri gidelim ve bunun yerine kodu daha güvenli bir şekilde yeniden yapılandırarak tüm bu sorunları çözelim. Yine örnek olarak ImageQuant sarmalayıcısını kullanacağım, ancak benzer yeniden düzenleme kuralları tüm codec'ler ve diğer benzer kod tabanları için geçerlidir.

İlk olarak, "boşluktan sonra kullanım" sorununu yayının başından itibaren düzeltelim. Bunun için, JavaScript tarafında ücretsiz olarak işaretlemeden önce WebAssembly destekli görünümdeki verileri klonlamamız gerekir:

  // …

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

Şimdi, çağrılar arasında genel değişkenlerde herhangi bir durum paylaşmadığımızdan emin olalım. Bu, hem önceden gördüğümüz sorunların bazılarını düzeltecek hem de ileride codec'lerimizi çok iş parçacıklı bir ortamda kullanmayı kolaylaştıracaktır.

Bunu yapmak için C++ sarmalayıcısını, işleve yapılan her çağrının yerel değişkenler kullanarak kendi verilerini yönettiğinden emin olacak şekilde yeniden düzenledik. Ardından, işaretçiyi geri kabul etmek için free_result işlevimizin imzasını değiştirebiliriz:

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

Ancak zaten Emscripten'de JavaScript ile etkileşimde bulunduğumuz için C++ bellek yönetimi ayrıntılarını tümüyle gizleyerek API'yi daha da güvenli hale getirebiliriz.

Bunun için new Uint8ClampedArray(…) kısmını, Embind koduyla JavaScript'ten C++ tarafına taşıyalım. Sonrasında bunu, işlevden dönmeden önce verileri JavaScript belleğine klonlamak için kullanabiliriz:

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

Tek bir değişiklikle, elde edilen bayt dizisinin JavaScript'e ait olmasını ve WebAssembly belleği tarafından desteklenmemesini nasıl sağladığımızı ve daha önce sızdırılan RawImage sarmalayıcısını nasıl kaldırdığımızı unutmayın.

JavaScript artık verileri serbest bırakma konusunda endişe duymaz ve sonucu, çöplerle toplanan diğer nesneler gibi kullanabilir:

  // …

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

Bu aynı zamanda artık C++ tarafında özel bir free_result bağlamaya ihtiyaç duymayacağımız anlamına gelir:

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

Sonuç olarak, sarmalayıcı kodumuz aynı anda hem daha temiz hem de daha güvenli hale geldi.

Bunun ardından, ImageQuant sarmalayıcısının kodunda bazı küçük iyileştirmeler yaptım ve diğer codec'ler için benzer bellek yönetimi düzeltmelerini çoğalttım. Daha ayrıntılı bilgi edinmek isterseniz elde edilen PR'yi şurada görebilirsiniz: C++ codec'leri için bellek düzeltmeleri.

Çalmalar

Bu yeniden düzenleme sürecinden, diğer kod tabanlarına uygulanabilecek ne gibi dersler çıkarıp paylaşabiliriz?

  • Hangi dilden derlendiği fark etmeksizin, WebAssembly tarafından desteklenen bellek görünümlerini tek bir çağrı dışında kullanmayın. Daha uzun süre hayatta kalacaklarına emanet edemezsiniz ve bu hataları geleneksel yollarla yakalayamazsınız. Dolayısıyla, verileri daha sonra kullanmak üzere saklamanız gerekirse, JavaScript tarafına kopyalayıp orada depolayın.
  • Mümkünse doğrudan ham işaretçiler üzerinde çalışmak yerine güvenli bir bellek yönetimi dili veya en azından güvenli türde sarmalayıcılar kullanın. Bu sizi JavaScript ÷ WebAssembly sınırındaki hatalardan kurtarmaz ancak en azından statik dil kodundan kaynaklanan hataların yüzeyini azaltır.
  • Hangi dili kullanırsanız kullanın, geliştirme sırasında temizleyiciler içeren kod çalıştırın. Bu araçlar yalnızca statik dil kodundaki sorunların yanı sıra JavaScript WebAssembly sınırındaki sorunları (.delete() çağrısını unutmak veya JavaScript tarafından geçersiz işaretçiler aktarmak gibi) yakalamanıza yardımcı olabilir.
  • Mümkünse, yönetilmeyen verileri ve nesneleri WebAssembly'den JavaScript'e tamamen açık hale getirmekten kaçının. JavaScript, atıklarla toplanan bir dildir ve manuel bellek yönetimi yaygın değildir. Bu, WebAssembly'nizin oluşturulduğu dilin bellek modelinin soyutlama sızıntısı olarak kabul edilebilir. JavaScript kod tabanında yanlış yönetim kolayca gözden kaçabilir.
  • Bu açıkça anlaşılabilir ancak diğer tüm kod tabanlarında olduğu gibi, değişken durumu genel değişkenlerde depolamaktan kaçının. URL'nin çeşitli çağrılarda, hatta ileti dizilerinde yeniden kullanımıyla ilgili sorunlarda hata ayıklamak istemezsiniz. Bu yüzden en iyi yöntem, mümkün olduğunca bağımsız tutmaktır.