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

Embora o JavaScript seja bastante tolerante à limpeza de si mesmo, as linguagens estáticas definitivamente não são...

O Squoosh.app é um PWA que ilustra a quantidade de codecs de imagem diferentes e as configurações 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 levá-las ao da Web.

Ser capaz de fazer a portabilidade do código de ecossistemas existentes é incrivelmente valioso, mas existem alguns as diferenças entre essas linguagens estáticas e JavaScript. Uma delas é nos diferentes diferentes para o gerenciamento de memória.

Embora o JavaScript seja bastante tolerante à limpeza de si mesmo, essas linguagens estáticas são definitivamente não. Você precisa pedir explicitamente uma nova memória alocada e garantir certifique-se de devolvê-lo depois e nunca mais usá-lo. Se isso não acontecer, vai haver vazamentos... isso acontece com bastante regularidade. Vamos dar uma olhada em como depurar esses vazamentos de memória e, ainda melhor, como projetar seu código para evitá-los da próxima vez.

Padrão suspeito

Recentemente, quando comecei a trabalhar no Squoosh, não pude deixar de notar um padrão interessante em Wrappers de codec C++. Vejamos um wrapper ImageQuant como um exemplo (reduzido para mostrar apenas as partes de criação e desalocação de objetos):

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ê identifica algum problema? Dica: é use-after-free, mas JavaScript.

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

Quando chamamos free_result do JavaScript, ele chama uma função C padrão free para marcar disponível para futuras alocações, ou seja, os dados que a visualização Uint8Array aponta, pode ser substituído por dados arbitrários por qualquer chamada futura para o Wasm.

Ou alguma implementação de free pode até mesmo decidir preencher a memória liberada imediatamente de zero. A A free usada pela Emscripten não faz isso, mas dependemos de um detalhe de implementação aqui. isso não pode ser garantido.

Ou, mesmo que a memória por trás do ponteiro seja preservada, a nova alocação pode precisar aumentar a Memória do WebAssembly. Quando WebAssembly.Memory for aumentado por meio da API JavaScript ou da memory.grow, ela invalida a ArrayBuffer e, transitivamente, todas as visualizações com ele.

Vamos 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 poderemos adicionar suporte a multissegmentação aos nossos codecs. Nesse caso, pode ser uma linha de execução completamente diferente que substitua os dados um pouco antes de conseguirmos cloná-los.

Procurando bugs de memória

Por precaução, decidi ir mais longe e verificar se este código exibe algum problema na prática. Esta parece uma oportunidade perfeita para testar os novos desinfetantes Emscripten de suporte adicionado no ano passado e apresentados na nossa palestra WebAssembly no Chrome Dev Summit:

Nesse caso, temos interesse AddressSanitizer, que pode detectar vários problemas relacionados ao 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 memórias. vazamentos de dados. Como estamos usando a ImageQuant como uma biblioteca em vez de um programa, não há um "ponto de saída" em em que o Emscripten poderia validar automaticamente que 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 pode ser invocado manualmente sempre que esperamos que toda a memória seja liberada e queremos validar essa suposição de dados. A __lsan_do_leak_check deve ser usada ao 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 adequada para casos de uso de biblioteca como o nosso, quando você quer imprimir vazamentos no console, mas manter o aplicativo em execução de qualquer forma.

Vamos expor esse segundo auxiliar pelo Embind para que ele possa ser chamado no 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 invoque-o no lado do JavaScript quando terminarmos a imagem. Fazer isso pela no lado do JavaScript, em vez de C++, ajuda a garantir que todos os escopos sejam saiu e todos os objetos C++ temporários foram liberados quando executamos 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 nos dá 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 os nomes de todas as funções estão danificados. 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 da mensagem &quot;Vazamento direto de 12 bytes&quot; vindo de uma função GenericBindingType RawImage ::toWireType.

Algumas partes do stack trace ainda parecem obscuras, porque apontam para elementos internos do Emscripten, mas podemos informar que o vazamento está vindo de uma conversão RawImage para um "tipo de condutor neutro". (para um valor JavaScript) ao Embind. De fato, quando analisamos o código, podemos ver que retornamos instâncias de C++ RawImage para JavaScript, mas nunca os liberamos em nenhum dos lados.

Como um lembrete, atualmente não há integração de coleta de lixo entre JavaScript e WebAssembly, embora um esteja em desenvolvimento. Em vez disso, você tem para liberar manualmente qualquer memória e chamar destruidores do lado do JavaScript quando terminar objeto. Para a Embind especificamente, a página oficial documentos sugerem chamar um método .delete() em classes C++ expostas:

O código JavaScript precisa excluir explicitamente qualquer identificador de objeto C++ recebido ou a e a pilha vai 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.

Descoberta de mais problemas com desinfetantes

Criar outros codecs Squoosh com limpadores revela problemas semelhantes e novos. Para exemplo, recebi este erro nas vinculações do MozJPEG:

Captura de tela de uma mensagem

Aqui não é um vazamento, mas estamos gravando em uma memória fora dos limites alocados ✅

Ao analisar o código do MozJPEG, descobrimos que o problema aqui é que jpeg_mem_dest, a que usamos para alocar um destino de memória para JPEG—reutiliza os valores existentes do outbuffer e outsize quando estiverem diferente 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 em um endereço de memória potencialmente aleatório, que foi armazenado nas variáveis da na hora da ligação.

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

A inicialização zero das duas variáveis antes da invocação resolve esse problema, e agora o código atinge uma verificação de vazamento de memória. Felizmente, a verificação foi aprovada, o que indica que não temos nenhuma vazamentos de dados nesse codec.

Problemas com o estado compartilhado

...Ou nós?

Sabemos que nossas vinculações de codec armazenam parte do estado e também resultam em uma sequência variáveis, e o MozJPEG tem 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 desses recursos forem inicializados lentamente na primeira execução e reutilizados de maneira incorreta no futuro? é executado? Assim, uma única chamada com uma ferramenta de limpeza não os reportaria como problemática.

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

Captura de tela de uma mensagem

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

Depois de conferir a documentação e os exemplos oficiais, descobrimos que jpeg_finish_compress não libera a memória alocada pela chamada jpeg_mem_dest anterior, apenas liberando de compactação, embora essa estrutura já conheça nossa memória destino... 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 aqueles insetos da memória, um por um, mas acho que agora está claro o suficiente que os abordagem atual para o gerenciamento de memória leva a alguns problemas sistemáticos desagradáveis.

Alguns deles podem ser pegos imediatamente pelo desinfetante. Outros exigem truques complexos para serem pegos. Por fim, há problemas como no início da postagem que, como podemos ver nos registros, não são pegados pelo desinfetante. O motivo é que o uso indevido real ocorre no no lado do JavaScript, para o qual a limpeza não tem visibilidade. Esses problemas revelam-se somente na produção ou depois de alterações aparentemente não relacionadas ao código no futuro.

Como criar um wrapper seguro

Agora vamos analisar alguns pontos e, em vez disso, corrigir todos esses problemas reestruturando o código. de forma mais segura. Usarei o wrapper ImageQuant novamente como exemplo, mas regras de refatoração semelhantes são aplicadas. a todos os codecs, bem como a outras bases de código semelhantes.

Em primeiro lugar, vamos corrigir o problema de uso após a liberação do início da postagem. Para isso, precisamos para clonar os dados da visualização com suporte do WebAssembly antes de marcá-los como sem custo financeiro no lado do 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 entre as invocações nas variáveis globais. Isso corrigirá alguns dos problemas já mencionados, além de facilitar o uso de nossos em um ambiente com várias linhas de execução no futuro.

Para isso, refatoramos o wrapper C++ para garantir que cada chamada da função gerencie a própria 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);
}

Mas, como já estamos usando o Embind no Emscripten para interagir com o JavaScript, podemos torne a API ainda mais segura ocultando completamente os detalhes do gerenciamento de memória C++.

Para isso, vamos mover a parte new Uint8ClampedArray(…) do JavaScript para a parte C++ com Embind. Em seguida, ele pode ser usado 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 alteração, ambos garantimos que a matriz de bytes resultante seja de propriedade do JavaScript e não tiver suporte da memória do WebAssembly e eliminar o wrapper RawImage que estava vazado também.

Agora, o JavaScript não precisa mais se preocupar com a liberação de 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());
}

Resumindo, nosso código de wrapper se tornou mais limpo e seguro ao mesmo tempo.

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

Aprendizados

Quais 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 de origem, além de única invocação. Você não pode confiar que eles sobrevivam por mais tempo que isso, e você não será capaz para capturar esses bugs por meios convencionais; portanto, se você precisar armazenar os dados para mais tarde, copie-os para no lado do JavaScript e armazená-lo lá.
  • Se possível, use uma linguagem de gerenciamento de memória segura ou, pelo menos, wrappers de tipo seguro, em vez de operando diretamente em ponteiros brutos. Isso não evitará bugs no JavaScript ↔ WebAssembly mas pelo menos reduzirá a superfície de bugs independentes pelo código de linguagem estática.
  • Independentemente da linguagem que você usar, execute o código com limpadores durante o desenvolvimento. Eles podem ajudar a detectar não só problemas no código de linguagem estática, como também alguns problemas no JavaScript ↔ Limite do WebAssembly, como esquecer de chamar .delete() ou transmitir ponteiros inválidos de no lado do JavaScript.
  • Se possível, evite expor dados e objetos não gerenciados do WebAssembly ao JavaScript. JavaScript é uma linguagem de coleta de lixo, e o gerenciamento manual de memória não é comum nela. Isso pode ser considerado um vazamento de abstração do modelo de memória da linguagem em que o WebAssembly foi desenvolvido e é fácil ignorar o gerenciamento incorreto em uma base de código JavaScript.
  • Isso pode ser óbvio, mas, como em qualquer outra base de código, evite armazenar estados mutáveis variáveis. Você não quer depurar problemas com a reutilização deles em várias invocações ou mesmo e por isso é melhor mantê-la o mais independente possível.