Debugowanie wycieków pamięci w WebAssembly za pomocą Emscripten

Choć JavaScript jest dość chętny do czyszczenia, to jednak języki statyczne na pewno nie...

Squoosh.app to aplikacja PWA, która pokazuje, jak różne kodeki graficzne i ustawienia mogą zwiększyć rozmiar pliku graficznego bez znacznego pogorszenia jakości. Jest to jednak również technicznej demonstracji, w jaki sposób można umieścić biblioteki napisane w języku C++ lub Rust i wdrożyć je sieci.

Możliwość przenoszenia kodu z istniejących ekosystemów jest bardzo cenna, między tymi językami statycznymi i JavaScriptem. Jedna z nich znajduje się w różnych pod kątem zarządzania pamięcią.

Choć JavaScript dość łatwo czyści się po sobie, to jednak są to języki statyczne, Zdecydowanie nie. Musisz wyraźnie poprosić o nowe przydzielone pamięć i naprawdę wykonać oddać go później i nie używać ponownie. Jeśli tak się nie stanie, dostaniesz wyciek danych... Dzieje się to dość regularnie. Przyjrzyjmy się, jak można debugować te wycieki pamięci. a nawet lepsze – jak tak zaprojektować kod, by następnym razem ich nie unikać.

Podejrzany wzorzec

Ostatnio, zaczynając pracę nad Squoosh, zauważyłem ciekawy wzorzec Kodeka C++. Spójrzmy na otokę ImageQuant, przykład (zmniejszony, by widoczne były tylko elementy tworzenia obiektów i atrybucji):

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

Widzisz jakiś problem? Podpowiedź: use-after-free, ale JavaScript

W Emscripten funkcja typed_memory_view zwraca kod JavaScript Uint8Array obsługiwany przez WebAssembly (Wasm) bufor pamięci z wartościami byteOffset i byteLength ustawionymi na dany wskaźnik i długość. Główny jest to widok TypedSlate do bufora pamięci WebAssembly, a nie Kopia danych należąca do JavaScriptu.

Gdy wywołujemy funkcję free_result w JavaScripcie, wywołuje ona z kolei standardową funkcję C free, aby zaznaczyć tę pamięć dostępną dla wszystkich przyszłych przydziałów, co oznacza, że nasze Uint8Array które może zostać zastąpione dowolnymi danymi przez dowolne przyszłe wywołanie Wasm.

Część implementacji funkcji free może nawet od razu wyzerować wolną pamięć. Metoda free, której używa Emscripten, nie spełnia tego wymogu, ale polegamy na szczegółach implementacji których nie można zagwarantować.

Lub, nawet jeśli pamięć postawiona za wskaźnikiem zostanie zachowana, nowy przydział może wymagać zwiększenia Pamięć WebAssembly. Gdy WebAssembly.Memory zostanie zwiększony za pomocą interfejsu JavaScript API lub odpowiedniego memory.grow, unieważnia istniejące ArrayBuffer oraz przechodnie wszystkie widoki i mają wsparcie.

Użyję konsoli deweloperskiej (lub konsoli Node.js), aby zademonstrować to zachowanie:

> 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

Wreszcie, nawet jeśli nie wywołamy ponownie Wasm między free_result a new Uint8ClampedArray, w pewnym momencie możemy dodać do naszych kodeków obsługę wielowątkowości. W takim przypadku to zupełnie inny wątek, który nadpisuje dane, zanim zdążymy je sklonować.

Szukam błędów pamięci

Postanowiliśmy sprawdzić, czy kod nie sprawia problemów w praktyce. To idealna okazja, aby wypróbować nowe środki do dezynfekcji Emscripten pomocy dodanej w zeszłym roku i zaprezentowane w naszej prezentacji WebAssembly podczas Chrome Dev Summit:

W tym przypadku interesuje nas AddressSanitizer, która może wykrywać różne problemy związane ze wskaźnikami i pamięcią. Aby go użyć, musimy ponownie skompilować kodek z użytkownikiem -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

Spowoduje to automatyczne włączenie kontroli bezpieczeństwa wskaźnika, ale chcemy też znaleźć potencjalną pamięć wyciek danych. Używamy ImageQuant jako biblioteki, a nie programu, więc nie ma „punktu wyjścia”. o który automatycznie sprawdza, czy cała pamięć została zwolniona.

Zamiast tego w takich przypadkach LeakSanitizer (dostępny w programie AddressSanitizer) udostępnia funkcje __lsan_do_leak_check i __lsan_do_recoverable_leak_check, który można wywołać ręcznie, gdy chcemy zwolnić całą pamięć i chcemy sprawdzić, założenia. __lsan_do_leak_check powinien być używany pod koniec uruchomionej aplikacji, gdy chcesz przerwać proces na wypadek wykrycia wycieku, a __lsan_do_recoverable_leak_check sprawdza się w zastosowaniach w bibliotekach, takich jak nasza, gdy chcesz wydrukować informacje o wyciekach do konsoli. aby aplikacja działała niezależnie.

Udostępnimy ten drugi pomocnik w narzędziu Embind, aby można było w dowolnym momencie wywołać go z JavaScriptu:

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

Gdy skończysz pracować z obrazem, wywołaj go od strony JavaScriptu. Robiąc to za pomocą polecenia JavaScript zamiast C++ pomaga zapewnić, że wszystkie zakresy zostały został zamknięty, a wszystkie tymczasowe obiekty C++ zostały zwolnione do czasu przeprowadzenia tych kontroli:

  // 

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

Uzyskasz taki raport w konsoli:

Zrzut ekranu wiadomości

Wyciekły informacje, ale zrzut stosu nie jest zbyt przydatny, ponieważ wszystkie nazwy funkcji są zniekształcone. Aby je zachować, skompilujmy ponownie z podstawowymi informacjami debugowania:

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

To znacznie lepiej:

Zrzut ekranu z komunikatem „Bezpośredni wyciek 12 bajtów” pochodzące z funkcji GeneralBindingType RawImage ::toWireType

Niektóre części zrzutu stosu nadal wyglądają na niejasne, ponieważ wskazują na zasoby wewnętrzne Emscripten, ale stwierdzą, że wyciek pochodzi z konwersji RawImage na „typ przewodu” (do wartości JavaScript) przez Embind. Kiedy spojrzymy na kod, widzimy, że zwracamy instancje C++ (RawImage) JavaScript, ale nigdy ich nie zwalniamy.

Przypominamy, że obecnie nie ma integracji czyszczenia pamięci między JavaScriptem a WebAssembly, choć wciąż jest jeszcze rozwijana. Zamiast tego masz aby ręcznie zwolnić pamięć i wywoływać destrukcje po stronie JavaScriptu, gdy skończysz używać obiektu. W przypadku Embind oficjalny dokument dokumenty sugeruje wywołanie metody .delete() w ujawnionych klasach C++:

Kod JavaScript musi jawnie usunąć wszystkie uchwyty obiektów C++, które otrzymał, lub interfejs Emscripten sterta będzie się powiększać bez końca.

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

Gdy robimy to w JavaScripcie na potrzeby naszej klasy:

  // 

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

Wyciek powinien zniknąć zgodnie z oczekiwaniami.

Wykrywanie kolejnych problemów ze środkami do dezynfekcji

Opracowanie innych kodeków Squoosh przy użyciu środków dezynfekujących pozwala ujawnić zarówno podobne, jak i nowe problemy. Dla: Na przykład w powiązaniach MozJPEG jest ten błąd:

Zrzut ekranu wiadomości

Tutaj nie chodzi o wyciek, ale to, że piszemy do wspomnień poza wyznaczonymi granicami 😱

Po przeanalizowaniu kodu MozJPEG okazuje się, że problem polega na tym, że jpeg_mem_dest – której używamy do przydzielenia miejsca docelowego pamięci dla JPEG – wykorzystuje ponownie istniejące wartości outbuffer i outsize, gdy są wartość różna od zera:

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

Jest ona jednak wywoływana bez inicjowania żadnej z tych zmiennych, co oznacza, że MozJPEG tworzy potencjalnie losowy adres pamięci, który został zapisany w tych zmiennych w momencie rozmowy.

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

Brak inicjowania obu zmiennych przed wywołaniem rozwiązuje ten problem. Teraz kod osiąga do kontroli wycieku pamięci. Na szczęście kontrola przebiegła pomyślnie i okazało się, że nie mamy żadnych które wyciekły w tym kodeku.

Problemy ze stanem „Współdzielone”

A może my?

Wiemy, że nasze powiązania kodeka przechowują część stanu, a także wyniki w globalnych statycznych zmiennych, a format MozJPEG ma wyjątkowo złożone struktury.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Co się stanie, jeśli niektóre z nich zostaną leniwie zainicjowane przy pierwszym uruchomieniu, a potem w przyszłości niewłaściwie użyte biegi? Dzięki temu pojedyncze połączenie ze środkiem do dezynfekcji nie powoduje zgłoszenia problemu jako problemu.

Spróbujmy przetworzyć zdjęcie kilka razy, losowo klikając je w różnych poziomach jakości w interfejsie. Rzeczywiście otrzymujemy następujący raport:

Zrzut ekranu wiadomości

262 144 bajty – wygląda na to,że cały przykładowy obraz wyciekł z jpeg_finish_compress.

Po zapoznaniu się z dokumentami i oficjalnymi przykładami okazuje się, że jpeg_finish_compress nie zwalnia pamięci przydzielonej przez nasze wcześniejsze wywołanie jpeg_mem_dest, tylko zwolni mimo że już zna ona naszą pamięć miejsce docelowe... Ech.

Aby to naprawić, zwolnij dane ręcznie w funkcji 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);
}

Mogłabym polować po kolei na te błędy pamięci, ale już teraz widzę, że obecne podejście do zarządzania pamięcią prowadzi do dokuczliwych, systematycznych problemów.

Niektóre z nich mogą od razu zostać przechwycone przez środek dezynfekcyjny. Inne wymagają złapania skomplikowanych sztuczek. I wreszcie, jak widać w dziennikach, problemy, np. na początku posta, nie są w ogóle zaliczane do środka dezynfekcyjnego. Powodem jest to, że nadużycia mają miejsce po stronie JavaScriptu, gdzie sanitizer nie jest widoczny. Te problemy ujawnią się same tylko w wersji produkcyjnej lub w przyszłości, pozornie niepowiązane zmiany w kodzie.

Tworzenie bezpiecznej otoki

Cofnijmy się o kilka kroków i rozwiążmy wszystkie te problemy, zmieniając strukturę kodu w bezpieczniejszy sposób. Ponownie użyję opakowania ImageQuant jako przykładu, ale obowiązują podobne reguły refaktoryzacji wszystkie kodeki i inne podobne bazy kodu.

Najpierw rozwiążmy problem związany z używaniem po rezygnacji, który pojawia się na początku posta. Aby to zrobić, , aby sklonować dane z widoku obsługiwanego przez WebAssembly, a następnie oznaczyć je jako bezpłatne po stronie 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;
}

Zadbajmy teraz o to, aby w zmiennych globalnych między wywołaniami nie był udostępniany żaden stan. Ten rozwiązania problemów, które już do tej pory widzieliśmy, a także ułatwią korzystanie może w przyszłości działać w środowisku wielowątkowym.

Aby to zrobić, refaktoryzujemy kod C++, aby każde wywołanie funkcji zarządzało własną. za pomocą zmiennych lokalnych. Następnie możemy zmienić podpis funkcji free_result na zaakceptuj wskaźnik:

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

Ponieważ jednak korzystamy już z Embind in Emscripten do interakcji z JavaScriptem, możemy równie dobrze jeszcze bardziej zwiększ bezpieczeństwo interfejsu API, ukrywając szczegóły zarządzania pamięcią w języku C++.

Przenieśmy więc część new Uint8ClampedArray(…) z JavaScriptu na stronę C++, Embind. Następnie możemy za jego pomocą sklonować dane do pamięci JavaScriptu jeszcze przed zwróceniem z funkcji:

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

Zwróć uwagę, że dzięki jednej zmianie możemy zapewnić, że wynikowa tablica bajtów należy do JavaScriptu i nie korzysta z pamięci WebAssembly, oraz pozbądź się wcześniej ujawnionego otoki RawImage .

Teraz JavaScript nie musi już martwić się o uwolnienie danych i może użyć takiego wyniku jak wszystkie inne obiekty pobrane z pamięci masowej:

  // 

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

Oznacza to również, że nie potrzebujemy już niestandardowego wiązania free_result po stronie 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());
}

Podsumowując, nasz kod otoki stał się jednocześnie bardziej przejrzysty i bezpieczniejszy.

Następnie wprowadziłem drobne ulepszenia w kodzie otoki ImageQuant oraz zreplikowanych podobnych poprawek zarządzania pamięcią dla innych kodeków. Jeśli chcesz dowiedzieć się więcej, możecie zobaczyć go tutaj: Poprawki pamięci w języku C++ .

Wnioski

Jakie wnioski można wyciągnąć z tej refaktoryzacji i jakie wnioski można wykorzystać w innych bazach kodu?

  • Nie używaj widoków pamięci opartych na technologii WebAssembly – niezależnie od tego, w jakim języku został utworzony – poza jedno wywołanie. Nie możesz liczyć na to, że przetrwają one dłużej. można wykrywać te błędy tradycyjnymi metodami, więc jeśli chcesz zapisać dane na później, skopiuj je do po stronie JavaScriptu i tam go zapisać.
  • Jeśli to możliwe, używaj bezpiecznego języka zarządzania pamięcią lub przynajmniej kodów bezpiecznych typów, zamiast bezpośrednio na podstawie nieprzetworzonych wskaźników. Nie unikniesz wtedy błędów w JavaScript ↔ WebAssembly ale przynajmniej ograniczy powierzchnię robaków będących autonomicznym kodem języka.
  • Niezależnie od używanego języka stosuj środki dezynfekcyjne, które pomogą Ci wychwytywać nie tylko problemy ze statycznym kodem języka, ale też niektóre problemy w kodzie JavaScript granica WebAssembly, np. zapomnienie wywołania funkcji .delete() lub przekazanie nieprawidłowych wskaźników z po stronie JavaScriptu.
  • Jeśli to możliwe, unikaj udostępniania niezarządzanych danych i obiektów z WebAssembly całemu skryptowi JavaScript. JavaScript to język zbierania śmieci, w którym ręczne zarządzanie pamięcią nie jest powszechnie stosowane. Można to uznać za abstrakcyjny wyciek modelu pamięci w języku WebAssembly został stworzony na jego podstawie, a nieprawidłowe zarządzanie jest łatwe do przeoczenia w bazie kodu JavaScript.
  • Może to być oczywiste, ale, tak jak w przypadku każdej innej bazy kodu, unikaj przechowywania zmiennego stanu w katalogu globalnym zmiennych. nie chcesz debugować problemów związanych z używaniem go w różnych wywołaniach, a nawet wątki, dlatego najlepiej jest zadbać o to, aby był on jak najbardziej samodzielny.