The Bugs That Made Bun Migrate From Zig to Rust
🇧🇷 Leia em Português
In May 2026, Bun’s 535,000 lines of Zig became Rust in a single PR with 6,755 commits. The community split.
The detail that explains the 6,755 commits: Jarred Sumner, Bun’s creator, didn’t write it by hand. He orchestrated around 50 Claude Code workflows running non-stop for 11 days.
And days before merging, he was still treating the whole thing as an experiment - “There’s a very high chance all this code gets thrown out completely.” It got merged anyway. What unlocked it was empirical: when 100% of the test suite passed on every platform, his opinion went from “worth trying” to “I’m merging this”.
In the post about the migration, Jarred presents a list of bugs that lived in the codebase - use-after-free, double-free, memory leak - all from the same class of problem, which he claims is easier to prevent in Rust.
Let’s go through these bugs and what they mean.
The class of problem
Bun is a JavaScript runtime. It manages two worlds of memory at the same time:
- GC-managed memory - JS objects, strings,
ArrayBuffers that JavaScriptCore’s garbage collector controls - Manually managed memory - pointers, buffers, C/C++/Zig handles that you allocate and free yourself
The problem is when the two worlds mix. JS code can come back in the middle of a native operation (reentrancy). Callbacks like valueOf() and toString() can run arbitrary code. Errors can exit through paths nobody tested. And then:
- You free memory that is still in use (use-after-free)
- You free the same memory twice (double-free)
- You forget to free (memory leak)
Let’s take each one.
Use-after-free
In garbage-collected languages, when you no longer need an object, it just goes away. The GC handles everything.
In C, there is no GC. You ask the system for memory with malloc() and give it back with free(). After free(), that address is no longer yours. But the pointer still points there.
// Compile: gcc -fsanitize=address -g uaf.c -o uaf && ./uaf
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* cache; // a reference stashed away for later reuse
void processa(char* msg) {
printf("Processing: %s\n", msg);
cache = msg; // stash the pointer
free(msg); // and give the memory back to the system
}
int main() {
char* msg = malloc(100);
strcpy(msg, "hello world");
processa(msg);
// later, another piece of code uses what's in the cache:
printf("From cache: %s\n", cache); // use-after-free! cache points to freed memory
return 0;
}
It compiles without errors. It may even seem to work. But the behavior is undefined - it can print garbage, it can crash, it can look fine for months and blow up in production.
AddressSanitizer (-fsanitize=address) catches this kind of bug on the spot. Run with it and you’ll see:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
Diagnosis vs prevention
Great. Amazing tool. So why not stay in C/C++ with sanitizers?
Because a sanitizer is diagnosis. Rust is prevention.
You might think that passing -fsanitize=address to the compiler makes this a compile-time thing. It doesn’t: the compiler doesn’t analyze whether the bug can exist, it just instruments the binary with checks that run along with the program. The flag is a compile flag; the detection happens at runtime.
A sanitizer runs the code and tells you: “here, on this specific input, on this specific execution path, at that moment, you accessed freed memory”. If the path with the bug never runs, the sanitizer catches nothing. It’s a testing tool - it only finds bugs on the paths you actually execute.
Rust’s borrow checker is static analysis. It tells you: “this code can have a use-after-free, no matter the input, the path, or the moment”. It catches the bug before the code exists. Before compiling. Before running. Before production.
Static analysis is not exclusive to Rust - your editor may have caught the example above. But in C it’s heuristic: it nails the easy, straight-line case and misses the hard ones. The borrow checker is neither optional nor a guess - it either proves the code is safe, or it doesn’t compile.
It’s the difference between a blood test and a vaccine. A sanitizer detects the disease after it shows up. Rust keeps it from showing up.
How this bit Bun
In Bun, the reentrancy bugs were non-deterministic. They depend on timing, on which callback runs first, on how many requests are in flight. A sanitizer can run a thousand tests and catch nothing, because the timing never lined up. The borrow checker catches it every time, because the problem is structural - two mutable references to the same data at the same time is not a timing issue, it’s a design issue.
A good share of the bugs listed in Bun are use-after-free. The most common pattern is reentrancy: JS code comes back in the middle of a native operation and invalidates the state the operation was using. Example: a hashmap that grows and reallocates everything internally, leaving dangling pointers to the old memory.
The real bugs:
node:zlib-heap-use-after-freewhen calling.reset()while an async.write()is still running on the threadpool.valueOf()/toString()as the attack vector.node:http2- reentrant JS callbacks (session.request()inside a listener) trigger a hashmap rehash, invalidating internal stream pointers.UDPSocket.send()/sendMany()-valueOf()detaches theArrayBufferbetween capturing the payload and actually sending it.Buffer#copy/Buffer#fill-valueOf()detaches/resizes theArrayBufferduring argument coercion.
Double-free
If use-after-free is accessing memory after freeing it, double-free is freeing the same memory twice. But it’s not as simple as free(ptr); free(ptr); in the same function. Nobody does that. The problem is when two owners don’t know about each other.
// 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;
// Function A: schedules an async close
// In practice, this would be libuv scheduling a callback for the next tick
void close_pipe_async(Pipe* pipe) {
printf(" [async] Close scheduled. Callback will run later...\n");
// The on_pipe_close callback will run on the next tick of the event loop
// and will call free(pipe) as well
}
// Function B: the async close callback (runs later)
void on_pipe_close(Pipe* pipe) {
printf(" [callback] Freeing pipe...\n"); // doesn't touch pipe->fd: it's already been freed
free(pipe); // Second free - but the scope already freed it!
}
void spawn_subprocess() {
Pipe* pipe = malloc(sizeof(Pipe));
pipe->fd = 42;
strcpy(pipe->name, "stdout");
// Schedule the async close - the callback will free the pipe later
close_pipe_async(pipe);
// But at the end of the scope, the pipe is freed too
// In Zig/C, it's easy to forget the callback already owns it
printf(" [scope] Freeing pipe (fd=%d)...\n", pipe->fd);
free(pipe); // First free
// ... later, on the next tick of the event loop:
on_pipe_close(pipe); // Second free - DOUBLE-FREE!
}
int main() {
spawn_subprocess();
return 0;
}
Two different functions, two paths that both believe they own the same pointer. In practice, that’s how double-free happens: an async path that frees, and a sync path that also frees, and neither knows about the other.
In Bun, this bug came from the exact pattern the code above shows: uv_close schedules an async close, and the scope that called close also frees the pointer. Jarred’s adversarial review caught this bug before the merge - the fix was Box::leak(pipe) to transfer ownership to the callback (commit f0a454376c7).
Memory leak
A leak is allocating memory and never giving it back. It doesn’t crash. It just keeps eating RAM until the process dies or the OS kills it.
The most common pattern: error paths. You test the happy path. Nobody tests the error path.
// 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) {
// Error! But buffer and temp were allocated and never freed.
// On the happy path, there's a free() at the end.
// Here? Forgotten.
return -1;
}
// ... process config ...
free(buffer);
free(temp);
return 0;
}
int main() {
read_config("invalid");
// buffer and temp leaked. If this runs on a 24/7 server,
// that's 5 KB per call. A thousand calls = 5 MB. A million = 5 GB.
return 0;
}
Bun’s most subtle bug was a reference count underflow: the reference counter went below zero and wrapped into a huge number (4,294,967,295 in unsigned). The GC thought there were still billions of references and never collected the object. fs.watch() leaked permanently because of this.
The real leaks:
crypto.scrypt- callback buffers and protected password/salt never freed when the output buffer allocation fails.SSLWrapper.init- thestrdupof the passphrase leaked on error paths.tlsSocket.setSession()- every call leaked anSSL_SESSION(~6.5 KB). MissingSSL_SESSION_freeafterd2i_SSL_SESSION.fs.watch()- reference count underflow pinned watchers as GC roots permanently. Never collected, even after.close().DuplexUpgradeContext- full leak viatls.connect({ socket: duplex }). Never freed.
Bun improved its integration with LeakSanitizer to track native memory allocations - but as we saw in the use-after-free section, sanitizers are diagnosis, not prevention.
RAII: the concept that solves all of this
By now it sounds like C is a nightmare and we should all give up. But the solution exists, and it’s older than many people reading this post.
RAII is a pattern from the early days of C++, named by Bjarne Stroustrup (the creator of the language). The name is terrible: Resource Acquisition Is Initialization. The idea is simple: the resource is acquired when the object is constructed and released when it’s destroyed. Automatic. Nothing to forget.
Let’s see the difference in practice.
Without RAII (C)
In C, you are responsible for every malloc() and every free(). Every error path is a place where you can forget.
// 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); // have to remember to free b if data fails
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); // have to remember
return -1;
}
if (strcmp(path, "bad") == 0) {
buf_destroy(extra); // have to remember
buf_destroy(buf); // have to remember
return -1;
}
printf(" OK: %s\n", path);
buf_destroy(extra);
buf_destroy(buf);
return 0;
}
int main() {
process("good");
process("bad");
return 0;
}
3 resources, 2 error paths, 6 places where you have to remember to free. Forget one? Leak. It scales linearly with the number of resources.
With RAII (C++)
In C++, destructors run automatically when the object goes out of scope. Even on error paths. Even with exceptions.
// 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 allocated (" << len << " bytes)\n";
}
~Buffer() {
std::cout << " - Buffer freed (" << 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 << " Error! Bailing out...\n";
return -1;
// buf and extra are DESTROYED automatically here.
// Even on the error path. Impossible to forget.
}
std::cout << " OK: " << path << "\n";
return 0;
// buf and extra are DESTROYED automatically here too.
}
int main() {
std::cout << "=== Happy path ===\n";
process("good");
std::cout << "\n=== Error path ===\n";
process("bad");
return 0;
}
Run it and see:
=== Happy path ===
+ Buffer allocated (4096 bytes)
+ Buffer allocated (1024 bytes)
OK: good
- Buffer freed (1024 bytes)
- Buffer freed (4096 bytes)
=== Error path ===
+ Buffer allocated (4096 bytes)
+ Buffer allocated (1024 bytes)
Error! Bailing out...
- Buffer freed (1024 bytes)
- Buffer freed (4096 bytes)
The destructors run on every path. Happy, sad, exception, early return. The compiler guarantees it. There is no way to forget.
The same idea fixes the SSL_SESSION bug that leaked 6.5 KB per call in 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); // simulates d2i_SSL_SESSION
std::cout << " + SSL_SESSION allocated\n";
}
~SSLSession() {
free(session); // simulates SSL_SESSION_free
std::cout << " - SSL_SESSION freed\n";
}
SSLSession(const SSLSession&) = delete;
};
void set_session() {
SSLSession sess;
// sess is destroyed when it goes out of scope
// IMPOSSIBLE to forget to free
}
int main() {
set_session(); // allocates and frees automatically, no hand-written SSL_SESSION_free
return 0;
}
If the developer forgot to call SSL_SESSION_free() by hand, it leaked. With RAII, the destructor does it automatically. It’s the compiler guaranteeing what human memory forgets.
RAII in Rust: Drop + borrow checker
Rust adopts RAII through Drop - the equivalent of a C++ destructor. The difference is that Rust goes further: the borrow checker prevents use-after-free at compile time.
In C++, RAII solves leaks and double-free. But use-after-free is still possible - a raw pointer can point to memory the destructor already released. In Rust, the borrow checker closes that door: it won’t let two mutable references to the same data coexist.
That http2 reentrancy bug - taking a mutable reference to the stream and, midway through, letting JS mutate the same structure - doesn’t even compile in Rust. The compiler refuses with error[E0499]: cannot borrow as mutable more than once at a time. It’s not discipline, it’s not convention, it’s not a test you have to remember to run: the code simply doesn’t get past the compiler.
What in Zig was a non-deterministic crash in production, in Rust becomes a deterministic compile error.
The Bun team even tried to emulate this in Zig, with homegrown smart pointers:
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();
// ...
}
Notice that every resource requires a hand-written defer - and someone has to remember to write each one. Jarred himself admits it:
“Homegrown smart pointers offer worse ergonomics than Rust, with none of the guarantees.”
Summary
Adapted from the table in Jarred’s post, the cleanup mechanism of each language:
| Language | Cleanup mechanism | Guarantee |
|---|---|---|
| Zig | defer, errdefer |
Manual - you write it, you can forget it |
| C | explicit free() |
Manual - every error path needs auditing |
| C++ | ~Destructor, std::unique_ptr |
RAII - automatic on scope exit |
| Rust | Drop, ownership + borrow checker |
RAII + verified at compile time |
And what each concept solves in practice:
| Concept | What it solves | Without it |
|---|---|---|
| RAII / Drop | Memory leaks on error paths, double-free | Manual defer that can be forgotten |
| Ownership | Double-free - only one owner frees | Anyone can free |
| Borrow checker | Use-after-free from reentrancy | Non-deterministic crash in production |
| Lifetimes | Use-after-free from expired references | Reference to memory that was already freed |
Strip away the noise and Bun’s migration is about one thing: the class of bug that shows up when GC and manual memory mix, and which tool prevents it by construction. RAII takes care of leaks and double-free. The borrow checker takes care of use-after-free from reentrancy.
A counterpoint is worth mentioning. Andrew Kelley, Zig’s creator, responded to the migration saying the problem was never the language - it was code quality. And he has a point: TigerBeetle writes Zig without these bugs. You can avoid all of this in Zig. Jarred himself admits he doesn’t blame Zig.
But “you can avoid it with discipline” and “the compiler won’t let it happen” are different things. It’s the same difference from the beginning of this post: the sanitizer you need to run on the right path, versus the borrow checker that catches it every time. Kelley bets on the team; Rust bets on not needing a perfect team for this class of bug.
The bugs listed in Jarred’s post? In safe Rust, almost none would survive. Use-after-free and double-free become compile errors - the borrow checker and ownership refuse them. The leaks, Drop prevents on its own.
Thanks for reading!