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

Même si JavaScript est assez parlant pour nettoyer après lui-même, les langages statiques ne sont absolument pas...

Squoosh.app est une PWA qui illustre les différents codecs d'image peuvent améliorer la taille du fichier image sans affecter considérablement la qualité. Cependant, il est aussi une démo technique montrant comment prendre des bibliothèques écrites en C++ ou Rust et les importer dans le Web.

Pouvoir transférer du code depuis des écosystèmes existants est extrêmement utile, mais certains éléments clés les différences entre ces langages statiques et JavaScript. L'une d'entre elles se trouve approches de la gestion de la mémoire.

Si JavaScript est assez parlant pour nettoyer après lui-même, de tels langages statiques sont certainement pas. Vous devez demander explicitement une nouvelle mémoire allouée et assurez-vous de le rendre après et de ne plus jamais l’utiliser. Si ce n'est pas le cas, vous avez des fuites... et cela se produit en fait assez régulièrement. Voyons comment vous pouvez déboguer ces fuites de mémoire et, comment concevoir votre code pour les éviter la prochaine fois.

Schéma 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 Wrappers de codec C++. Examinons un wrapper ImageQuant en tant que exemple (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 (oui, 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: il est use-after-free, mais dans JavaScript!

Dans Emscripten, typed_memory_view renvoie un Uint8Array JavaScript reposant sur WebAssembly (Wasm). tampon de mémoire, avec byteOffset et byteLength définis sur le pointeur et la longueur donnés. La principale 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, il appelle à son tour une fonction C standard free pour marquer cette mémoire comme disponible pour toute allocation future, ce qui signifie que les données de notre vue Uint8Array peut être remplacé par des données arbitraires lors d'un futur appel dans Wasm.

Ou bien, une implémentation de free peut décider de remplir à zéro la mémoire libérée immédiatement. La free utilisé par Emscripten ne le fait pas, mais nous nous appuyons sur un détail d'implémentation ici. qui ne peuvent pas être garanties.

Ou, même si la mémoire derrière le pointeur est préservée, une nouvelle allocation peut avoir besoin d'augmenter la Mémoire WebAssembly. Lorsque WebAssembly.Memory est développé soit via l'API JavaScript, soit via une l'instruction memory.grow, cela invalide le ArrayBuffer existant et, de manière transitoire, toutes les vues. s'appuient dessus.

Je vais utiliser la console des outils de développement (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 Wasm entre free_result et new Uint8ClampedArray, il se peut qu'à un moment donné, nous ajoutions la prise en charge du multithreading à nos codecs. Dans ce cas, peut être un thread complètement différent qui écrase les données juste avant de parvenir à les cloner.

Recherche de bugs dans la 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 dans la pratique. Ça semble être l'occasion idéale d'essayer les tout nouveaux désinfectants Emscripten que nous avons ajoutées l'année dernière et présenté lors de notre conférence 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 activera automatiquement les contrôles de sécurité du pointeur, mais nous voulons aussi trouver la mémoire potentielle les fuites sonores. Étant donné que nous utilisons ImageQuant comme bibliothèque plutôt que comme programme, il n'y a pas de "point de sortie" à Emscripten peut 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 peut être appelé manuellement chaque fois que toute la mémoire doit être libérée et que nous voulons vérifier que cette hypothèse. __lsan_do_leak_check est destiné à être utilisé à la fin d'une application en cours d'exécution, lorsque vous voulez annuler le processus en cas de détection de fuite, tandis que __lsan_do_recoverable_leak_check est plus adapté aux cas d'utilisation des bibliothèques comme le nôtre, lorsque vous souhaitez imprimer des fuites sur la console, mais que pour que l'application continue à s'exécuter.

Exposons cette deuxième aide via Embind afin de pouvoir l'appeler à tout moment à partir de 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 à partir du côté JavaScript une fois que nous avons terminé l'image. Pour ce faire, Côté JavaScript, plutôt que C++, permet de s'assurer que tous les champs d'application ont été et tous les objets C++ temporaires ont été libérés au moment de l'exécution de 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
  );
}

Nous obtenons ainsi un rapport semblable à celui-ci dans la console:

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

Oups, il y a de petites fuites, mais la trace de la pile n'est pas très utile, car tous les noms de fonctions s'emmêlent. 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

Cela semble beaucoup mieux:

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

Certaines parties de la trace de la pile semblent encore obscurs, car elles pointent vers les composants internes d'Empscripten, mais nous pouvons indiquer que la fuite provient d'une conversion RawImage en "type de fil" (à une valeur JavaScript) par Associer. En examinant le code, nous constatons que nous renvoyons les instances C++ RawImage à en JavaScript, mais nous ne les libérons jamais de chaque côté.

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

Le code JavaScript doit supprimer explicitement tous les descripteurs d'objet C++ qu'il a reçus, ou l'emscripten le tas de mémoire croît 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écouvrir plus de 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. Pour J'ai cette erreur dans les liaisons MozJPEG:

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

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

En examinant le code de MozJPEG, nous constatons que le problème est que jpeg_mem_dest, le que nous utilisons afin d'allouer une destination mémoire au format JPEG : réutilise les valeurs existantes outbuffer et outsize lorsqu'ils autre que 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'invoquons sans initialiser ces variables, ce qui signifie que MozJPEG écrit le une adresse mémoire potentiellement aléatoire qui a été stockée dans ces variables au niveau à l'heure de l'appel.

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

L'initialisation à zéro des deux variables avant que l'appel ne résolve ce problème, et le code atteint maintenant une une vérification de fuite de mémoire à la place. Heureusement, la vérification réussit, ce qui indique que nous n'avons dans ce codec.

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

Ou est-ce le cas ?

Nous savons que nos liaisons de codec stockent une partie de l'état et génèrent des données et MozJPEG présente des structures particulièrement compliquées.

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 certaines d'entre elles sont initialisées de manière différée lors de la première exécution, puis réutilisées de manière incorrecte lors des prochaines s'exécute ? Ainsi, un seul appel avec un désinfectant ne les signalerait pas comme problématique.

Essayons de traiter l'image plusieurs fois en cliquant de manière aléatoire sur différents niveaux de qualité. dans l'UI. Nous obtenons à présent le rapport suivant:

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

262 144 octets. On dirait que l'intégralité de l'exemple d'image a été divulguée de 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, mais uniquement le de compression, même si celle-ci connaît déjà l'existence destination... 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 à les fouiller un par un, mais je pense qu'à présent, il est assez clair l'approche actuelle de la gestion de la mémoire entraîne des problèmes systématiques désagréables.

Certains peuvent être éliminés immédiatement par le désinfectant. D'autres nécessitent des tours complexes pour être interceptés. 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 interceptés par le désinfectant. La raison est que l'usage abusif réel se produit sur le Côté JavaScript, dans lequel le désinfectant n'a aucune visibilité. Ces problèmes se révéleront qu'en production ou après des modifications ultérieures du code apparemment sans rapport.

Créer un wrapper sécurisé

Revenons un peu en arrière pour résoudre 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 refactorisation similaires s'appliquent. à tous les codecs, ainsi qu'à d'autres codebases similaires.

Tout d'abord, corrigeons le problème "use-after-free" dès le début de cet article. Pour cela, nous avons besoin pour cloner les données de la vue reposant sur WebAssembly avant de les marquer comme libres 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 à présent que nous ne partageons aucun état dans les variables globales entre les appels. Ce permet de résoudre certains des problèmes déjà rencontrés et facilite l'utilisation de notre dans un environnement multithread à l'avenir.

Pour ce faire, nous refactorisons le wrapper C++ pour nous assurer que chaque appel à la fonction gère sa propre à l'aide de variables locales. Ensuite, nous pouvons remplacer la signature de notre fonction free_result par acceptez le pointeur de retour:

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

Mais, comme nous utilisons déjà Embind dans Emscripten pour interagir avec JavaScript, nous pourrions rendre l'API encore plus sûre en masquant les détails de la gestion de la mémoire C++.

Pour cela, déplaçons la partie new Uint8ClampedArray(…) de JavaScript vers C++ avec Associer. Nous pouvons ensuite l'utiliser pour cloner les données dans la mémoire JavaScript, même avant de renvoyer 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'en une seule modification, nous nous assurons tous deux que le tableau d'octets obtenu appartient à JavaScript. et non sauvegardés par la mémoire WebAssembly, et supprimez le wrapper RawImage précédemment divulgué. .

JavaScript n'a plus à se soucier de libérer des données et peut utiliser le résultat tout autre objet récupéré avec la 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é d'autres améliorations mineures au code du wrapper ImageQuant. des correctifs de gestion de mémoire similaires pour d'autres codecs. Pour en savoir plus, vous pouvez consulter le PR résultant ici: Correctifs de mémoire pour C++ codecs.

Points à retenir

Quelles leçons pouvons-nous tirer de cette refactorisation et en tirer des enseignements qui pourraient être appliqués à d'autres codebases ?

  • N'utilisez pas de vues de mémoire sauvegardées par WebAssembly, quel que soit le langage de développement, au-delà d'un à un appel unique. Vous ne pouvez pas compter sur leur survie plus longtemps et vous ne pourrez pas pour 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 y stocker les données.
  • Si possible, utilisez un langage de gestion de la mémoire sécurisé ou, au moins, des wrappers de type sûr, au lieu de directement sur les pointeurs bruts. Cela ne vous évitera pas de bugs dans JavaScript → WebAssembly mais au moins cela réduira la surface de signalement de bugs que le code de langage statique impliquera.
  • Quel que soit le langage utilisé, exécutez du code avec des désinfectants pendant le développement : ils peuvent vous aider à détecter non seulement les problèmes dans le code de langage statique, mais aussi ceux qui concernent le code JavaScript ;" Limite WebAssembly, par exemple en oubliant d'appeler .delete() ou en transmettant des pointeurs non valides depuis 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 récupération de mémoire, pour lequel 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 de votre WebAssembly et la gestion incorrecte est facile à négliger dans un codebase JavaScript.
  • Cela peut sembler évident, mais comme dans n'importe quel autre codebase, évitez de stocker un état modifiable dans des variables. Vous ne voulez pas déboguer les problèmes de réutilisation lors de divers appels ou même des threads, il est donc préférable de le garder aussi autonome que possible.