Debug delle perdite di memoria in WebAssembly utilizzando Emscripten

Sebbene JavaScript sia abbastanza tollerante nel ripulire se stesso, i linguaggi statici non sono di certo...

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app è una PWA che mostra quanti codec e impostazioni immagine diversi possono migliorare le dimensioni del file immagine senza influire notevolmente sulla qualità. Tuttavia, è anche una demo tecnica che mostra come portare le librerie scritte in C++ o Rust sul web.

La possibilità di trasferire il codice dagli ecosistemi esistenti è incredibilmente utile, ma esistono alcune differenze fondamentali tra questi linguaggi statici e JavaScript. Uno di questi è i diversi approcci alla gestione della memoria.

Sebbene JavaScript sia abbastanza tollerante nel ripulire se stesso, tali linguaggi statici non lo sono affatto. Devi richiedere esplicitamente una nuova memoria allocata e assicurarti di restituirla in seguito, senza mai utilizzarla di nuovo. Se ciò non accade, ci sono perdite... e accade in modo abbastanza regolare. Vediamo come eseguire il debug di queste perdite di memoria e, ancora meglio, come progettare il codice per evitarle la prossima volta.

Pattern sospetto

Recentemente, quando ho iniziato a lavorare su Squoosh, non ho potuto fare a meno di notare un pattern interessante nei wrapper codec C++. Diamo un'occhiata a un wrapper ImageQuant (ridotto per mostrare solo le parti di creazione degli oggetti e di deallocation):

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

Riscontri un problema? Suggerimento: è senza costi da usare dopo, ma in JavaScript.

In Emscripten, typed_memory_view restituisce un Uint8Array JavaScript supportato dal buffer di memoria di WebAssembly (Wasm), con byteOffset e byteLength impostati sul puntatore e sulla lunghezza specificati. Il punto principale è che si tratta di una vista TypedArray in un buffer di memoria di WebAssembly, anziché di una copia dei dati di proprietà di JavaScript.

Quando chiamiamo free_result da JavaScript, a sua volta viene richiamata una funzione C standard free per contrassegnare questa memoria come disponibile per eventuali allocazioni future, il che significa che i dati a cui punta la nostra Uint8Array vista possono essere sovrascritti con dati arbitrari da qualsiasi chiamata futura in Wasm.

Oppure, alcune implementazioni di free potrebbero persino decidere di riempire immediatamente la memoria liberata. L'free utilizzato da Emscripten non serve a questo scopo, ma ci basiamo su un dettaglio di implementazione che non può essere garantito.

Oppure, anche se la memoria dietro il puntatore viene conservata, una nuova allocazione potrebbe dover aumentare la memoria di WebAssembly. Se WebAssembly.Memory viene aumentato tramite l'API JavaScript o l'istruzione memory.grow corrispondente, invalida il ArrayBuffer esistente e, in modo transitivo, qualsiasi visualizzazione supportata da questa istruzione.

Fammi utilizzare la console DevTools (o Node.js) per dimostrare questo comportamento:

> 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

Infine, anche se non richiamiamo esplicitamente Wasm tra free_result e new Uint8ClampedArray, prima o poi potremmo aggiungere il supporto del multithreading ai nostri codec. In questo caso potrebbe essere un thread completamente diverso che sovrascrive i dati appena prima di riuscire a clonarli.

Ricerca di bug di memoria

Per sicurezza, ho deciso di andare oltre e controllare se questo codice presenta dei problemi nella pratica. Questa è un'opportunità perfetta per provare il nuovo supporto per i sanitizers Emscripten che è stato aggiunto l'anno scorso e presentato durante la nostra conferenza WebAssembly al Chrome Dev Summit:

In questo caso, ci interessa AddressSanitizer, che può rilevare vari problemi relativi a puntatore e memoria. Per utilizzarlo, dobbiamo ricompilare il nostro codec con -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

In questo modo verranno abilitati automaticamente i controlli di sicurezza dei cursori, ma vogliamo anche rilevare potenziali perdite di memoria. Poiché utilizziamo ImageQuant come libreria anziché come programma, non esiste un "punto di uscita" in cui Emscripten potrebbe convalidare automaticamente che è stata liberata tutta la memoria.

In questi casi, invece, il LeakSanitizer (incluso in AddressSanitizer) fornisce le funzioni __lsan_do_leak_check e __lsan_do_recoverable_leak_check, che possono essere richiamate manualmente ogni volta che prevediamo di liberare tutta la memoria e vogliamo convalidarla. __lsan_do_leak_check è pensato per essere utilizzato alla fine di un'applicazione in esecuzione, quando vuoi interrompere il processo nel caso vengano rilevate perdite, mentre __lsan_do_recoverable_leak_check è più adatto per casi d'uso in biblioteca come il nostro, quando vuoi stampare perdite sulla console, ma mantenere l'applicazione in esecuzione comunque.

Esploriamo il secondo helper tramite Embind, in modo da poterlo chiamare da JavaScript in qualsiasi momento:

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

E richiamarlo dal lato JavaScript una volta che abbiamo finito con l'immagine. Eseguire questa operazione dal lato JavaScript, anziché da quello di C++, contribuisce a garantire che tutti gli ambiti siano stati chiusi e che tutti gli oggetti C++ temporanei siano stati liberati quando eseguiamo quei controlli:

  // …

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

Nella console verrà visualizzato un report simile al seguente:

Screenshot di un messaggio

Oh oh, ci sono alcune piccole perdite, ma lo stacktrace non è molto utile perché tutti i nomi delle funzioni vengono alterati. Compiliamo nuovamente le informazioni di debug di base per mantenerle:

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

Questo aspetto è molto più bello:

Screenshot di un messaggio con la dicitura &quot;Perdita diretta di 12 byte&quot; proveniente da una funzione GenericBindingType RawImage ::toWireType

Alcune parti dell'analisi dello stack sono ancora poco chiare poiché puntano agli elementi interni di Emscripten, ma possiamo dire che la perdita deriva da una conversione RawImage in "tipo di cavo" (in un valore JavaScript) di Embind. Infatti, quando esaminiamo il codice, possiamo vedere che restiamo RawImage istanze C++ in JavaScript, ma non le liberiamo mai da nessuno dei due lati.

Ti ricordiamo che al momento non esiste un'integrazione di garbage collection tra JavaScript e WebAssembly, anche se ne è in fase di sviluppo. Al contrario, devi liberare manualmente eventuali distruttori di memoria e chiamate dal lato JavaScript una volta che hai finito di lavorare con l'oggetto. In particolare, per Embind, i documenti ufficiali suggeriscono di chiamare un metodo .delete() sulle classi C++ esposte:

Il codice JavaScript deve eliminare in modo esplicito qualsiasi handle di oggetti C++ ricevuto, altrimenti l'heap Emscripten aumenterà all'infinito.

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

Infatti, se lo facciamo in JavaScript per la nostra classe:

  // …

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

La fuga di notizie si è rimossa come previsto.

Scoprire altri problemi relativi ai disinfettanti

La creazione di altri codec Squoosh con disinfettanti rivela problemi simili e alcuni nuovi. Ad esempio, viene visualizzato questo errore nelle associazioni MozJPEG:

Screenshot di un messaggio

Qui non si tratta di una fuga di notizie, ma di scrivere a un ricordo al di fuori dei confini allocati XYZ

Analizzando il codice di MozJPEG, abbiamo riscontrato che il problema qui è che jpeg_mem_dest, la funzione che utilizziamo per allocare una destinazione della memoria per JPEG, riutilizza i valori esistenti di outbuffer e outsize quando sono diversi da zero:

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

Tuttavia, lo richiamiamo senza inizializzare nessuna di queste variabili, il che significa che MozJPEG scrive il risultato in un indirizzo di memoria potenzialmente casuale che era memorizzato in quelle variabili al momento della chiamata.

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

Inizializzazione zero di entrambe le variabili prima che la chiamata risolva il problema e ora il codice raggiunge un controllo di perdita di memoria. Fortunatamente, il controllo ha esito positivo, indicando che non sono presenti fughe di dati in questo codec.

Problemi con lo stato condiviso

...O no?

Sappiamo che le nostre associazioni di codec memorizzano parte dello stato e dei risultati in variabili statiche globali e MozJPEG ha alcune strutture particolarmente complicate.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Che cosa succede se alcune di queste vengono inizializzate malamente alla prima esecuzione e poi riutilizzate in modo improprio per le esecuzioni future? In questo caso, una singola chiamata con un disinfettante non segnala problemi come tali.

Proviamo a elaborare l'immagine un paio di volte facendo clic in modo casuale a diversi livelli di qualità nell'interfaccia utente. Infatti, ora otteniamo il seguente report:

Screenshot di un messaggio

262.144 byte. Sembra che l'intera immagine di esempio sia trapelata da jpeg_finish_compress.

Dopo aver esaminato la documentazione e gli esempi ufficiali, jpeg_finish_compress non libera la memoria allocata dalla nostra precedente chiamata jpeg_mem_dest, si limita a liberare la struttura di compressione, anche se quella struttura di compressione conosce già la nostra destinazione della memoria... Sospiro.

Possiamo risolvere il problema liberando i dati manualmente nella funzione 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);
}

Potrei continuare a cacciare gli insetti della memoria uno per uno, ma penso che ormai sia abbastanza chiaro che l'approccio attuale alla gestione della memoria porta a gravi problemi sistematici.

Alcune possono essere prese immediatamente dal disinfettante. Altre richiedono di farsi scoprire intricati trucchi. Infine, ci sono problemi come all'inizio del post che, come vediamo dai log, non vengono rilevati dal sanitizer. Il motivo è che l'uso improprio effettivo si verifica sul lato JavaScript, in cui il sanitizer non ha visibilità. Questi problemi si riveleranno solo in produzione o dopo modifiche apparentemente non correlate al codice.

Creare un wrapper sicuro

Facciamo un paio di passaggi indietro e risolviamo invece tutti questi problemi riorganizzando il codice in modo più sicuro. Utilizzerò di nuovo il wrapper ImageQuant, ma regole di refactoring simili si applicano a tutti i codec e ad altri codebase simili.

Prima di tutto, risolviamo il problema "use-after-free" dall'inizio del post. Per eseguirlo, dobbiamo clonare i dati dalla vista supportata da WebAssembly prima di contrassegnarli come senza costi sul lato 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;
}

Ora assicurati di non condividere alcuno stato nelle variabili globali tra una chiamata e l'altra. Questa operazione risolve alcuni dei problemi già riscontrati, oltre a semplificare l'utilizzo dei nostri codec in un ambiente multithread in futuro.

Per farlo, eseguiamo il refactoring del wrapper C++ per assicurarci che ogni chiamata alla funzione gestisca i propri dati utilizzando le variabili locali. Quindi, possiamo modificare la firma della nostra funzione free_result per accettare il puntatore:

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

Ma poiché stiamo già utilizzando Embind in Emscripten per interagire con JavaScript, potremmo anche rendere l'API ancora più sicura nascondendo del tutto i dettagli di gestione della memoria C++.

Per farlo, spostiamo la parte new Uint8ClampedArray(…) da JavaScript al lato C++ con Embind. Dopodiché possiamo utilizzarla per clonare i dati nella memoria JavaScript anche prima di tornare dalla funzione:

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

Nota come, con una singola modifica, ci assicuriamo che l'array di byte risultante sia di proprietà di JavaScript e non sia supportato dalla memoria di WebAssembly, e eliminiamo anche il wrapper RawImage precedentemente divulgato.

Ora JavaScript non deve più preoccuparsi di liberare i dati e può utilizzare il risultato come qualsiasi altro oggetto garbage-collected:

  // …

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

Ciò significa inoltre che non abbiamo più bisogno di un'associazione free_result personalizzata sul lato 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());
}

Nel complesso, il codice del wrapper è diventato più chiaro e sicuro allo stesso tempo.

Successivamente, ho apportato ulteriori piccoli miglioramenti al codice del wrapper ImageQuant e replicato simili correzioni per la gestione della memoria per altri codec. Se ti interessa saperne di più, puoi trovare il PR risultante qui: Correzioni della memoria per i codec C++.

Concetti chiave

Quali insegnamenti possiamo apprendere e condividere da questo refactoring e che potremmo applicare ad altri codebase?

  • Non utilizzare le visualizzazioni della memoria supportate da WebAssembly, indipendentemente dal linguaggio di creazione, al di fuori di una singola chiamata. Non puoi fare affidamento su un servizio che sopravvivrà più a lungo e non sarai in grado di individuare questi bug con metodi convenzionali, quindi se devi archiviare i dati per un secondo momento, copiali sul lato JavaScript e memorizzali lì.
  • Se possibile, utilizza un linguaggio di gestione della memoria sicuro o, almeno, wrapper di tipo sicuro, anziché operare direttamente sui puntatori non elaborati. In questo modo non puoi evitare i bug ai confini di JavaScript ↔ WebAssembly, ma riduce almeno la superficie per i bug indipendenti dal codice statico del linguaggio.
  • Indipendentemente dal linguaggio utilizzato, esegui il codice con i sanitizer durante lo sviluppo: possono aiutarti a rilevare non solo i problemi nel codice del linguaggio statico, ma anche alcuni problemi in tutto il confine di JavaScript ↔ WebAssembly, come dimenticare di chiamare .delete() o passare puntatori non validi dal lato JavaScript.
  • Se possibile, evita di esporre del tutto dati e oggetti non gestiti da WebAssembly a JavaScript. JavaScript è un linguaggio garbage-collecting e la gestione manuale della memoria non è comune. Può essere considerata una perdita di astrazione del modello di memoria del linguaggio da cui è stato creato WebAssembly, ed è facile trascurare una gestione errata in un codebase JavaScript.
  • Questo potrebbe essere ovvio, ma, come per qualsiasi altro codebase, evita di memorizzare lo stato modificabile nelle variabili globali. Non vuoi eseguire il debug dei problemi relativi al suo riutilizzo in varie chiamate o anche in thread, quindi è meglio mantenerlo il più autonomo possibile.