Os erros que fizeram Bun migrar de Zig para Rust
🇬🇧 Read it in English
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:
- Memória gerenciada pelo GC - objetos JS, strings,
ArrayBuffers que o garbage collector do JavaScriptCore controla - 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-freeao 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()detachaArrayBufferentre captura do payload e envio real.Buffer#copy/Buffer#fill-valueOf()detacha/redimensionaArrayBufferdurante 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-strdupda passphrase vazava nos error paths.tlsSocket.setSession()- cada chamada vazava umSSL_SESSION(~6.5 KB). FaltavaSSL_SESSION_freeapósd2i_SSL_SESSION.fs.watch()- reference count underflow fixava watchers como GC root permanentemente. Nunca coletados, mesmo após.close().DuplexUpgradeContext- leak completo portls.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ó.