Como depurar vazamentos de memória no WebAssembly usando o Emscripten

Embora o JavaScript seja perdoável na limpeza após si mesmo, as linguagens estáticas definitivamente não são...

O Squoosh.app é um PWA que ilustra como diferentes codecs e configurações de imagem podem melhorar o tamanho do arquivo de imagem sem afetar significativamente a qualidade. No entanto, também é uma demonstração técnica que mostra como você pode pegar bibliotecas escritas em C++ ou Rust e trazê-las para a Web.

A capacidade de transferir código de ecossistemas existentes é incrivelmente valiosa, mas existem algumas diferenças importantes entre essas linguagens estáticas e o JavaScript. Uma delas são as abordagens diferentes para o gerenciamento de memória.

Embora o JavaScript seja perdoável na limpeza após si mesmo, essas linguagens estáticas definitivamente não são. É necessário solicitar explicitamente uma nova memória alocada e garantir que você a devolva e nunca a use novamente. Se isso não acontecer, haverá vazamentos, e, na verdade, isso acontece com regularidade. Vamos descobrir como depurar esses vazamentos de memória e, melhor ainda, criar código para evitá-los da próxima vez.

Padrão suspeito

Recentemente, enquanto comecei a trabalhar no Squoosh, notei um padrão interessante nos wrappers de codec C++. Vejamos um exemplo de wrapper ImageQuant (reduzido para mostrar apenas a criação de objetos e as partes de desalocação):

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

Você percebe um problema? Dica: ele é use-after-free, mas em JavaScript.

No Emscripten, typed_memory_view retorna um Uint8Array JavaScript com suporte do buffer de memória do WebAssembly (Wasm), com byteOffset e byteLength definidos como o ponteiro e o comprimento especificados. O ponto principal é que essa é uma visualização do TypedArray em um buffer de memória do WebAssembly, em vez de uma cópia dos dados de propriedade do JavaScript.

Quando chamamos free_result do JavaScript, ele, por sua vez, chama uma função C padrão free para marcar essa memória como disponível para alocações futuras. Isso significa que os dados para os quais nossas visualizações de Uint8Array podem ser substituídos por dados arbitrários por qualquer chamada futura ao Wasm.

Ou alguma implementação de free pode até decidir preencher a memória liberada imediatamente. O free usado pelo Emscripten não faz isso, mas contamos com um detalhe de implementação que não pode ser garantido.

Ou, mesmo que a memória por trás do ponteiro seja preservada, uma nova alocação pode precisar aumentar a memória do WebAssembly. Quando a WebAssembly.Memory cresce usando a API JavaScript ou a instrução memory.grow correspondente, ela invalida o ArrayBuffer existente e, transitivamente, as visualizações apoiadas por ele.

Vou usar o console do DevTools (ou Node.js) para demonstrar esse 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

Por fim, mesmo que não chamemos explicitamente o Wasm novamente entre free_result e new Uint8ClampedArray, em algum momento podemos adicionar suporte a várias linhas de execução aos nossos codecs. Nesse caso, pode ser uma linha de execução completamente diferente que substitui os dados pouco antes de eles serem clonados.

Procurando bugs de memória

Por precaução, decidi ir mais além e verificar se esse código exibe algum problema na prática. Esta parece ser uma oportunidade perfeita para testar o novo suporte a desinfetantes Emscripten que foi adicionado no ano passado e apresentado em nossa palestra WebAssembly na Conferência de Desenvolvedores do Chrome:

Nesse caso, estamos interessados no AddressSanitizer, que pode detectar vários problemas relacionados a ponteiro e memória. Para usá-lo, precisamos recompilar nosso codec com -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

Isso vai ativar automaticamente as verificações de segurança do ponteiro, mas também queremos encontrar possíveis vazamentos de memória. Como estamos usando o ImageQuant como uma biblioteca em vez de um programa, não há um "ponto de saída" em que o Emscripten possa validar automaticamente se toda a memória foi liberada.

Em vez disso, para esses casos, o LeakSanitizer (incluído no AddressSanitizer) fornece as funções __lsan_do_leak_check e __lsan_do_recoverable_leak_check, que podem ser invocadas manualmente sempre que esperamos que toda a memória seja liberada e quisermos validar essa suposição. __lsan_do_leak_check é usado no final de um aplicativo em execução, quando você quer cancelar o processo caso algum vazamento seja detectado, enquanto __lsan_do_recoverable_leak_check é mais adequado para casos de uso de bibliotecas como o nosso, quando você quer imprimir vazamentos no console, mas manter o aplicativo em execução mesmo assim.

Vamos expor esse segundo auxiliar pelo Embind para que possamos chamá-lo do JavaScript a qualquer 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 invocá-lo do lado do JavaScript quando terminarmos a imagem. Fazer isso pelo JavaScript, em vez do C++, ajuda a garantir que todos os escopos sejam saídos e todos os objetos C++ temporários sejam liberados quando executarmos essas verificações:

  // …

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

Isso gera um relatório como este no console:

Captura de tela de uma mensagem

Há alguns pequenos vazamentos, mas o stack trace não é muito útil, porque todos os nomes de funções estão corrompidos. Vamos recompilar com informações básicas de depuração para preservá-las:

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

Isso ficou muito melhor:

Captura de tela de uma mensagem dizendo &quot;Vazamento direto de 12 bytes&quot; proveniente de uma função GenericBindingType RawImage ::toWireType

Algumas partes do stack trace ainda parecem obscuras porque apontam para os componentes internos do Emscripten, mas podemos identificar que o vazamento vem de uma conversão de RawImage para um "tipo de transferência" (para um valor JavaScript) da Embind. Na verdade, ao analisar o código, podemos notar que retornamos instâncias C++ RawImage para JavaScript, mas nunca as liberamos em nenhum dos lados.

Lembre-se de que, no momento, não há integração de coleta de lixo entre o JavaScript e o WebAssembly, embora uma esteja em desenvolvimento. Em vez disso, é preciso liberar manualmente qualquer memória e chamar destruidores do lado do JavaScript quando terminar de usar o objeto. Para o Embind especificamente, os documentos oficiais sugerem chamar um método .delete() em classes C++ expostas:

O código JavaScript precisa excluir explicitamente todos os identificadores de objeto C++ recebidos. Caso contrário, o heap Emscripten crescerá indefinidamente.

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

De fato, quando fazemos isso em JavaScript para nossa 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
  );
}

O vazamento desaparece conforme esperado.

Descobrir mais problemas com higienizadores

A criação de outros codecs Squoosh com limpadores revela problemas semelhantes e alguns novos. Por exemplo, vejo este erro nas vinculações do MozJPEG:

Captura de tela de uma mensagem

Aqui não é um vazamento, mas a gente grava em uma memória fora dos limites alocados 😂

Analisando o código do MozJPEG, descobrimos que o problema é que jpeg_mem_dest, a função que usamos para alocar um destino de memória para JPEG, reutiliza os valores existentes de outbuffer e outsize quando eles são diferentes de 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;
}

No entanto, a invocamos sem inicializar nenhuma dessas variáveis, o que significa que o MozJPEG grava o resultado em um endereço de memória potencialmente aleatório que foi armazenado nessas variáveis no momento da chamada.

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

A inicialização das duas variáveis antes da invocação resolve esse problema. Agora, o código atinge uma verificação de vazamento de memória. Felizmente, a verificação foi aprovada, indicando que não há vazamentos nesse codec.

Problemas com o estado compartilhado

... Ou nós?

Sabemos que nossas vinculações de codec armazenam parte do estado e resultados em variáveis estáticas globais, e o MozJPEG tem algumas estruturas particularmente complicadas.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

E se alguns deles forem inicializados lentamente na primeira execução e depois reutilizados de maneira inadequada em execuções futuras? Assim, uma única chamada com um limpador não os reportaria como problemáticos.

Vamos tentar processar a imagem algumas vezes clicando aleatoriamente em diferentes níveis de qualidade na interface. Agora recebemos o seguinte relatório:

Captura de tela de uma mensagem

262.144 bytes,parece que toda a imagem de amostra vazou do jpeg_finish_compress.

Depois de conferir os documentos e os exemplos oficiais, descobrimos que jpeg_finish_compress não libera a memória alocada pela chamada jpeg_mem_dest anterior. Isso apenas libera a estrutura de compactação, mesmo que ela já conheça o destino de memória... suspiro.

Podemos corrigir isso liberando os dados manualmente na função 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);
}

Eu poderia continuar caçando esses bugs de memória, um por um, mas acho que está claro o suficiente que a abordagem atual para o gerenciamento de memória leve a alguns problemas sistemáticos desagradáveis.

Alguns deles podem ser detectados pelo desinfetante imediatamente. Outros exigem truques complexos para serem capturados. Por fim, há problemas como no início da postagem que, como podemos ver nos registros, não são detectados pelo limpador. O motivo é que o uso indevido real acontece no lado do JavaScript, em que a limpeza não tem visibilidade. Esses problemas serão revelados apenas na produção ou após alterações aparentemente não relacionadas no código no futuro.

Como criar um wrapper seguro

Vamos voltar alguns passos e corrigir todos esses problemas reestruturando o código de uma maneira mais segura. Vou usar o wrapper ImageQuant como exemplo novamente, mas regras de refatoração semelhantes se aplicam a todos os codecs e a outras bases de código semelhantes.

Primeiro, vamos corrigir o problema de uso após a liberação do início da postagem. Para isso, precisamos clonar os dados da visualização compatível com WebAssembly antes de marcá-los como livres no 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;
}

Agora, vamos garantir que nenhum estado seja compartilhado em variáveis globais entre as invocações. Isso corrigirá alguns dos problemas que já vimos e facilitará o uso de nossos codecs em um ambiente com várias linhas de execução no futuro.

Para fazer isso, refatoramos o wrapper C++ para garantir que cada chamada para a função gerencie os próprios dados usando variáveis locais. Em seguida, podemos mudar a assinatura da função free_result para aceitar o ponteiro de volta:

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

No entanto, como já estamos usando o Embind no Emscripten para interagir com o JavaScript, é possível tornar a API ainda mais segura ocultando todos os detalhes de gerenciamento de memória do C++.

Para isso, vamos mover a parte do new Uint8ClampedArray(…) do JavaScript para o lado do C++ com Embind. Em seguida, é possível usá-lo para clonar os dados na memória JavaScript mesmo antes de retornar da função:

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

Observe como, com uma única mudança, garantimos que a matriz de bytes resultante seja de propriedade do JavaScript e não da memória WebAssembly, e também nos livramos do wrapper RawImage vazado anteriormente.

Agora, o JavaScript não precisa mais se preocupar em liberar dados e pode usar o resultado como qualquer outro objeto de coleta de lixo:

  // …

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

Isso também significa que não precisamos mais de uma vinculação free_result personalizada no lado do 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());
}

De modo geral, o código wrapper ficou mais limpo e mais seguro ao mesmo tempo.

Depois disso, fiz algumas pequenas melhorias no código do wrapper ImageQuant e replicamos correções de gerenciamento de memória semelhantes para outros codecs. Se você quiser saber mais detalhes, confira o PR resultante aqui: Correções de memória para codecs C++.

Pontos principais

Que lições podemos aprender e compartilhar com essa refatoração que podem ser aplicadas a outras bases de código?

  • Não use visualizações de memória com suporte do WebAssembly, independentemente da linguagem dele, além de uma única invocação. Você não pode confiar que eles sobrevivam por mais tempo do que isso e não será possível detectar esses bugs por meios convencionais. Portanto, se você precisar armazenar os dados para mais tarde, copie-os para o JavaScript e armazene-os lá.
  • Se possível, use uma linguagem de gerenciamento de memória segura ou, pelo menos, wrappers de tipo seguro, em vez de operar diretamente em ponteiros brutos. Isso não evitará bugs no limite do JavaScript si WebAssembly, mas pelo menos reduzirá a superfície de bugs independentes do código de linguagem estático.
  • Seja qual for a linguagem usada, execute o código com limpadores durante o desenvolvimento. Eles podem ajudar a identificar problemas não apenas no código da linguagem estática, mas também em alguns problemas no JavaScript ↔ do limite do WebAssembly, como esquecer de chamar .delete() ou transmitir ponteiros inválidos do lado do JavaScript.
  • Se possível, evite expor dados e objetos não gerenciados do WebAssembly ao JavaScript. O JavaScript é uma linguagem coletada de lixo, e o gerenciamento manual de memória não é comum nele. Isso pode ser considerado um vazamento de abstração do modelo de memória da linguagem em que o WebAssembly foi criado, e o gerenciamento incorreto é fácil de ignorar em uma base de código JavaScript.
  • Isso pode ser óbvio, mas, como em qualquer outra base de código, evite armazenar o estado mutável em variáveis globais. Você não quer depurar problemas com a reutilização dele em várias invocações ou mesmo linhas de execução. Por isso, é melhor mantê-lo o mais independente possível.