Em maio de 2026, as 535 mil linhas de Zig do Bun viraram Rust num único PR com 6.755 commits. A comunidade se dividiu.

O detalhe que explica os 6.755 commits: o Jarred Sumner, criador do Bun, não escreveu isso na mão. Ele orquestrou cerca de 50 workflows do Claude Code rodando sem parar por 11 dias.

E dias antes de mergear, ainda tratava tudo como experimento - “There’s a very high chance all this code gets thrown out completely.” Acabou mergeado. O que destravou foi empírico: quando 100% do test suite passou em todas as plataformas, a opinião dele virou de “vale a pena tentar” pra “vou mergear isso”.

No post sobre a migração, o Jarred apresenta uma lista de bugs que viviam na codebase - use-after-free, double-free, memory leak - todos da mesma classe de problema, que ele alega ser mais fácil de prevenir em Rust.

Vamos abordar esses erros e o que eles significam.

A classe de problema

Bun é um runtime JavaScript. Ele gerencia dois mundos de memória ao mesmo tempo:

  1. Memória gerenciada pelo GC - objetos JS, strings, ArrayBuffers que o garbage collector do JavaScriptCore controla
  2. Memória gerenciada manualmente - ponteiros, buffers, handles de C/C++/Zig que você mesmo aloca e libera

O problema é quando os dois mundos se misturam. Código JS pode voltar no meio de uma operação nativa (reentrância). Callbacks como valueOf() e toString() podem executar código arbitrário. Erros podem sair por caminhos que ninguém testou. E aí:

  • Você libera memória que ainda está sendo usada (use-after-free)
  • Você libera a mesma memória duas vezes (double-free)
  • Você esquece de liberar (memory leak)

Vamos em cada um.

Use-after-free

Em linguagens com garbage collector, quando você não precisa mais de um objeto, ele simplesmente some. O GC cuida de tudo.

Em C, não tem GC. Você pede memória pro sistema com malloc() e devolve com free(). Depois do free(), aquele endereço não é mais seu. Mas o ponteiro continua apontando pra lá.

// Compile: gcc -fsanitize=address -g uaf.c -o uaf && ./uaf
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* cache;   // uma referência guardada pra reusar depois

void processa(char* msg) {
    printf("Processando: %s\n", msg);
    cache = msg;    // guarda o ponteiro
    free(msg);      // e devolve a memória pro sistema
}

int main() {
    char* msg = malloc(100);
    strcpy(msg, "hello world");

    processa(msg);

    // mais tarde, outro trecho usa o que ficou no cache:
    printf("Do cache: %s\n", cache);   // use-after-free! cache aponta pra memória liberada
    return 0;
}

Compila sem erro. Pode até parecer que funciona. Mas o comportamento é indefinido - pode imprimir lixo, pode crashar, pode parecer normal por meses e explodir em produção.

O AddressSanitizer (-fsanitize=address) pega esse tipo de bug na hora. Rode com ele e você vai ver:

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010

Diagnóstico vs prevenção

Ótimo. Ferramenta incrível. Então por que não continuar em C/C++ com sanitizers?

Porque sanitizer é diagnóstico. Rust é prevenção.

Você pode achar que, passando -fsanitize=address pro compilador, isso vira coisa de compile-time. Não vira: o compilador não analisa se o bug pode existir, ele só instrumenta o binário com checagens que rodam junto com o programa. A flag é de compilação; a detecção é em runtime.

Sanitizer roda o código e te diz: “aqui, nesse input específico, nesse caminho de execução específico, naquele momento, você acessou memória liberada”. Se o caminho que tem o bug não foi executado, o sanitizer não pega nada. É uma ferramenta de teste - ela só encontra bugs nos caminhos que você de fato roda.

O borrow checker do Rust é análise estática. Ele te diz: “esse código pode ter use-after-free, não importa qual input, qual caminho, qual momento”. Ele pega o bug antes do código existir. Antes de compilar. Antes de rodar. Antes de ir pra produção.

Análise estática não é exclusividade do Rust - talvez seu editor tenha pego o exemplo acima. Mas em C ela é heurística: acerta o caso fácil e reto, e passa batido nos difíceis. O borrow checker não é opcional nem chuta - ou prova que é seguro, ou não compila.

É a diferença entre exame de sangue e vacina. Sanitizer detecta a doença depois que ela aparece. Rust impede que ela apareça.

Como isso mordeu o Bun

No Bun, os bugs de reentrância eram não-determinísticos. Depende de timing, de qual callback roda primeiro, de quantos requests tão em vôo. Sanitizer pode rodar mil testes e não pegar nada, porque o timing nunca alinhou. O borrow checker pega sempre, porque o problema é estrutural - duas referências mutáveis ao mesmo dado ao mesmo tempo não é uma questão de timing, é uma questão de design.

Boa parte dos bugs listados no Bun são use-after-free. O padrão mais comum é reentrância: código JS volta no meio de uma operação nativa e invalida o estado que a operação estava usando. Exemplo: um hashmap que cresce e realoca tudo internamente, deixando ponteiros pendurados pra memória antiga.

Os bugs reais:

  • node:zlib - heap-use-after-free ao chamar .reset() enquanto .write() assíncrono ainda roda na threadpool. valueOf()/toString() como vetor de ataque.
  • node:http2 - callbacks JS reentrantes (session.request() dentro de listener) causam hashmap rehash, invalidando ponteiros internos de stream.
  • UDPSocket.send()/sendMany() - valueOf() detacha ArrayBuffer entre captura do payload e envio real.
  • Buffer#copy/Buffer#fill - valueOf() detacha/redimensiona ArrayBuffer durante coerção de argumento.

Double-free

Se use-after-free é acessar memória depois de liberar, double-free é liberar a mesma memória duas vezes. Mas não é tão simples quanto free(ptr); free(ptr); na mesma função. Ninguém faz isso. O problema é quando dois donos não sabem um do outro.

// Compile: gcc -fsanitize=address -g df.c -o df && ./df
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    int fd;
    char name[64];
} Pipe;

// Função A: agenda um close assíncrono
// Na prática, isso seria libuv agendando um callback pro próximo tick
void close_pipe_async(Pipe* pipe) {
    printf("  [async] Close agendado. Callback vai rodar depois...\n");
    // O callback on_pipe_close vai rodar no próximo tick do event loop
    // e vai chamar free(pipe) também
}

// Função B: callback do close assíncrono (roda depois)
void on_pipe_close(Pipe* pipe) {
    printf("  [callback] Liberando pipe...\n");  // não toca em pipe->fd: já foi liberado
    free(pipe);  // Segundo free - mas o escopo já liberou!
}

void spawn_subprocess() {
    Pipe* pipe = malloc(sizeof(Pipe));
    pipe->fd = 42;
    strcpy(pipe->name, "stdout");

    // Agenda close assíncrono - o callback vai liberar o pipe depois
    close_pipe_async(pipe);

    // Mas no fim do escopo, o pipe também é liberado
    // Em Zig/C, é fácil esquecer que o callback já é dono
    printf("  [escopo] Liberando pipe (fd=%d)...\n", pipe->fd);
    free(pipe);  // Primeiro free

    // ... depois, no próximo tick do event loop:
    on_pipe_close(pipe);  // Segundo free - DOUBLE-FREE!
}

int main() {
    spawn_subprocess();
    return 0;
}

Duas funções diferentes, dois caminhos que ambos acham que são donos do mesmo ponteiro. Na prática, é assim que double-free acontece: um caminho assíncrono que libera, e um caminho síncrono que também libera, e nenhum dos dois sabe do outro.

No Bun, esse bug veio do padrão que o código acima mostra: uv_close agenda um close assíncrono, e o escopo que chamou close também libera o ponteiro. O adversarial review do Jarred pegou esse bug antes do merge - o fix foi Box::leak(pipe) pra transferir ownership pro callback (commit f0a454376c7).

Memory leak

Leak é alocar memória e nunca devolver. Não crasha. Só vai comendo RAM até o processo morrer ou o SO matá-lo.

O padrão mais comum: error paths. Você testa o caminho feliz. O caminho de erro ninguém testa.

// Compile: gcc -g leak.c -o leak && ./leak
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int read_config(const char* path) {
    char* buffer = malloc(4096);
    char* temp = malloc(1024);

    if (strcmp(path, "invalid") == 0) {
        // Erro! Mas buffer e temp foram alocados e nunca liberados.
        // No caminho feliz, tem free() no final.
        // Aqui? Esqueceu.
        return -1;
    }

    // ... processa config ...
    free(buffer);
    free(temp);
    return 0;
}

int main() {
    read_config("invalid");
    // buffer e temp vazados. Se isso roda num servidor 24/7,
    // são 5 KB por chamada. Mil chamadas = 5 MB. Um milhão = 5 GB.
    return 0;
}

O bug mais sutil do Bun foi um reference count underflow: o contador de referências ia abaixo de zero e virava um número imenso (4.294.967.295 em unsigned). O GC achava que ainda tinha bilhões de referências e nunca coletava o objeto. O fs.watch() vazava permanentemente por causa disso.

Os leaks reais:

  • crypto.scrypt - buffers de callback e senha/salt protegidos nunca liberados quando a alocação do buffer de saída falha.
  • SSLWrapper.init - strdup da passphrase vazava nos error paths.
  • tlsSocket.setSession() - cada chamada vazava um SSL_SESSION (~6.5 KB). Faltava SSL_SESSION_free após d2i_SSL_SESSION.
  • fs.watch() - reference count underflow fixava watchers como GC root permanentemente. Nunca coletados, mesmo após .close().
  • DuplexUpgradeContext - leak completo por tls.connect({ socket: duplex }). Nunca liberado.

O Bun melhorou a integração com LeakSanitizer pra rastrear alocações de memória nativa - mas como vimos na seção de use-after-free, sanitizers são diagnóstico, não prevenção.

RAII: o conceito que resolve tudo isso

Até agora parece que C é um pesadelo e a gente deveria desistir. Mas a solução existe e é mais velha que muita gente lendo esse post.

RAII é um padrão que vem dos primórdios do C++, batizado por Bjarne Stroustrup (o criador da linguagem). O nome é horrível: Resource Acquisition Is Initialization. A ideia é simples: o recurso é adquirido na construção do objeto e liberado na destruição. Automático. Sem esquecer.

Vamos ver a diferença na prática.

Sem RAII (C)

Em C, você é responsável por cada malloc() e cada free(). Cada error path é um lugar onde você pode esquecer.

// Compile: gcc -g no_raii.c -o no_raii && ./no_raii
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char* data;
    size_t len;
} Buffer;

Buffer* buf_create(size_t size) {
    Buffer* b = malloc(sizeof(Buffer));
    if (!b) return NULL;

    b->data = malloc(size);
    if (!b->data) {
        free(b);      // tem que lembrar de liberar b se data falhar
        return NULL;
    }
    b->len = size;
    return b;
}

void buf_destroy(Buffer* b) {
    if (b) {
        free(b->data);
        free(b);
    }
}

int process(const char* path) {
    Buffer* buf = buf_create(4096);
    if (!buf) return -1;

    Buffer* extra = buf_create(1024);
    if (!extra) {
        buf_destroy(buf);   // tem que lembrar
        return -1;
    }

    if (strcmp(path, "bad") == 0) {
        buf_destroy(extra);  // tem que lembrar
        buf_destroy(buf);    // tem que lembrar
        return -1;
    }

    printf("  OK: %s\n", path);
    buf_destroy(extra);
    buf_destroy(buf);
    return 0;
}

int main() {
    process("good");
    process("bad");
    return 0;
}

3 recursos, 2 error paths, 6 lugares pra lembrar de liberar. Esqueceu um? Leak. É escala linear com o número de recursos.

Com RAII (C++)

Em C++, destrutores rodam automaticamente quando o objeto sai de escopo. Mesmo em error paths. Mesmo com exceções.

// Compile: g++ -g raii.cpp -o raii && ./raii
#include <iostream>
#include <memory>
#include <string>

class Buffer {
    std::unique_ptr<char[]> data;
    size_t len;
public:
    explicit Buffer(size_t size)
        : data(std::make_unique<char[]>(size)), len(size) {
        std::cout << "  + Buffer alocado (" << len << " bytes)\n";
    }

    ~Buffer() {
        std::cout << "  - Buffer liberado (" << len << " bytes)\n";
    }

    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;
};

int process(const std::string& path) {
    Buffer buf(4096);
    Buffer extra(1024);

    if (path == "bad") {
        std::cout << "  Erro! Saindo...\n";
        return -1;
        // buf e extra são DESTRUÍDOS automaticamente aqui.
        // Mesmo no error path. Impossível esquecer.
    }

    std::cout << "  OK: " << path << "\n";
    return 0;
    // buf e extra são DESTRUÍDOS automaticamente aqui também.
}

int main() {
    std::cout << "=== Caminho feliz ===\n";
    process("good");

    std::cout << "\n=== Error path ===\n";
    process("bad");

    return 0;
}

Rode e veja:

=== Caminho feliz ===
  + Buffer alocado (4096 bytes)
  + Buffer alocado (1024 bytes)
  OK: good
  - Buffer liberado (1024 bytes)
  - Buffer liberado (4096 bytes)

=== Error path ===
  + Buffer alocado (4096 bytes)
  + Buffer alocado (1024 bytes)
  Erro! Saindo...
  - Buffer liberado (1024 bytes)
  - Buffer liberado (4096 bytes)

Os destrutores rodam em todos os caminhos. Feliz, triste, exceção, early return. O compilador garante. Não tem como esquecer.

A mesma ideia resolve o bug do SSL_SESSION que vazava 6.5 KB por chamada no Bun:

// Compile: g++ -g ssl_session.cpp -o ssl_session && ./ssl_session
#include <iostream>
#include <cstdlib>

class SSLSession {
    void* session;
public:
    explicit SSLSession() {
        session = malloc(6500);  // simula d2i_SSL_SESSION
        std::cout << "  + SSL_SESSION alocado\n";
    }
    ~SSLSession() {
        free(session);  // simula SSL_SESSION_free
        std::cout << "  - SSL_SESSION liberado\n";
    }
    SSLSession(const SSLSession&) = delete;
};

void set_session() {
    SSLSession sess;
    // sess será destruída ao sair do escopo
    // IMPOSSÍVEL esquecer de liberar
}

int main() {
    set_session();  // aloca e libera automaticamente, sem SSL_SESSION_free na mão
    return 0;
}

Se o desenvolvedor esquecesse de chamar SSL_SESSION_free() na mão, vazava. Com RAII, o destrutor faz isso automaticamente. É o compilador garantindo o que a memória humana esquece.

RAII em Rust: Drop + borrow checker

Rust adota RAII com Drop - o equivalente ao destrutor de C++. A diferença é que Rust vai além: o borrow checker impede use-after-free em tempo de compilação.

Em C++, RAII resolve leaks e double-free. Mas use-after-free ainda é possível - um ponteiro cru pode apontar pra memória que o destrutor já liberou. Em Rust, o borrow checker fecha essa porta: ele não deixa duas referências mutáveis pro mesmo dado coexistirem.

Aquele bug de reentrância do http2 - pegar uma referência mutável pro stream e, no meio, deixar o JS mutar a mesma estrutura - em Rust nem compila. O compilador recusa com error[E0499]: cannot borrow as mutable more than once at a time. Não é disciplina, não é convenção, não é um teste que você precisa lembrar de rodar: o código simplesmente não passa pelo compilador.

O que em Zig era um crash não-determinístico em produção, em Rust vira um erro de compilação determinístico.

O time do Bun até tentou emular isso em Zig, com smart pointers caseiros:

fn foo(a_ptr: SharedPtr(TCPSocket)) !void {
  const a: *TCPSocket = a_ptr.get();
  defer a_ptr.deref();

  const b = try do_something_with_a(a);
  defer b.deref();

  // ...
}

Repara que cada recurso exige um defer escrito na mão - e alguém tem que lembrar de escrever cada um. O próprio Jarred admite:

“Homegrown smart pointers offer worse ergonomics than Rust, with none of the guarantees.”

Resumo

Adaptado da tabela do post do Jarred, os mecanismos de cleanup de cada linguagem:

Linguagem Mecanismo de cleanup Garantia
Zig defer, errdefer Manual - você escreve, você pode esquecer
C free() explícito Manual - cada error path precisa ser auditado
C++ ~Destructor, std::unique_ptr RAII - automático no escopo
Rust Drop, ownership + borrow checker RAII + verificado em compile-time

E o que cada conceito resolve na prática:

Conceito O que resolve Sem ele
RAII / Drop Memory leaks em error paths, double-free defer manual que pode ser esquecido
Ownership Double-free - só um dono libera Qualquer um pode liberar
Borrow checker Use-after-free por reentrância Crash não-determinístico em produção
Lifetimes Use-after-free por referência expirada Referência pra memória que já foi liberada

Tirando o barulho, a migração do Bun é sobre uma coisa só: a classe de bug que aparece quando GC e memória manual se misturam, e qual ferramenta a impede por construção. RAII cuida de leaks e double-free. O borrow checker cuida de use-after-free por reentrância.

Vale um contraponto. O Andrew Kelley, criador do Zig, respondeu à migração dizendo que o problema nunca foi a linguagem - foi qualidade de código. E ele tem um ponto: a TigerBeetle escreve Zig sem esses bugs. Dá pra evitar tudo isso em Zig. O próprio Jarred admite que não culpa o Zig.

Mas “dá pra evitar com disciplina” e “o compilador não deixa acontecer” são coisas diferentes. É a mesma diferença lá do começo: o sanitizer que você precisa rodar no caminho certo, versus o borrow checker que pega sempre. Kelley aposta no time; o Rust aposta em não precisar de um time perfeito pra essa classe de bug.

Os bugs listados no post do Jarred? Em Rust seguro, quase nenhum sobreviveria. Use-after-free e double-free viram erro de compilação - o borrow checker e o ownership recusam. Os leaks, o Drop previne sozinho.

Por hoje é só.