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

Si JavaScript est assez indulgent en ce qui concerne le nettoyage, les langages statiques ne sont pas...

Squoosh.app est une PWA qui illustre à quel point différents codecs et paramètres d'image peuvent améliorer la taille des fichiers image sans affecter la qualité de manière significative. Cependant, il s'agit également d'une démonstration technique montrant comment récupérer des bibliothèques écrites en C++ ou Rust pour les importer sur le Web.

La capacité à transférer 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 s'impose assez facilement dans le nettoyage après lui-même, ces langages statiques ne le sont absolument pas. Vous devez demander explicitement une nouvelle mémoire allouée et vous devez vraiment vous assurer de la restituer par la suite pour ne plus jamais l'utiliser. S'il n'y en a pas, vous avez des fuites, et en fait, cela se produit 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.

Schéma suspect

Récemment, alors que j'ai commencé à travailler sur Squoosh, je n'ai pas pu m'empêcher de remarquer un schéma intéressant dans les wrappers de codec C++. Prenons l'exemple d'un wrapper ImageQuant (avec une version réduite 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 (bien, 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
  );
}

Repérez-vous un problème ? Indice: l'utilisation est use-after-free, mais en JavaScript !

Dans Emscripten, typed_memory_view renvoie un Uint8Array JavaScript sauvegardé par le tampon de mémoire WebAssembly (Wasm), avec byteOffset et byteLength définis sur le pointeur et la longueur donnés. Le principal est qu'il s'agit d'une vue TypedArray dans un tampon de mémoire WebAssembly, et non d'une copie des données appartenant à JavaScript.

Lorsque nous appelons free_result à partir de JavaScript, elle appelle à son tour une fonction C standard free pour marquer cette mémoire comme disponible pour toutes les allocations futures, ce qui signifie que les données vers lesquelles pointe notre vue Uint8Array peuvent être remplacées par des données arbitraires par tout prochain appel à Wasm.

Certaines implémentations de free peuvent également décider de remplir immédiatement la mémoire libérée à zéro. Le free utilisé par Emscripten ne le fait pas, mais nous nous appuyons 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 conservée, une nouvelle allocation peut avoir besoin d'augmenter la mémoire WebAssembly. Lorsque WebAssembly.Memory est augmenté via l'API JavaScript ou l'instruction memory.grow correspondante, il invalide le ArrayBuffer existant et, de manière transitoire, toutes les vues qui lui sont associées.

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 pas explicitement à nouveau Wasm entre free_result et new Uint8ClampedArray, nous pourrions à un moment donné 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 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 de tester les nouveaux désinfectants Emscripten, ajoutés l'année dernière et présentés lors de notre conférence WebAssembly lors du Chrome Dev Summit.

Dans le cas présent, 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 activera automatiquement les contrôles de sécurité du pointeur, mais nous souhaitons également détecter les fuites de mémoire potentielles. Étant donné que nous utilisons ImageQuant en tant que bibliothèque plutôt qu'en tant que programme, il n'existe aucun "point de sortie" permettant à Emscripten de vérifier automatiquement que toute la mémoire a été libérée.

À la place, 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 toute la mémoire doit être 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 fuite, tandis que __lsan_do_recoverable_leak_check convient mieux aux cas d'utilisation de bibliothèques comme le nôtre, lorsque vous souhaitez imprimer des fuites sur la console, tout en maintenant l'application en cours d'exécution.

Affichons ce deuxième outil d'aide via Embind afin de pouvoir l'appeler à partir de JavaScript à tout moment:

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

Ensuite, appelez-le depuis le côté JavaScript une fois que nous avons fini d'utiliser l'image. Cela permet de s'assurer que tous les champs d'application ont été fermés et que tous les objets C++ temporaires ont été libérés au moment de l'exécution des 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
  );
}

Un rapport semblable à celui-ci s'affiche dans la console:

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

Petit problème... La trace de la pile n'est pas très utile, car tous les noms de fonctions sont tronqués. Recompilons avec les 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

Cela semble 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 encore obscures, car elles pointent vers les composants internes d'Emscripten. Toutefois, nous pouvons constater 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 RawImage instances C++ en JavaScript, mais que nous ne les libérons jamais de part et d'autre.

Pour rappel, il n'existe actuellement aucune intégration de récupération de mémoire entre JavaScript et WebAssembly, bien qu'une autre soit en cours de développement. Au lieu de cela, vous devez libérer manuellement toute mémoire et appeler des destructeurs du côté JavaScript une fois que vous avez terminé d'utiliser l'objet. Pour Embind en particulier, la documentation officielle suggère d'appeler une méthode .delete() sur les classes C++ exposées:

Le code JavaScript doit supprimer explicitement tout objet C++ qu'il a reçu, sans quoi le tas de mémoire Emscripten augmentera indéfiniment.

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

En effet, lorsque nous le faisons 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 normalement.

Découvrir d'autres problèmes liés aux désinfectants

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

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

Ici, ce n'est pas une fuite, mais nous écrivons dans une mémoire en dehors des limites allouées EXTERNAL

En examinant le code de MozJPEG, nous constatons que le problème ici 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 sont différentes de zéro:

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 l'une de ces variables, ce qui signifie que MozJPEG écrit le résultat dans une adresse de mémoire potentiellement aléatoire qui a été 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, et le code atteint désormais une vérification de fuite de mémoire. Heureusement, la vérification réussit, ce qui indique que nous n'avons aucune fuite dans ce codec.

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

... C'est le cas ou non ?

Nous savons que nos liaisons de codec stockent une partie de l'état ainsi que les résultats dans des variables statiques globales, et que MozJPEG présente 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 en différé à la première exécution, puis réutilisés de manière incorrecte lors de futures exécutions ? Dans ce cas, un simple appel avec un produit désinfectant ne signalerait pas le problème.

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 maintenant le rapport suivant:

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

262 144 octets. On dirait que la totalité de l'image d'exemple a été divulguée à partir de jpeg_finish_compress.

Après avoir consulté les documents et les exemples officiels, il s'avère que jpeg_finish_compress ne libère pas la mémoire allouée par notre précédent appel jpeg_mem_dest. Il libère seulement la structure de compression, même si celle-ci 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 à examiner ces bugs de mémoire un par un, mais je pense qu'à présent, il est suffisamment clair que l'approche actuelle de la gestion de la mémoire entraîne des problèmes systématiques désagréables.

Certaines d'entre elles peuvent être immédiatement attrapées par le désinfectant. D'autres ont besoin d'astuces complexes pour les attraper. Enfin, il y a des problèmes comme au début de l'article qui, comme nous pouvons le voir dans les journaux, ne sont pas du tout détectés par le désinfectant. En effet, l'usage abusif se produit du côté JavaScript, que le désinfectant n'a aucune visibilité. Ces problèmes ne se manifestent qu'en production ou après des modifications apparemment sans lien avec le code.

Créer un wrapper sécurisé

Reprenons quelques pas en arrière et résolvons tous ces problèmes en restructurant le code de manière plus sécurisée. J'utiliserai à nouveau le wrapper ImageQuant comme exemple, mais des règles de refactorisation similaires s'appliquent à tous les codecs, ainsi qu'à d'autres codebases similaires.

Tout d'abord, résolvons le problème "use-after-free" au début de la publication. Pour ce faire, nous devons cloner les données à partir de la vue sauvegardée par WebAssembly avant de les marquer comme sans frais du 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;
}

Vérifions maintenant que nous ne partageons aucun état dans les variables globales entre les appels. Cela permettra à la fois 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 à 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 pourrions aussi rendre l'API encore plus sûre en masquant complètement les détails de gestion de la mémoire C++.

Pour cela, déplaçons 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 avant d'effectuer le renvoi 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 que le tableau d'octets obtenu appartient à JavaScript et n'est pas sauvegardé par la mémoire WebAssembly, et nous supprimons également le wrapper RawImage précédemment divulgué.

Désormais, JavaScript n'a plus à se soucier de la libération des données et peut utiliser le résultat comme tout autre objet ayant fait l'objet d'une récupération de mémoire:

  // …

  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 wrapper est devenu à la fois plus propre et plus sûr.

Ensuite, j'ai apporté quelques améliorations mineures au code du wrapper ImageQuant et répliqué des correctifs similaires de gestion de la mémoire pour d'autres codecs. Si vous souhaitez en savoir plus, vous pouvez consulter le PR obtenu ici: Memory correctifs for C++ codecs (Corrections de mémoire pour les codecs C++).

Points à retenir

Quelles leçons pouvons-nous tirer de cette refactorisation qui pourrait être appliquée à d'autres codebases ?

  • N'utilisez pas de vues de mémoire sauvegardées par WebAssembly, quel que soit le langage dans lequel elles sont compilées, au-delà d'un seul appel. Vous ne pouvez pas compter sur leur survie plus longtemps, et vous ne pourrez pas détecter ces bugs par des moyens conventionnels. Par conséquent, si vous devez stocker des données pour plus tard, copiez-les du côté JavaScript et stockez-les ici.
  • 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 de bugs au niveau de la limite JavaScript/WebAssembly, mais au moins cela réduira la surface pour les bugs autonomes contenus dans le code de langage statique.
  • Quel que soit le langage que vous utilisez, exécutez du code avec des désinfectants pendant le développement. Ils peuvent vous aider à détecter non seulement des problèmes dans le code de langage statique, mais également des problèmes au niveau des limites entre JavaScript et WebAssembly, tels que l'oubli d'appeler .delete() ou la transmission de pointeurs non valides du côté JavaScript.
  • Si possible, évitez d'exposer à JavaScript des données et des objets non gérés depuis WebAssembly. JavaScript est un langage récupéré de mémoire, 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 dans lequel votre WebAssembly a été créé. Une gestion incorrecte est facile à négliger dans un codebase JavaScript.
  • Cela peut être évident, mais, comme dans tout autre codebase, évitez de stocker un état modifiable dans des variables globales. Vous ne souhaitez pas déboguer les problèmes liés à sa réutilisation dans différents appels ou même threads. Il est donc préférable qu'elle soit aussi autonome que possible.