Déboguer les fuites de mémoire dans WebAssembly à l'aide d'Emmscripten

Si JavaScript est assez indulgent pour nettoyer après lui-même, les langages statiques ne le sont pas du tout.

Squoosh.app est une PWA qui illustre à quel point différents codecs et paramètres d'image peuvent améliorer la taille des fichiers d'image sans affecter significativement la qualité. Il s'agit également d'une démonstration technique montrant comment vous pouvez prendre des bibliothèques écrites en C++ ou en Rust et les transférer sur le Web.

La possibilité de porter du code à partir d'écosystèmes existants est extrêmement utile, mais il existe des différences clés entre ces langages statiques et JavaScript. L'une d'elles réside dans leurs différentes approches de la gestion de la mémoire.

Bien que JavaScript soit assez indulgent pour nettoyer après lui-même, ce n'est pas le cas de ces langages statiques. Vous devez demander explicitement une nouvelle mémoire allouée et vous devez vraiment vous assurer de la rendre ensuite et de ne plus jamais l'utiliser. Si ce n'est pas le cas, des fuites se produisent, et cela arrive assez régulièrement. Voyons comment déboguer ces fuites de mémoire et, mieux encore, comment concevoir votre code pour les éviter la prochaine fois.

Comportement suspect

Récemment, alors que je commençais à travailler sur Squoosh, je n'ai pas pu m'empêcher de remarquer un modèle intéressant dans les wrappers de codecs C++. Prenons un exemple de wrapper ImageQuant (réduit pour n'afficher que les parties de création et de désallocation d'objets):

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

Voyez-vous un problème ? Indice: il s'agit d'une erreur use-after-free, mais en JavaScript.

Dans Emscripten, typed_memory_view renvoie un Uint8Array JavaScript compatible avec le tampon de mémoire WebAssembly (Wasm), avec byteOffset et byteLength définis sur le pointeur et la longueur donnés. L'essentiel est qu'il s'agit d'une vue TypedArray dans un tampon de mémoire WebAssembly, plutôt que d'une copie des données appartenant à JavaScript.

Lorsque nous appelons free_result à partir de JavaScript, il appelle à son tour une fonction C standard free pour marquer cette mémoire comme disponible pour toute future allocation, ce qui signifie que les données auxquelles notre vue Uint8Array fait référence peuvent être écrasées par des données arbitraires par tout futur appel dans Wasm.

Certaines implémentations de free peuvent même décider de remplir immédiatement la mémoire libérée avec des zéros. Le free utilisé par Emscripten ne le fait pas, mais nous nous appuyons ici sur un détail d'implémentation qui ne peut pas être garanti.

Ou, même si la mémoire derrière le pointeur est préservée, une nouvelle allocation peut être nécessaire pour augmenter la mémoire WebAssembly. Lorsque WebAssembly.Memory est développé via l'API JavaScript ou l'instruction memory.grow correspondante, il invalide l'ArrayBuffer existante et, par transitivité, toutes les vues qui en dépendent.

Je vais utiliser la console DevTools (ou Node.js) pour illustrer ce comportement:

> 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

Enfin, même si nous n'appelons plus explicitement Wasm entre free_result et new Uint8ClampedArray, nous pourrons à terme ajouter la prise en charge du multithreading à nos codecs. Dans ce cas, il peut s'agir d'un thread complètement différent qui écrase les données juste avant que nous ne parvenions à les cloner.

Rechercher des bugs de mémoire

Au cas où, j'ai décidé d'aller plus loin et de vérifier si ce code présente des problèmes en pratique. Cela semble être l'occasion idéale d'essayer la nouvelle compatibilité avec les nettoyeurs Emscripten ajoutée l'année dernière et présentée lors de notre conférence sur WebAssembly lors du Chrome Dev Summit:

Dans ce cas, nous nous intéressons à AddressSanitizer, qui peut détecter divers problèmes liés aux pointeurs et à la mémoire. Pour l'utiliser, nous devons recompiler notre codec avec -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

Cela active automatiquement les vérifications de sécurité des pointeurs, mais nous souhaitons également détecter d'éventuelles fuites de mémoire. Étant donné que nous utilisons ImageQuant comme bibliothèque plutôt que comme programme, il n'existe pas de "point de sortie" où Emscripten pourrait valider automatiquement que toute la mémoire a été libérée.

Dans ce cas, LeakSanitizer (inclus dans AddressSanitizer) fournit les fonctions __lsan_do_leak_check et __lsan_do_recoverable_leak_check, qui peuvent être appelées manuellement chaque fois que nous nous attendons à ce que toute la mémoire soit libérée et que nous souhaitons valider cette hypothèse. __lsan_do_leak_check est destiné à être utilisé à la fin d'une application en cours d'exécution, lorsque vous souhaitez interrompre le processus en cas de détection de fuites, tandis que __lsan_do_recoverable_leak_check est plus adapté aux cas d'utilisation de bibliothèques comme la nôtre, lorsque vous souhaitez imprimer des fuites dans la console, mais que vous souhaitez que l'application continue de s'exécuter.

Exposons ce deuxième helper via Embind afin de pouvoir l'appeler à tout moment depuis JavaScript:

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

Et l'appeler côté JavaScript une fois que nous avons terminé avec l'image. Effectuer cette opération du côté JavaScript plutôt que du côté C++ permet de s'assurer que tous les champs d'application ont été quittés et que tous les objets C++ temporaires ont été libérés au moment où nous exécutons ces vérifications:

  // 

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

Vous obtenez un rapport semblable à celui-ci dans la console:

Capture d&#39;écran d&#39;un message

Uh-oh, il y a quelques petites fuites, mais la trace de la pile n'est pas très utile, car tous les noms de fonction sont déformés. Recompilons avec des informations de débogage de base pour les conserver:

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

C'est beaucoup mieux:

Capture d&#39;écran d&#39;un message indiquant &quot;Fuite directe de 12 octets&quot; provenant d&#39;une fonction GenericBindingType RawImage ::toWireType

Certaines parties de la trace de la pile semblent toujours obscures, car elles pointent vers les éléments internes d'Emscripten. Toutefois, nous pouvons dire que la fuite provient d'une conversion RawImage en "type de fil" (en valeur JavaScript) par Embind. En effet, lorsque nous examinons le code, nous pouvons voir que nous renvoyons des instances C++ RawImage à JavaScript, mais que nous ne les libérons jamais de chaque côté.

Pour rappel, il n'existe actuellement aucune intégration du garbage collection entre JavaScript et WebAssembly, bien qu'une soit en cours de développement. À la place, vous devez libérer manuellement toute mémoire et appeler les destructeurs côté JavaScript une fois que vous avez terminé avec l'objet. Pour Embind en particulier, les documentations officielles suggèrent d'appeler une méthode .delete() sur les classes C++ exposées:

Le code JavaScript doit supprimer explicitement tous les gestionnaires d'objets C++ qu'il a reçus, sinon la pile Emscripten augmentera indéfiniment.

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

En effet, lorsque nous faisons cela en JavaScript pour notre 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 fuite disparaît, comme prévu.

Découverte d'autres problèmes avec les nettoyants

La création d'autres codecs Squoosh avec des outils de nettoyage révèle des problèmes similaires et de nouveaux problèmes. Par exemple, j'ai obtenu cette erreur dans les liaisons MozJPEG:

Capture d&#39;écran d&#39;un message

Ici, il ne s'agit pas d'une fuite, mais d'une écriture dans une mémoire en dehors des limites allouées. 😱

En examinant le code de MozJPEG, nous constatons que le problème est que jpeg_mem_dest (la fonction que nous utilisons pour allouer une destination de mémoire pour JPEG) réutilise les valeurs existantes de outbuffer et outsize lorsqu'elles ne sont pas nulles:

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

Cependant, nous l'appelons sans initialiser aucune de ces variables, ce qui signifie que MozJPEG écrit le résultat dans une adresse mémoire potentiellement aléatoire qui s'est avérée être stockée dans ces variables au moment de l'appel.

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

L'initialisation à zéro des deux variables avant l'appel résout ce problème. Le code atteint désormais une vérification de fuite de mémoire. Heureusement, la vérification réussit, ce qui indique qu'il n'y a pas de fuites dans ce codec.

Problèmes liés à l'état partagé

…Ou pas ?

Nous savons que nos liaisons de codec stockent une partie de l'état ainsi que des résultats dans des variables statiques globales, et MozJPEG possède des structures particulièrement complexes.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Que se passe-t-il si certains d'entre eux sont initialisés de manière paresseuse lors de la première exécution, puis réutilisés de manière incorrecte lors des exécutions suivantes ? Un seul appel avec un nettoyeur ne les signalera donc pas comme problématiques.

Essayons de traiter l'image plusieurs fois en cliquant de manière aléatoire sur différents niveaux de qualité dans l'interface utilisateur. En effet, nous obtenons le rapport suivant:

Capture d&#39;écran d&#39;un message

262 144 octets : il semble que l'intégralité de l'image d'exemple ait été divulguée par jpeg_finish_compress.

Après avoir consulté la documentation et les exemples officiels, il s'avère que jpeg_finish_compress ne libère pas la mémoire allouée par notre appel jpeg_mem_dest précédent. Il ne libère que la structure de compression, même si cette structure de compression connaît déjà notre destination de mémoire. Soupir.

Pour résoudre ce problème, libérez les données manuellement dans la fonction 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);
}

Je pourrais continuer à rechercher ces bugs de mémoire un par un, mais je pense qu'il est assez clair à ce stade que l'approche actuelle de la gestion de la mémoire entraîne des problèmes systématiques déplaisants.

Certaines d'entre elles peuvent être capturées immédiatement par le désinfectant. D'autres nécessitent des astuces complexes pour être détectés. Enfin, comme nous pouvons le voir dans les journaux, il existe des problèmes comme au début de l'article qui ne sont pas du tout détectés par le nettoyeur. En effet, l'utilisation abusive réelle se produit côté JavaScript, sur lequel le nettoyeur n'a aucune visibilité. Ces problèmes ne se révéleront qu'en production ou après des modifications du code qui semblent n'avoir aucun rapport.

Créer un wrapper sécurisé

Revenons en arrière et résolvons tous ces problèmes en restructurant le code de manière plus sûre. Je vais à nouveau utiliser le wrapper ImageQuant comme exemple, mais des règles de refactoring similaires s'appliquent à tous les codecs, ainsi qu'à d'autres codebases similaires.

Tout d'abord, corrigeons le problème d'utilisation après libération au début de l'article. Pour ce faire, nous devons cloner les données de la vue compatible avec WebAssembly avant de les marquer comme libres côté 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;
}

Assurons-nous maintenant de ne pas partager d'état dans les variables globales entre les appels. Cela permettra de résoudre certains des problèmes que nous avons déjà rencontrés et de faciliter l'utilisation de nos codecs dans un environnement multithread à l'avenir.

Pour ce faire, nous refactorisons le wrapper C++ pour nous assurer que chaque appel de la fonction gère ses propres données à l'aide de variables locales. Nous pouvons ensuite modifier la signature de notre fonction free_result pour accepter le pointeur:

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

Toutefois, comme nous utilisons déjà Embind dans Emscripten pour interagir avec JavaScript, nous pouvons aussi rendre l'API encore plus sécurisée en masquant complètement les détails de gestion de la mémoire C++.

Pour ce faire, transférons la partie new Uint8ClampedArray(…) de JavaScript vers le côté C++ avec Embind. Nous pouvons ensuite l'utiliser pour cloner les données dans la mémoire JavaScript même avant de revenir de la fonction:

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

Notez qu'avec une seule modification, nous nous assurons à la fois que le tableau d'octets résultant appartient à JavaScript et qu'il n'est pas pris en charge par la mémoire WebAssembly, et nous nous débarrassons également du wrapper RawImage précédemment divulgué.

JavaScript n'a plus à se soucier de libérer des données et peut utiliser le résultat comme n'importe quel autre objet collecté par le garbage collector:

  // 

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

Cela signifie également que nous n'avons plus besoin d'une liaison free_result personnalisée côté 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());
}

Dans l'ensemble, notre code de wrapper est devenu à la fois plus propre et plus sûr.

J'ai ensuite apporté d'autres améliorations mineures au code du wrapper ImageQuant et répliqué des corrections de gestion de la mémoire similaires pour d'autres codecs. Pour en savoir plus, consultez la PR générée ici: Corrections de mémoire pour les codecs C++.

Points à retenir

Quelles leçons pouvons-nous tirer de ce refactoring et partager pour pouvoir les appliquer à d'autres codebases ?

  • N'utilisez pas de vues de mémoire compatibles avec WebAssembly (quel que soit le langage à partir duquel elles sont créées) au-delà d'une seule invocation. Vous ne pouvez pas vous attendre à ce qu'elles survivent plus longtemps que cela, et vous ne pourrez pas détecter ces bugs par des moyens conventionnels. Par conséquent, si vous devez stocker les données pour plus tard, copiez-les côté JavaScript et stockez-les là.
  • Si possible, utilisez un langage de gestion de la mémoire sécurisé ou, au moins, des wrappers de type sécurisé, au lieu d'utiliser directement des pointeurs bruts. Cela ne vous évitera pas les bugs à la frontière JavaScript ↔ WebAssembly, mais cela réduira au moins la surface des bugs autocontenus par le code de langage statique.
  • Quel que soit le langage que vous utilisez, exécutez le code avec des outils de nettoyage pendant le développement. Ils peuvent vous aider à détecter non seulement les problèmes dans le code de langage statique, mais aussi certains problèmes au niveau de la limite JavaScript ↔ WebAssembly, comme oublier d'appeler .delete() ou transmettre des pointeurs non valides du côté JavaScript.
  • Si possible, évitez d'exposer des données et des objets non gérés de WebAssembly à JavaScript. JavaScript est un langage de collecte des déchets, et la gestion manuelle de la mémoire n'est pas courante. Cela peut être considéré comme une fuite d'abstraction du modèle de mémoire du langage à partir duquel votre WebAssembly a été créé. Une gestion incorrecte est facile à négliger dans un codebase JavaScript.
  • Cela peut sembler évident, mais, comme dans tout autre codebase, évitez de stocker un état modifiable dans des variables globales. Vous ne souhaitez pas déboguer des problèmes liés à sa réutilisation dans différentes invocations ou même des threads. Il est donc préférable de le garder aussi autonome que possible.